diff --git a/app/src/main/java/awais/instagrabber/adapters/DiscoverTopicsAdapter.java b/app/src/main/java/awais/instagrabber/adapters/DiscoverTopicsAdapter.java index fc4e0cce..4623e230 100644 --- a/app/src/main/java/awais/instagrabber/adapters/DiscoverTopicsAdapter.java +++ b/app/src/main/java/awais/instagrabber/adapters/DiscoverTopicsAdapter.java @@ -40,7 +40,7 @@ public class DiscoverTopicsAdapter extends ListAdapter { + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final SavedCollection oldItem, @NonNull final SavedCollection newItem) { + return oldItem.getId().equals(newItem.getId()); + } + + @Override + public boolean areContentsTheSame(@NonNull final SavedCollection oldItem, @NonNull final SavedCollection newItem) { + if (oldItem.getCoverMedias().size() == newItem.getCoverMedias().size()) { + if (oldItem.getCoverMedias().size() == 0) return true; + return oldItem.getCoverMedias().get(0).getId().equals(newItem.getCoverMedias().get(0).getId()); + } + return false; + } + }; + + private final OnCollectionClickListener onCollectionClickListener; + + public SavedCollectionsAdapter(final OnCollectionClickListener onCollectionClickListener) { + super(DIFF_CALLBACK); + this.onCollectionClickListener = onCollectionClickListener; + } + + @NonNull + @Override + public TopicClusterViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + final ItemDiscoverTopicBinding binding = ItemDiscoverTopicBinding.inflate(layoutInflater, parent, false); + return new TopicClusterViewHolder(binding, null, onCollectionClickListener); + } + + @Override + public void onBindViewHolder(@NonNull final TopicClusterViewHolder holder, final int position) { + final SavedCollection topicCluster = getItem(position); + holder.bind(topicCluster); + } + + public interface OnCollectionClickListener { + void onCollectionClick(SavedCollection savedCollection, View root, View cover, View title, int titleColor, int backgroundColor); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/TopicClusterViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/TopicClusterViewHolder.java index e4526eea..ffeefa17 100644 --- a/app/src/main/java/awais/instagrabber/adapters/viewholder/TopicClusterViewHolder.java +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/TopicClusterViewHolder.java @@ -25,19 +25,24 @@ import java.util.concurrent.atomic.AtomicInteger; import awais.instagrabber.R; import awais.instagrabber.adapters.DiscoverTopicsAdapter; +import awais.instagrabber.adapters.SavedCollectionsAdapter; import awais.instagrabber.databinding.ItemDiscoverTopicBinding; import awais.instagrabber.repositories.responses.discover.TopicCluster; +import awais.instagrabber.repositories.responses.saved.SavedCollection; import awais.instagrabber.utils.ResponseBodyUtils; public class TopicClusterViewHolder extends RecyclerView.ViewHolder { private final ItemDiscoverTopicBinding binding; private final DiscoverTopicsAdapter.OnTopicClickListener onTopicClickListener; + private final SavedCollectionsAdapter.OnCollectionClickListener onCollectionClickListener; public TopicClusterViewHolder(@NonNull final ItemDiscoverTopicBinding binding, - final DiscoverTopicsAdapter.OnTopicClickListener onTopicClickListener) { + final DiscoverTopicsAdapter.OnTopicClickListener onTopicClickListener, + final SavedCollectionsAdapter.OnCollectionClickListener onCollectionClickListener) { super(binding.getRoot()); this.binding = binding; this.onTopicClickListener = onTopicClickListener; + this.onCollectionClickListener = onCollectionClickListener; } public void bind(final TopicCluster topicCluster) { @@ -102,4 +107,69 @@ public class TopicClusterViewHolder extends RecyclerView.ViewHolder { } binding.title.setText(topicCluster.getTitle()); } + + public void bind(final SavedCollection topicCluster) { + if (topicCluster == null) { + return; + } + final AtomicInteger titleColor = new AtomicInteger(-1); + final AtomicInteger backgroundColor = new AtomicInteger(-1); + if (onCollectionClickListener != null) { + itemView.setOnClickListener(v -> onCollectionClickListener.onCollectionClick( + topicCluster, + binding.getRoot(), + binding.cover, + binding.title, + titleColor.get(), + backgroundColor.get() + )); + } + // binding.title.setTransitionName("title-" + topicCluster.getId()); + binding.cover.setTransitionName("cover-" + topicCluster.getId()); + final String thumbUrl = ResponseBodyUtils.getThumbUrl(topicCluster.getCoverMedias() == null + ? null + : topicCluster.getCoverMedias().get(0)); + if (thumbUrl == null) { + binding.cover.setImageURI((String) null); + } else { + final ImageRequest imageRequest = ImageRequestBuilder + .newBuilderWithSource(Uri.parse(thumbUrl)) + .build(); + final ImagePipeline imagePipeline = Fresco.getImagePipeline(); + final DataSource> dataSource = imagePipeline + .fetchDecodedImage(imageRequest, CallerThreadExecutor.getInstance()); + dataSource.subscribe(new BaseBitmapDataSubscriber() { + @Override + public void onNewResultImpl(@Nullable Bitmap bitmap) { + if (dataSource.isFinished()) { + dataSource.close(); + } + if (bitmap != null) { + Palette.from(bitmap).generate(p -> { + final Palette.Swatch swatch = p.getDominantSwatch(); + final Resources resources = itemView.getResources(); + int titleTextColor = resources.getColor(R.color.white); + if (swatch != null) { + backgroundColor.set(swatch.getRgb()); + GradientDrawable gd = new GradientDrawable( + GradientDrawable.Orientation.TOP_BOTTOM, + new int[]{Color.TRANSPARENT, backgroundColor.get()}); + titleTextColor = swatch.getTitleTextColor(); + binding.background.setBackground(gd); + } + titleColor.set(titleTextColor); + binding.title.setTextColor(titleTextColor); + }); + } + } + + @Override + public void onFailureImpl(@NonNull DataSource dataSource) { + dataSource.close(); + } + }, CallerThreadExecutor.getInstance()); + binding.cover.setImageRequest(imageRequest); + } + binding.title.setText(topicCluster.getTitle()); + } } diff --git a/app/src/main/java/awais/instagrabber/asyncs/SavedPostFetchService.java b/app/src/main/java/awais/instagrabber/asyncs/SavedPostFetchService.java index e59defcb..9b3511a9 100644 --- a/app/src/main/java/awais/instagrabber/asyncs/SavedPostFetchService.java +++ b/app/src/main/java/awais/instagrabber/asyncs/SavedPostFetchService.java @@ -19,12 +19,14 @@ public class SavedPostFetchService implements PostFetcher.PostFetchService { private final boolean isLoggedIn; private String nextMaxId; + private final String collectionId; private boolean moreAvailable; - public SavedPostFetchService(final long profileId, final PostItemType type, final boolean isLoggedIn) { + public SavedPostFetchService(final long profileId, final PostItemType type, final boolean isLoggedIn, final String collectionId) { this.profileId = profileId; this.type = type; this.isLoggedIn = isLoggedIn; + this.collectionId = collectionId; graphQLService = isLoggedIn ? null : GraphQLService.getInstance(); profileService = isLoggedIn ? ProfileService.getInstance() : null; } @@ -58,10 +60,12 @@ public class SavedPostFetchService implements PostFetcher.PostFetchService { if (isLoggedIn) profileService.fetchTagged(profileId, nextMaxId, callback); else graphQLService.fetchTaggedPosts(profileId, 30, nextMaxId, callback); break; + case COLLECTION: case SAVED: - default: - profileService.fetchSaved(nextMaxId, callback); + profileService.fetchSaved(nextMaxId, collectionId, callback); break; + default: + callback.onFailure(null); } } diff --git a/app/src/main/java/awais/instagrabber/fragments/CollectionPostsFragment.java b/app/src/main/java/awais/instagrabber/fragments/CollectionPostsFragment.java new file mode 100644 index 00000000..82487b3c --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/CollectionPostsFragment.java @@ -0,0 +1,432 @@ +package awais.instagrabber.fragments; + +import android.animation.ArgbEvaluator; +import android.content.Context; +import android.content.pm.PackageManager; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.Animatable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.os.Bundle; +import android.os.Handler; +import android.view.ActionMode; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; + +import androidx.activity.OnBackPressedCallback; +import androidx.activity.OnBackPressedDispatcher; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.content.PermissionChecker; +import androidx.core.graphics.ColorUtils; +import androidx.fragment.app.Fragment; +import androidx.navigation.NavController; +import androidx.navigation.NavDirections; +import androidx.navigation.fragment.NavHostFragment; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; +import androidx.transition.ChangeBounds; +import androidx.transition.TransitionInflater; +import androidx.transition.TransitionSet; + +import com.facebook.drawee.backends.pipeline.Fresco; +import com.facebook.drawee.controller.BaseControllerListener; +import com.facebook.drawee.interfaces.DraweeController; +import com.facebook.imagepipeline.image.ImageInfo; +import com.google.common.collect.ImmutableList; + +import java.util.Set; + +import awais.instagrabber.R; +import awais.instagrabber.activities.MainActivity; +import awais.instagrabber.adapters.FeedAdapterV2; +import awais.instagrabber.asyncs.SavedPostFetchService; +import awais.instagrabber.customviews.PrimaryActionModeCallback; +import awais.instagrabber.databinding.FragmentCollectionPostsBinding; +import awais.instagrabber.dialogs.PostsLayoutPreferencesDialogFragment; +import awais.instagrabber.fragments.CollectionPostsFragmentDirections; +import awais.instagrabber.models.PostsLayoutPreferences; +import awais.instagrabber.models.enums.PostItemType; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.saved.SavedCollection; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.DownloadUtils; +import awais.instagrabber.utils.ResponseBodyUtils; +import awais.instagrabber.utils.Utils; + +import static androidx.core.content.PermissionChecker.checkSelfPermission; +import static awais.instagrabber.utils.DownloadUtils.WRITE_PERMISSION; + +public class CollectionPostsFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { + private static final int STORAGE_PERM_REQUEST_CODE = 8020; + private static final int STORAGE_PERM_REQUEST_CODE_FOR_SELECTION = 8030; + + private MainActivity fragmentActivity; + private FragmentCollectionPostsBinding binding; + private CoordinatorLayout root; + private boolean shouldRefresh = true; + private SavedCollection savedCollection; + private ActionMode actionMode; + private Set selectedFeedModels; + private Media downloadFeedModel; + private int downloadChildPosition = -1; + private PostsLayoutPreferences layoutPreferences = Utils.getPostsLayoutPreferences(Constants.PREF_SAVED_POSTS_LAYOUT); + + private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { + @Override + public void handleOnBackPressed() { + binding.posts.endSelection(); + } + }; + private final PrimaryActionModeCallback multiSelectAction = new PrimaryActionModeCallback( + R.menu.multi_select_download_menu, new PrimaryActionModeCallback.CallbacksHelper() { + @Override + public void onDestroy(final ActionMode mode) { + binding.posts.endSelection(); + } + + @Override + public boolean onActionItemClicked(final ActionMode mode, + final MenuItem item) { + if (item.getItemId() == R.id.action_download) { + if (CollectionPostsFragment.this.selectedFeedModels == null) return false; + final Context context = getContext(); + if (context == null) return false; + if (checkSelfPermission(context, WRITE_PERMISSION) == PermissionChecker.PERMISSION_GRANTED) { + DownloadUtils.download(context, ImmutableList.copyOf(CollectionPostsFragment.this.selectedFeedModels)); + binding.posts.endSelection(); + return true; + } + requestPermissions(DownloadUtils.PERMS, STORAGE_PERM_REQUEST_CODE_FOR_SELECTION); + } + return false; + } + }); + private final FeedAdapterV2.FeedItemCallback feedItemCallback = new FeedAdapterV2.FeedItemCallback() { + @Override + public void onPostClick(final Media feedModel, final View profilePicView, final View mainPostImage) { + openPostDialog(feedModel, profilePicView, mainPostImage, -1); + } + + @Override + public void onSliderClick(final Media feedModel, final int position) { + openPostDialog(feedModel, null, null, position); + } + + @Override + public void onCommentsClick(final Media feedModel) { + final NavDirections commentsAction = CollectionPostsFragmentDirections.actionGlobalCommentsViewerFragment( + feedModel.getCode(), + feedModel.getPk(), + feedModel.getUser().getPk() + ); + NavHostFragment.findNavController(CollectionPostsFragment.this).navigate(commentsAction); + } + + @Override + public void onDownloadClick(final Media feedModel, final int childPosition) { + final Context context = getContext(); + if (context == null) return; + if (checkSelfPermission(context, WRITE_PERMISSION) == PermissionChecker.PERMISSION_GRANTED) { + DownloadUtils.showDownloadDialog(context, feedModel, childPosition); + return; + } + downloadFeedModel = feedModel; + downloadChildPosition = -1; + requestPermissions(DownloadUtils.PERMS, STORAGE_PERM_REQUEST_CODE); + } + + @Override + public void onHashtagClick(final String hashtag) { + final NavDirections action = CollectionPostsFragmentDirections.actionGlobalHashTagFragment(hashtag); + NavHostFragment.findNavController(CollectionPostsFragment.this).navigate(action); + } + + @Override + public void onLocationClick(final Media feedModel) { + final NavDirections action = CollectionPostsFragmentDirections.actionGlobalLocationFragment(feedModel.getLocation().getPk()); + NavHostFragment.findNavController(CollectionPostsFragment.this).navigate(action); + } + + @Override + public void onMentionClick(final String mention) { + navigateToProfile(mention.trim()); + } + + @Override + public void onNameClick(final Media feedModel, final View profilePicView) { + navigateToProfile("@" + feedModel.getUser().getUsername()); + } + + @Override + public void onProfilePicClick(final Media feedModel, final View profilePicView) { + navigateToProfile("@" + feedModel.getUser().getUsername()); + } + + @Override + public void onURLClick(final String url) { + Utils.openURL(getContext(), url); + } + + @Override + public void onEmailClick(final String emailId) { + Utils.openEmailAddress(getContext(), emailId); + } + + private void openPostDialog(final Media feedModel, + final View profilePicView, + final View mainPostImage, + final int position) { + final PostViewV2Fragment.Builder builder = PostViewV2Fragment + .builder(feedModel); + if (position >= 0) { + builder.setPosition(position); + } + if (!layoutPreferences.isAnimationDisabled()) { + builder.setSharedProfilePicElement(profilePicView) + .setSharedMainPostElement(mainPostImage); + } + builder.build().show(getChildFragmentManager(), "post_view"); + } + }; + private final FeedAdapterV2.SelectionModeCallback selectionModeCallback = new FeedAdapterV2.SelectionModeCallback() { + + @Override + public void onSelectionStart() { + if (!onBackPressedCallback.isEnabled()) { + final OnBackPressedDispatcher onBackPressedDispatcher = fragmentActivity.getOnBackPressedDispatcher(); + onBackPressedCallback.setEnabled(true); + onBackPressedDispatcher.addCallback(getViewLifecycleOwner(), onBackPressedCallback); + } + if (actionMode == null) { + actionMode = fragmentActivity.startActionMode(multiSelectAction); + } + } + + @Override + public void onSelectionChange(final Set selectedFeedModels) { + final String title = getString(R.string.number_selected, selectedFeedModels.size()); + if (actionMode != null) { + actionMode.setTitle(title); + } + CollectionPostsFragment.this.selectedFeedModels = selectedFeedModels; + } + + @Override + public void onSelectionEnd() { + if (onBackPressedCallback.isEnabled()) { + onBackPressedCallback.setEnabled(false); + onBackPressedCallback.remove(); + } + if (actionMode != null) { + actionMode.finish(); + actionMode = null; + } + } + }; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + fragmentActivity = (MainActivity) requireActivity(); + final TransitionSet transitionSet = new TransitionSet(); + transitionSet.addTransition(new ChangeBounds()) + .addTransition(TransitionInflater.from(getContext()).inflateTransition(android.R.transition.move)) + .setDuration(200); + setSharedElementEnterTransition(transitionSet); + postponeEnterTransition(); + setHasOptionsMenu(true); + } + + @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 = FragmentCollectionPostsBinding.inflate(inflater, container, false); + root = binding.getRoot(); + return root; + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + if (!shouldRefresh) return; + binding.swipeRefreshLayout.setOnRefreshListener(this); + init(); + shouldRefresh = false; + } + + @Override + public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { + inflater.inflate(R.menu.topic_posts_menu, menu); + } + + @Override + public boolean onOptionsItemSelected(@NonNull final MenuItem item) { + if (item.getItemId() == R.id.layout) { + showPostsLayoutPreferences(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onResume() { + super.onResume(); + fragmentActivity.setToolbar(binding.toolbar); + } + + @Override + public void onRefresh() { + binding.posts.refresh(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + resetToolbar(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + resetToolbar(); + } + + @Override + public void onRequestPermissionsResult(final int requestCode, @NonNull final String[] permissions, @NonNull final int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + final boolean granted = grantResults[0] == PackageManager.PERMISSION_GRANTED; + final Context context = getContext(); + if (context == null) return; + if (requestCode == STORAGE_PERM_REQUEST_CODE && granted) { + if (downloadFeedModel == null) return; + DownloadUtils.showDownloadDialog(context, downloadFeedModel, downloadChildPosition); + downloadFeedModel = null; + downloadChildPosition = -1; + return; + } + if (requestCode == STORAGE_PERM_REQUEST_CODE_FOR_SELECTION && granted) { + DownloadUtils.download(context, ImmutableList.copyOf(selectedFeedModels)); + binding.posts.endSelection(); + } + } + + private void resetToolbar() { + fragmentActivity.resetToolbar(); + } + + private void init() { + if (getArguments() == null) return; + final CollectionPostsFragmentArgs fragmentArgs = CollectionPostsFragmentArgs.fromBundle(getArguments()); + savedCollection = fragmentArgs.getSavedCollection(); + setupToolbar(fragmentArgs.getTitleColor(), fragmentArgs.getBackgroundColor()); + setupPosts(); + } + + private void setupToolbar(final int titleColor, final int backgroundColor) { + if (savedCollection == null) { + return; + } + binding.cover.setTransitionName("collection-" + savedCollection.getId()); + fragmentActivity.setToolbar(binding.toolbar); + binding.collapsingToolbarLayout.setTitle(savedCollection.getTitle()); + final int collapsedTitleTextColor = ColorUtils.setAlphaComponent(titleColor, 0xFF); + final int expandedTitleTextColor = ColorUtils.setAlphaComponent(titleColor, 0x99); + binding.collapsingToolbarLayout.setExpandedTitleColor(expandedTitleTextColor); + binding.collapsingToolbarLayout.setCollapsedTitleTextColor(collapsedTitleTextColor); + binding.collapsingToolbarLayout.setContentScrimColor(backgroundColor); + final Drawable navigationIcon = binding.toolbar.getNavigationIcon(); + final Drawable overflowIcon = binding.toolbar.getOverflowIcon(); + if (navigationIcon != null && overflowIcon != null) { + final Drawable navDrawable = navigationIcon.mutate(); + final Drawable overflowDrawable = overflowIcon.mutate(); + navDrawable.setAlpha(0xFF); + overflowDrawable.setAlpha(0xFF); + final ArgbEvaluator argbEvaluator = new ArgbEvaluator(); + binding.appBarLayout.addOnOffsetChangedListener((appBarLayout, verticalOffset) -> { + final int totalScrollRange = appBarLayout.getTotalScrollRange(); + final float current = totalScrollRange + verticalOffset; + final float fraction = current / totalScrollRange; + final int tempColor = (int) argbEvaluator.evaluate(fraction, collapsedTitleTextColor, expandedTitleTextColor); + navDrawable.setColorFilter(tempColor, PorterDuff.Mode.SRC_ATOP); + overflowDrawable.setColorFilter(tempColor, PorterDuff.Mode.SRC_ATOP); + + }); + } + final GradientDrawable gd = new GradientDrawable( + GradientDrawable.Orientation.TOP_BOTTOM, + new int[]{Color.TRANSPARENT, backgroundColor}); + binding.background.setBackground(gd); + setupCover(); + } + + private void setupCover() { + final String coverUrl = ResponseBodyUtils.getImageUrl(savedCollection.getCoverMedias() == null + ? null + : savedCollection.getCoverMedias().get(0)); + final DraweeController controller = Fresco + .newDraweeControllerBuilder() + .setOldController(binding.cover.getController()) + .setUri(coverUrl) + .setControllerListener(new BaseControllerListener() { + + @Override + public void onFailure(final String id, final Throwable throwable) { + super.onFailure(id, throwable); + startPostponedEnterTransition(); + } + + @Override + public void onFinalImageSet(final String id, + @Nullable final ImageInfo imageInfo, + @Nullable final Animatable animatable) { + startPostponedEnterTransition(); + } + }) + .build(); + binding.cover.setController(controller); + } + + private void setupPosts() { + binding.posts.setViewModelStoreOwner(this) + .setLifeCycleOwner(this) + .setPostFetchService(new SavedPostFetchService(0, PostItemType.COLLECTION, true, savedCollection.getId())) + .setLayoutPreferences(layoutPreferences) + .addFetchStatusChangeListener(fetching -> updateSwipeRefreshState()) + .setFeedItemCallback(feedItemCallback) + .setSelectionModeCallback(selectionModeCallback) + .init(); + binding.swipeRefreshLayout.setRefreshing(true); + } + + private void updateSwipeRefreshState() { + binding.swipeRefreshLayout.setRefreshing(binding.posts.isFetching()); + } + + private void navigateToProfile(final String username) { + final NavController navController = NavHostFragment.findNavController(this); + final Bundle bundle = new Bundle(); + bundle.putString("username", username); + navController.navigate(R.id.action_global_profileFragment, bundle); + } + + private void showPostsLayoutPreferences() { + final PostsLayoutPreferencesDialogFragment fragment = new PostsLayoutPreferencesDialogFragment( + Constants.PREF_TOPIC_POSTS_LAYOUT, + preferences -> { + layoutPreferences = preferences; + new Handler().postDelayed(() -> binding.posts.setLayoutPreferences(preferences), 200); + }); + fragment.show(getChildFragmentManager(), "posts_layout_preferences"); + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/SavedCollectionsFragment.java b/app/src/main/java/awais/instagrabber/fragments/SavedCollectionsFragment.java new file mode 100644 index 00000000..cf3de76e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/SavedCollectionsFragment.java @@ -0,0 +1,161 @@ +package awais.instagrabber.fragments; + +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.fragment.FragmentNavigator; +import androidx.navigation.fragment.NavHostFragment; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import awais.instagrabber.R; +import awais.instagrabber.activities.MainActivity; +import awais.instagrabber.adapters.SavedCollectionsAdapter; +import awais.instagrabber.customviews.helpers.GridSpacingItemDecoration; +import awais.instagrabber.databinding.FragmentSavedCollectionsBinding; +import awais.instagrabber.repositories.responses.StoryStickerResponse; +import awais.instagrabber.repositories.responses.saved.CollectionsListResponse; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.viewmodels.SavedCollectionsViewModel; +import awais.instagrabber.webservices.ProfileService; +import awais.instagrabber.webservices.ServiceCallback; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class SavedCollectionsFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { + private static final String TAG = "SavedCollectionsFragment"; + + private MainActivity fragmentActivity; + private CoordinatorLayout root; + private FragmentSavedCollectionsBinding binding; + private SavedCollectionsViewModel savedCollectionsViewModel; + private boolean shouldRefresh = true; + private ProfileService profileService; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + fragmentActivity = (MainActivity) requireActivity(); + profileService = ProfileService.getInstance(); + setHasOptionsMenu(true); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + final ViewGroup container, + final Bundle savedInstanceState) { + if (root != null) { + shouldRefresh = false; + return root; + } + binding = FragmentSavedCollectionsBinding.inflate(inflater, container, false); + root = binding.getRoot(); + return root; + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + if (!shouldRefresh) return; + binding.swipeRefreshLayout.setOnRefreshListener(this); + init(); + shouldRefresh = false; + } + + @Override + public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { + inflater.inflate(R.menu.saved_collection_menu, menu); + } + + @Override + public boolean onOptionsItemSelected(@NonNull final MenuItem item) { + if (item.getItemId() == R.id.add) { + final Context context = getContext(); + final EditText input = new EditText(context); + new AlertDialog.Builder(context) + .setTitle(R.string.saved_create_collection) + .setView(input) + .setPositiveButton(R.string.confirm, (d, w) -> { + final String cookie = settingsHelper.getString(Constants.COOKIE); + profileService.createCollection( + input.getText().toString(), + settingsHelper.getString(Constants.DEVICE_UUID), + CookieUtils.getUserIdFromCookie(cookie), + CookieUtils.getCsrfTokenFromCookie(cookie), + new ServiceCallback() { + @Override + public void onSuccess(final String result) { + onRefresh(); + } + + @Override + public void onFailure(final Throwable t) { + Log.e(TAG, "Error creating collection", t); + Toast.makeText(context, t.getMessage(), Toast.LENGTH_SHORT).show(); + } + }); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + return true; + } + return false; + } + + private void init() { + setupTopics(); + fetchTopics(null); + } + + @Override + public void onRefresh() { + fetchTopics(null); + } + + public void setupTopics() { + savedCollectionsViewModel = new ViewModelProvider(fragmentActivity).get(SavedCollectionsViewModel.class); + binding.topicsRecyclerView.addItemDecoration(new GridSpacingItemDecoration(Utils.convertDpToPx(2))); + final SavedCollectionsAdapter adapter = new SavedCollectionsAdapter((topicCluster, root, cover, title, titleColor, backgroundColor) -> { + final FragmentNavigator.Extras.Builder builder = new FragmentNavigator.Extras.Builder() + .addSharedElement(cover, "collection-" + topicCluster.getId()); + final SavedCollectionsFragmentDirections.ActionSavedCollectionsFragmentToCollectionPostsFragment action = SavedCollectionsFragmentDirections + .actionSavedCollectionsFragmentToCollectionPostsFragment(topicCluster, titleColor, backgroundColor); + NavHostFragment.findNavController(this).navigate(action, builder.build()); + }); + binding.topicsRecyclerView.setAdapter(adapter); + savedCollectionsViewModel.getList().observe(getViewLifecycleOwner(), adapter::submitList); + } + + private void fetchTopics(final String maxId) { + binding.swipeRefreshLayout.setRefreshing(true); + profileService.fetchCollections(maxId, new ServiceCallback() { + @Override + public void onSuccess(final CollectionsListResponse result) { + if (result == null) return; + savedCollectionsViewModel.getList().postValue(result.getItems()); + binding.swipeRefreshLayout.setRefreshing(false); + } + + @Override + public void onFailure(final Throwable t) { + Log.e(TAG, "onFailure", t); + binding.swipeRefreshLayout.setRefreshing(false); + } + }); + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/SavedViewerFragment.java b/app/src/main/java/awais/instagrabber/fragments/SavedViewerFragment.java index 3874256c..b6ee5431 100644 --- a/app/src/main/java/awais/instagrabber/fragments/SavedViewerFragment.java +++ b/app/src/main/java/awais/instagrabber/fragments/SavedViewerFragment.java @@ -286,7 +286,7 @@ public final class SavedViewerFragment extends Fragment implements SwipeRefreshL private void setupPosts() { binding.posts.setViewModelStoreOwner(this) .setLifeCycleOwner(this) - .setPostFetchService(new SavedPostFetchService(profileId, type, isLoggedIn)) + .setPostFetchService(new SavedPostFetchService(profileId, type, isLoggedIn, null)) .setLayoutPreferences(layoutPreferences) .addFetchStatusChangeListener(fetching -> updateSwipeRefreshState()) .setFeedItemCallback(feedItemCallback) diff --git a/app/src/main/java/awais/instagrabber/fragments/main/ProfileFragment.java b/app/src/main/java/awais/instagrabber/fragments/main/ProfileFragment.java index 1b2a9a5e..0752cb29 100644 --- a/app/src/main/java/awais/instagrabber/fragments/main/ProfileFragment.java +++ b/app/src/main/java/awais/instagrabber/fragments/main/ProfileFragment.java @@ -946,9 +946,7 @@ public class ProfileFragment extends Fragment implements SwipeRefreshLayout.OnRe } }); profileDetailsBinding.btnSaved.setOnClickListener(v -> { - final NavDirections action = ProfileFragmentDirections.actionProfileFragmentToSavedViewerFragment(profileModel.getUsername(), - profileModel.getPk(), - PostItemType.SAVED); + final NavDirections action = ProfileFragmentDirections.actionGlobalSavedCollectionsFragment(); NavHostFragment.findNavController(this).navigate(action); }); profileDetailsBinding.btnLiked.setOnClickListener(v -> { diff --git a/app/src/main/java/awais/instagrabber/models/enums/PostItemType.java b/app/src/main/java/awais/instagrabber/models/enums/PostItemType.java index bb4b84d8..4e651a51 100644 --- a/app/src/main/java/awais/instagrabber/models/enums/PostItemType.java +++ b/app/src/main/java/awais/instagrabber/models/enums/PostItemType.java @@ -7,6 +7,7 @@ public enum PostItemType implements Serializable { DISCOVER, FEED, SAVED, + COLLECTION, LIKED, TAGGED, HASHTAG, diff --git a/app/src/main/java/awais/instagrabber/repositories/ProfileRepository.java b/app/src/main/java/awais/instagrabber/repositories/ProfileRepository.java index e8218101..57071f88 100644 --- a/app/src/main/java/awais/instagrabber/repositories/ProfileRepository.java +++ b/app/src/main/java/awais/instagrabber/repositories/ProfileRepository.java @@ -2,10 +2,14 @@ package awais.instagrabber.repositories; import java.util.Map; +import awais.instagrabber.repositories.responses.saved.CollectionsListResponse; import awais.instagrabber.repositories.responses.UserFeedResponse; import retrofit2.Call; +import retrofit2.http.FieldMap; +import retrofit2.http.FormUrlEncoded; import retrofit2.http.GET; import retrofit2.http.Path; +import retrofit2.http.POST; import retrofit2.http.QueryMap; public interface ProfileRepository { @@ -16,9 +20,20 @@ public interface ProfileRepository { @GET("/api/v1/feed/saved/") Call fetchSaved(@QueryMap Map queryParams); + @GET("/api/v1/feed/collection/{collectionId}/") + Call fetchSavedCollection(@Path("collectionId") final String collectionId, + @QueryMap Map queryParams); + @GET("/api/v1/feed/liked/") Call fetchLiked(@QueryMap Map queryParams); @GET("/api/v1/usertags/{profileId}/feed/") Call fetchTagged(@Path("profileId") final long profileId, @QueryMap Map queryParams); + + @GET("/api/v1/collections/list/") + Call fetchCollections(@QueryMap Map queryParams); + + @FormUrlEncoded + @POST("/api/v1/collections/create/") + Call createCollection(@FieldMap Map signedForm); } diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/saved/CollectionsListResponse.java b/app/src/main/java/awais/instagrabber/repositories/responses/saved/CollectionsListResponse.java new file mode 100644 index 00000000..3c91dacf --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/saved/CollectionsListResponse.java @@ -0,0 +1,50 @@ +package awais.instagrabber.repositories.responses.saved; + +import java.util.List; + +public class CollectionsListResponse { + private final boolean moreAvailable; + private final String nextMaxId; + private final String maxId; + private final String status; +// private final int numResults; + private final List items; + + public CollectionsListResponse(final boolean moreAvailable, + final String nextMaxId, + final String maxId, + final String status, +// final int numResults, + final List items) { + this.moreAvailable = moreAvailable; + this.nextMaxId = nextMaxId; + this.maxId = maxId; + this.status = status; +// this.numResults = numResults; + this.items = items; + } + + public boolean isMoreAvailable() { + return moreAvailable; + } + + public String getNextMaxId() { + return nextMaxId; + } + + public String getMaxId() { + return maxId; + } + + public String getStatus() { + return status; + } + +// public int getNumResults() { +// return numResults; +// } + + public List getItems() { + return items; + } +} diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/saved/SavedCollection.java b/app/src/main/java/awais/instagrabber/repositories/responses/saved/SavedCollection.java new file mode 100644 index 00000000..9e1c264f --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/saved/SavedCollection.java @@ -0,0 +1,46 @@ +package awais.instagrabber.repositories.responses.saved; + +import java.io.Serializable; +import java.util.List; + +import awais.instagrabber.repositories.responses.Media; + +public class SavedCollection implements Serializable { + private final String collectionId; + private final String collectionName; + private final String collectionType; + private final int collectionMediacount; + private final List coverMediaList; + + public SavedCollection(final String collectionId, + final String collectionName, + final String collectionType, + final int collectionMediacount, + final List coverMediaList) { + this.collectionId = collectionId; + this.collectionName = collectionName; + this.collectionType = collectionType; + this.collectionMediacount = collectionMediacount; + this.coverMediaList = coverMediaList; + } + + public String getId() { + return collectionId; + } + + public String getTitle() { + return collectionName; + } + + public String getType() { + return collectionType; + } + + public int getMediaCount() { + return collectionMediacount; + } + + public List getCoverMedias() { + return coverMediaList; + } +} diff --git a/app/src/main/java/awais/instagrabber/viewmodels/SavedCollectionsViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/SavedCollectionsViewModel.java new file mode 100644 index 00000000..4f245f3a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/SavedCollectionsViewModel.java @@ -0,0 +1,19 @@ +package awais.instagrabber.viewmodels; + +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import java.util.List; + +import awais.instagrabber.repositories.responses.saved.SavedCollection; + +public class SavedCollectionsViewModel extends ViewModel { + private MutableLiveData> list; + + public MutableLiveData> getList() { + if (list == null) { + list = new MutableLiveData<>(); + } + return list; + } +} diff --git a/app/src/main/java/awais/instagrabber/webservices/ProfileService.java b/app/src/main/java/awais/instagrabber/webservices/ProfileService.java index c6ab47f5..4480223a 100644 --- a/app/src/main/java/awais/instagrabber/webservices/ProfileService.java +++ b/app/src/main/java/awais/instagrabber/webservices/ProfileService.java @@ -4,10 +4,15 @@ import androidx.annotation.NonNull; import com.google.common.collect.ImmutableMap; +import java.util.HashMap; +import java.util.Map; + import awais.instagrabber.repositories.ProfileRepository; import awais.instagrabber.repositories.responses.PostsFetchResponse; import awais.instagrabber.repositories.responses.UserFeedResponse; +import awais.instagrabber.repositories.responses.saved.CollectionsListResponse; import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; @@ -68,12 +73,15 @@ public class ProfileService extends BaseService { } public void fetchSaved(final String maxId, + final String collectionId, final ServiceCallback callback) { final ImmutableMap.Builder builder = ImmutableMap.builder(); + Call request = null; if (!TextUtils.isEmpty(maxId)) { builder.put("max_id", maxId); } - final Call request = repository.fetchSaved(builder.build()); + if (TextUtils.isEmpty(collectionId) || collectionId.equals("ALL_MEDIA_AUTO_COLLECTION")) request = repository.fetchSaved(builder.build()); + else request = repository.fetchSavedCollection(collectionId, builder.build()); request.enqueue(new Callback() { @Override public void onResponse(@NonNull final Call call, @NonNull final Response response) { @@ -99,6 +107,71 @@ public class ProfileService extends BaseService { }); } + public void fetchCollections(final String maxId, + final ServiceCallback callback) { + final ImmutableMap.Builder builder = ImmutableMap.builder(); + if (!TextUtils.isEmpty(maxId)) { + builder.put("max_id", maxId); + } + builder.put("collection_types", "[\"ALL_MEDIA_AUTO_COLLECTION\",\"MEDIA\",\"PRODUCT_AUTO_COLLECTION\"]"); + final Call request = repository.fetchCollections(builder.build()); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + if (callback == null) return; + final CollectionsListResponse collectionsListResponse = response.body(); + if (collectionsListResponse == null) { + callback.onSuccess(null); + return; + } + callback.onSuccess(collectionsListResponse); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + if (callback != null) { + callback.onFailure(t); + } + } + }); + } + + public void createCollection(final String name, + final String deviceUuid, + final long userId, + final String csrfToken, + final ServiceCallback callback) { + final Map form = new HashMap<>(6); + form.put("_csrftoken", csrfToken); + form.put("_uuid", deviceUuid); + form.put("_uid", userId); + form.put("collection_visibility", "0"); // 1 for public, planned for future but currently inexistant + form.put("module_name", "collection_create"); + form.put("name", name); + final Map signedForm = Utils.sign(form); + final Call request = repository.createCollection(signedForm); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + if (callback == null) return; + final String collectionsListResponse = response.body(); + if (collectionsListResponse == null) { + callback.onSuccess(null); + return; + } + callback.onSuccess(collectionsListResponse); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + if (callback != null) { + callback.onFailure(t); + } + } + }); + } + + public void fetchLiked(final String maxId, final ServiceCallback callback) { final ImmutableMap.Builder builder = ImmutableMap.builder(); diff --git a/app/src/main/res/layout/fragment_collection_posts.xml b/app/src/main/res/layout/fragment_collection_posts.xml new file mode 100644 index 00000000..4d82bbe3 --- /dev/null +++ b/app/src/main/res/layout/fragment_collection_posts.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_saved_collections.xml b/app/src/main/res/layout/fragment_saved_collections.xml new file mode 100644 index 00000000..6636e3bc --- /dev/null +++ b/app/src/main/res/layout/fragment_saved_collections.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/saved_collection_menu.xml b/app/src/main/res/menu/saved_collection_menu.xml new file mode 100644 index 00000000..6c67c1f1 --- /dev/null +++ b/app/src/main/res/menu/saved_collection_menu.xml @@ -0,0 +1,10 @@ + + + + + \ 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 cda21c30..44023a69 100644 --- a/app/src/main/res/navigation/profile_nav_graph.xml +++ b/app/src/main/res/navigation/profile_nav_graph.xml @@ -82,6 +82,12 @@ app:nullable="false" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 31c0d0d6..33d7939d 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -97,6 +97,7 @@ Remove all accounts This will remove all added accounts from the app!\nTo remove just one account, long tap the account from the account switcher dialog.\nDo you want to continue? Date format + Create new collection Liked Saved Tagged