From 904c1bc7a6e9aa3b9240391c374eda1bcd0eae4f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Oct 2025 19:54:26 +0000 Subject: [PATCH 1/8] Implement multi-tab functionality for collections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This update adds support for multiple tabs in the app, allowing users to have different collections open simultaneously and switch between them. Key changes: - Created CollectionFragment: Extracted collection viewing logic from MainActivity into a reusable fragment that can be displayed in tabs - Created TabStateManager: Manages tab state persistence using SharedPreferences with support for up to 10 tabs - Created CollectionTabAdapter: ViewPager2 adapter for managing collection fragments across tabs - Refactored MainActivity: Now acts as a tab container managing TabLayout and ViewPager2 instead of directly displaying collections - Updated activity_main.xml: Replaced RecyclerView with TabLayout, ViewPager2, and FloatingActionButton for tab management - Added fragment_collection.xml: Layout for individual collection tabs - Enhanced EpubCoverRepository: Added getFilteredCoversForCollection() method to support filtering covers by specific collection keys - Updated build.gradle: Added ViewPager2 dependency Features: - Users can open multiple collections in separate tabs - Tabs persist across app restarts - Long-press on tabs to close them (minimum 1 tab required) - FAB button to add new tabs by selecting collections - Swipe between tabs with native Android gestures - Each tab maintains its own state independently 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/build.gradle | 13 +- .../oyvindbs/zotshelf/CollectionFragment.java | 496 +++++++++ .../zotshelf/CollectionTabAdapter.java | 51 + .../java/oyvindbs/zotshelf/MainActivity.java | 967 ++++++------------ .../oyvindbs/zotshelf/TabStateManager.java | 127 +++ .../database/EpubCoverRepository.java | 33 +- app/src/main/res/layout/activity_main.xml | 60 +- .../main/res/layout/fragment_collection.xml | 54 + 8 files changed, 1123 insertions(+), 678 deletions(-) create mode 100644 app/src/main/java/oyvindbs/zotshelf/CollectionFragment.java create mode 100644 app/src/main/java/oyvindbs/zotshelf/CollectionTabAdapter.java create mode 100644 app/src/main/java/oyvindbs/zotshelf/TabStateManager.java create mode 100644 app/src/main/res/layout/fragment_collection.xml diff --git a/app/build.gradle b/app/build.gradle index c8a026f..0a3e4b9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -48,20 +48,23 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' - + + // ViewPager2 for tabs + implementation 'androidx.viewpager2:viewpager2:1.0.0' + // Room database implementation 'androidx.room:room-runtime:2.6.1' annotationProcessor 'androidx.room:room-compiler:2.6.1' - + // Retrofit for API calls implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' implementation 'com.squareup.okhttp3:logging-interceptor:4.11.0' - + // Glide for image loading implementation 'com.github.bumptech.glide:glide:4.15.1' - + // Epublib for EPUB parsing - implementation 'com.github.mertakdut:EpubParser:1.0.95' + implementation 'com.github.mertakdut:EpubParser:1.0.95' implementation 'org.slf4j:slf4j-android:1.7.36' } diff --git a/app/src/main/java/oyvindbs/zotshelf/CollectionFragment.java b/app/src/main/java/oyvindbs/zotshelf/CollectionFragment.java new file mode 100644 index 0000000..c6678a4 --- /dev/null +++ b/app/src/main/java/oyvindbs/zotshelf/CollectionFragment.java @@ -0,0 +1,496 @@ +package oyvindbs.zotshelf; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import oyvindbs.zotshelf.database.EpubCoverRepository; +import oyvindbs.zotshelf.utils.NetworkUtils; + +public class CollectionFragment extends Fragment implements CoverGridAdapter.CoverClickListener { + + private static final String ARG_COLLECTION_KEY = "collection_key"; + private static final String ARG_COLLECTION_NAME = "collection_name"; + + private String collectionKey; + private String collectionName; + + private RecyclerView recyclerView; + private CoverGridAdapter adapter; + private List coverItems = new ArrayList<>(); + private ProgressBar progressBar; + private TextView emptyView; + private SwipeRefreshLayout swipeRefreshLayout; + private ZoteroApiClient zoteroApiClient; + private UserPreferences userPreferences; + private EpubCoverRepository coverRepository; + private boolean isOfflineMode = false; + + public static CollectionFragment newInstance(String collectionKey, String collectionName) { + CollectionFragment fragment = new CollectionFragment(); + Bundle args = new Bundle(); + args.putString(ARG_COLLECTION_KEY, collectionKey); + args.putString(ARG_COLLECTION_NAME, collectionName); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (getArguments() != null) { + collectionKey = getArguments().getString(ARG_COLLECTION_KEY); + collectionName = getArguments().getString(ARG_COLLECTION_NAME); + } + + userPreferences = new UserPreferences(requireContext()); + coverRepository = new EpubCoverRepository(requireContext()); + zoteroApiClient = new ZoteroApiClient(requireContext()); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_collection, container, false); + + progressBar = view.findViewById(R.id.progressBar); + emptyView = view.findViewById(R.id.emptyView); + recyclerView = view.findViewById(R.id.recyclerViewCovers); + swipeRefreshLayout = view.findViewById(R.id.swipeRefreshLayout); + + // Setup RecyclerView with Grid Layout + int spanCount = calculateSpanCount(); + recyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount)); + int displayMode = userPreferences.getDisplayMode(); + adapter = new CoverGridAdapter(requireContext(), coverItems, this, displayMode); + recyclerView.setAdapter(adapter); + + // Setup refresh listener + swipeRefreshLayout.setOnRefreshListener(this::refreshCovers); + + return view; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + loadCovers(); + } + + private int calculateSpanCount() { + float density = getResources().getDisplayMetrics().density; + int screenWidthDp = (int) (getResources().getDisplayMetrics().widthPixels / density); + int itemWidthDp = 120; + return Math.max(2, screenWidthDp / itemWidthDp); + } + + private void loadCovers() { + if (!userPreferences.hasZoteroCredentials()) { + showEmptyState("Please enter your Zotero credentials in settings"); + swipeRefreshLayout.setRefreshing(false); + return; + } + + if (!userPreferences.hasAnyFileTypeEnabled()) { + showEmptyState("Please enable at least one file type (EPUB or PDF) in settings"); + swipeRefreshLayout.setRefreshing(false); + return; + } + + showLoading(); + + // Load from cache first for instant display + coverRepository.getFilteredCoversForCollection(collectionKey, + new EpubCoverRepository.CoverRepositoryCallback() { + @Override + public void onCoversLoaded(List cachedCovers) { + if (getActivity() == null) return; + + getActivity().runOnUiThread(() -> { + if (!cachedCovers.isEmpty()) { + updateUI(cachedCovers); + + if (NetworkUtils.isNetworkAvailable(requireContext())) { + isOfflineMode = false; + loadCoversFromApiInBackground(); + } else { + isOfflineMode = true; + Toast.makeText(requireContext(), "Offline mode - showing cached covers", + Toast.LENGTH_SHORT).show(); + swipeRefreshLayout.setRefreshing(false); + } + } else { + if (NetworkUtils.isNetworkAvailable(requireContext())) { + isOfflineMode = false; + loadCoversFromApi(); + } else { + isOfflineMode = true; + showEmptyState("No internet connection and no cached data available"); + swipeRefreshLayout.setRefreshing(false); + } + } + }); + } + + @Override + public void onError(String message) { + if (getActivity() == null) return; + + getActivity().runOnUiThread(() -> { + Log.e("CollectionFragment", "Error loading cached covers: " + message); + if (NetworkUtils.isNetworkAvailable(requireContext())) { + loadCoversFromApi(); + } else { + showEmptyState("No cached data available and no internet connection"); + swipeRefreshLayout.setRefreshing(false); + } + }); + } + }); + } + + private void refreshCovers() { + if (!NetworkUtils.isNetworkAvailable(requireContext())) { + Toast.makeText(requireContext(), "No internet connection. Showing cached data.", + Toast.LENGTH_LONG).show(); + swipeRefreshLayout.setRefreshing(false); + return; + } + + loadCoversFromApi(); + } + + private void loadCoversFromApi() { + String userId = userPreferences.getZoteroUserId(); + String apiKey = userPreferences.getZoteroApiKey(); + + Log.d("CollectionFragment", "Loading covers from API - Collection: " + collectionKey); + + zoteroApiClient.getAllEbookItemsWithMetadata(userId, apiKey, collectionKey, + new ZoteroApiClient.ZoteroCallback>() { + @Override + public void onSuccess(List zoteroItems) { + Log.d("CollectionFragment", "Received " + zoteroItems.size() + " items from API"); + processZoteroItems(zoteroItems); + } + + @Override + public void onError(String errorMessage) { + Log.e("CollectionFragment", "Error loading covers: " + errorMessage); + if (getActivity() == null) return; + + getActivity().runOnUiThread(() -> { + coverRepository.hasCachedCovers(hasCovers -> { + if (hasCovers) { + loadCachedCovers(); + Toast.makeText(requireContext(), + "Failed to update from Zotero: " + errorMessage, + Toast.LENGTH_LONG).show(); + } else { + showEmptyState("Error: " + errorMessage); + swipeRefreshLayout.setRefreshing(false); + } + }); + }); + } + }); + } + + private void loadCoversFromApiInBackground() { + String userId = userPreferences.getZoteroUserId(); + String apiKey = userPreferences.getZoteroApiKey(); + + zoteroApiClient.getAllEbookItemsWithMetadata(userId, apiKey, collectionKey, + new ZoteroApiClient.ZoteroCallback>() { + @Override + public void onSuccess(List zoteroItems) { + Log.d("CollectionFragment", "Background update: Received " + + zoteroItems.size() + " items from API"); + processZoteroItemsForCache(zoteroItems); + + if (getActivity() == null) return; + getActivity().runOnUiThread(() -> { + swipeRefreshLayout.setRefreshing(false); + Toast.makeText(requireContext(), "Library updated from Zotero", + Toast.LENGTH_SHORT).show(); + }); + } + + @Override + public void onError(String errorMessage) { + Log.e("CollectionFragment", "Background update error: " + errorMessage); + if (getActivity() == null) return; + + getActivity().runOnUiThread(() -> { + swipeRefreshLayout.setRefreshing(false); + }); + } + }); + } + + private void processZoteroItemsForCache(List zoteroItems) { + if (zoteroItems.isEmpty()) { + return; + } + + for (ZoteroItem item : zoteroItems) { + zoteroApiClient.downloadEbook(item, new ZoteroApiClient.FileCallback() { + @Override + public void onFileDownloaded(ZoteroItem item, String filePath) { + CoverExtractor.extractCover(filePath, new CoverExtractor.CoverCallback() { + @Override + public void onCoverExtracted(String coverPath) { + coverRepository.saveCoverFromZoteroItem(item, coverPath); + } + + @Override + public void onError(String errorMessage) { + coverRepository.saveCoverFromZoteroItem(item, null); + } + }); + } + + @Override + public void onError(ZoteroItem item, String errorMessage) { + coverRepository.saveCoverFromZoteroItem(item, null); + } + }); + } + } + + private void loadCachedCovers() { + coverRepository.getFilteredCoversForCollection(collectionKey, + new EpubCoverRepository.CoverRepositoryCallback() { + @Override + public void onCoversLoaded(List covers) { + if (getActivity() == null) return; + + getActivity().runOnUiThread(() -> { + if (covers.isEmpty()) { + showEmptyState("No cached covers found"); + } else { + updateUI(covers); + if (isOfflineMode) { + Toast.makeText(requireContext(), "Offline mode - showing cached covers", + Toast.LENGTH_SHORT).show(); + } + } + swipeRefreshLayout.setRefreshing(false); + }); + } + + @Override + public void onError(String message) { + if (getActivity() == null) return; + + getActivity().runOnUiThread(() -> { + showEmptyState("Error loading cached covers: " + message); + swipeRefreshLayout.setRefreshing(false); + }); + } + }); + } + + private void processZoteroItems(List zoteroItems) { + if (zoteroItems.isEmpty()) { + if (getActivity() == null) return; + + getActivity().runOnUiThread(() -> { + showEmptyState("No EPUB or PDF files found matching your filters"); + swipeRefreshLayout.setRefreshing(false); + }); + return; + } + + Log.d("CollectionFragment", "Processing " + zoteroItems.size() + " Zotero items"); + + List newCoverItems = new ArrayList<>(); + final int totalItems = zoteroItems.size(); + final int[] processedCount = {0}; + + final List itemsToSave = Collections.synchronizedList(new ArrayList<>()); + final List coverPathsToSave = Collections.synchronizedList(new ArrayList<>()); + + for (ZoteroItem item : zoteroItems) { + zoteroApiClient.downloadEbook(item, new ZoteroApiClient.FileCallback() { + @Override + public void onFileDownloaded(ZoteroItem item, String filePath) { + CoverExtractor.extractCover(filePath, new CoverExtractor.CoverCallback() { + @Override + public void onCoverExtracted(String coverPath) { + EpubCoverItem coverItem = new EpubCoverItem( + item.getKey(), + item.getTitle(), + coverPath, + item.getAuthors(), + userPreferences.getZoteroUsername() + ); + + synchronized (newCoverItems) { + newCoverItems.add(coverItem); + itemsToSave.add(item); + coverPathsToSave.add(coverPath); + processedCount[0]++; + + new Thread(() -> { + coverRepository.saveCoverFromZoteroItemSync(item, coverPath); + }).start(); + + if (processedCount[0] == totalItems) { + updateUI(newCoverItems); + } + } + } + + @Override + public void onError(String errorMessage) { + EpubCoverItem coverItem = new EpubCoverItem( + item.getKey(), + item.getTitle(), + null, + item.getAuthors(), + userPreferences.getZoteroUsername() + ); + + synchronized (newCoverItems) { + newCoverItems.add(coverItem); + itemsToSave.add(item); + coverPathsToSave.add(null); + processedCount[0]++; + + new Thread(() -> { + coverRepository.saveCoverFromZoteroItemSync(item, null); + }).start(); + + if (processedCount[0] == totalItems) { + updateUI(newCoverItems); + } + } + } + }); + } + + @Override + public void onError(ZoteroItem item, String errorMessage) { + EpubCoverItem coverItem = new EpubCoverItem( + item.getKey(), + item.getTitle() + " (Download failed)", + null, + item.getAuthors(), + userPreferences.getZoteroUsername() + ); + + synchronized (newCoverItems) { + newCoverItems.add(coverItem); + itemsToSave.add(item); + coverPathsToSave.add(null); + processedCount[0]++; + + new Thread(() -> { + coverRepository.saveCoverFromZoteroItemSync(item, null); + }).start(); + + if (processedCount[0] == totalItems) { + updateUI(newCoverItems); + } + } + } + }); + } + } + + private void updateUI(final List newItems) { + if (getActivity() == null) return; + + getActivity().runOnUiThread(() -> { + coverItems.clear(); + coverItems.addAll(newItems); + + int displayMode = userPreferences.getDisplayMode(); + adapter = new CoverGridAdapter(requireContext(), coverItems, this, displayMode); + recyclerView.setAdapter(adapter); + + if (coverItems.isEmpty()) { + showEmptyState("No EPUB files found"); + } else { + hideEmptyState(); + } + + progressBar.setVisibility(View.GONE); + swipeRefreshLayout.setRefreshing(false); + }); + } + + private void showLoading() { + progressBar.setVisibility(View.VISIBLE); + emptyView.setVisibility(View.GONE); + } + + private void showEmptyState(String message) { + progressBar.setVisibility(View.GONE); + emptyView.setVisibility(View.VISIBLE); + emptyView.setText(message); + } + + private void hideEmptyState() { + emptyView.setVisibility(View.GONE); + } + + @Override + public void onCoverClick(EpubCoverItem item) { + String zoteroUsername = userPreferences.getZoteroUsername(); + if (zoteroUsername == null || zoteroUsername.isEmpty()) { + return; + } + + if (!NetworkUtils.isNetworkAvailable(requireContext())) { + Toast.makeText(requireContext(), "Cannot open reader - no internet connection", + Toast.LENGTH_SHORT).show(); + return; + } + + String url = "https://www.zotero.org/" + zoteroUsername + "/items/" + + item.getId() + "/reader"; + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + startActivity(intent); + } + + public String getCollectionKey() { + return collectionKey; + } + + public String getCollectionName() { + return collectionName; + } + + public void refresh() { + refreshCovers(); + } + + public void updateDisplayMode() { + if (adapter != null) { + int displayMode = userPreferences.getDisplayMode(); + adapter = new CoverGridAdapter(requireContext(), coverItems, this, displayMode); + recyclerView.setAdapter(adapter); + } + } +} diff --git a/app/src/main/java/oyvindbs/zotshelf/CollectionTabAdapter.java b/app/src/main/java/oyvindbs/zotshelf/CollectionTabAdapter.java new file mode 100644 index 0000000..7da4aeb --- /dev/null +++ b/app/src/main/java/oyvindbs/zotshelf/CollectionTabAdapter.java @@ -0,0 +1,51 @@ +package oyvindbs.zotshelf; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.viewpager2.adapter.FragmentStateAdapter; + +import java.util.ArrayList; +import java.util.List; + +public class CollectionTabAdapter extends FragmentStateAdapter { + private final List tabs; + + public CollectionTabAdapter(@NonNull FragmentActivity fragmentActivity, + List tabs) { + super(fragmentActivity); + this.tabs = new ArrayList<>(tabs); + } + + @NonNull + @Override + public Fragment createFragment(int position) { + TabStateManager.TabInfo tab = tabs.get(position); + return CollectionFragment.newInstance( + tab.getCollectionKey(), + tab.getCollectionName() + ); + } + + @Override + public int getItemCount() { + return tabs.size(); + } + + public void updateTabs(List newTabs) { + tabs.clear(); + tabs.addAll(newTabs); + notifyDataSetChanged(); + } + + public TabStateManager.TabInfo getTabAt(int position) { + if (position >= 0 && position < tabs.size()) { + return tabs.get(position); + } + return null; + } + + public List getTabs() { + return new ArrayList<>(tabs); + } +} diff --git a/app/src/main/java/oyvindbs/zotshelf/MainActivity.java b/app/src/main/java/oyvindbs/zotshelf/MainActivity.java index 8503b2b..e7e5647 100644 --- a/app/src/main/java/oyvindbs/zotshelf/MainActivity.java +++ b/app/src/main/java/oyvindbs/zotshelf/MainActivity.java @@ -1,717 +1,416 @@ package oyvindbs.zotshelf; import android.content.Intent; -import android.net.Uri; import android.os.Bundle; -import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.view.View; -import android.widget.ProgressBar; -import android.widget.TextView; -import android.app.AlertDialog; -import android.view.LayoutInflater; import android.widget.Toast; +import android.app.AlertDialog; -import java.util.Collections; import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; +import androidx.viewpager2.widget.ViewPager2; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.google.android.material.tabs.TabLayout; +import com.google.android.material.tabs.TabLayoutMediator; -import oyvindbs.zotshelf.database.EpubCoverRepository; import oyvindbs.zotshelf.utils.NetworkUtils; -import java.util.ArrayList; import java.util.List; -public class MainActivity extends AppCompatActivity implements CoverGridAdapter.CoverClickListener { - -private RecyclerView recyclerView; -private static final int REQUEST_CODE_SELECT_COLLECTION = 1001; -private CoverGridAdapter adapter; -private List coverItems = new ArrayList<>(); -private ProgressBar progressBar; -private TextView emptyView; -private SwipeRefreshLayout swipeRefreshLayout; -private ZoteroApiClient zoteroApiClient; -private UserPreferences userPreferences; -private EpubCoverRepository coverRepository; -private boolean isOfflineMode = false; - -@Override -protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - - userPreferences = new UserPreferences(this); - coverRepository = new EpubCoverRepository(this); - - progressBar = findViewById(R.id.progressBar); - emptyView = findViewById(R.id.emptyView); - recyclerView = findViewById(R.id.recyclerViewCovers); - swipeRefreshLayout = findViewById(R.id.swipeRefreshLayout); - - // Setup RecyclerView with Grid Layout - int spanCount = calculateSpanCount(); - recyclerView.setLayoutManager(new GridLayoutManager(this, spanCount)); - int displayMode = userPreferences.getDisplayMode(); - adapter = new CoverGridAdapter(this, coverItems, this, displayMode); - - recyclerView.setAdapter(adapter); - - // Initialize Zotero API client - zoteroApiClient = new ZoteroApiClient(this); - - // Setup refresh listener - swipeRefreshLayout.setOnRefreshListener(this::refreshCovers); - - updateTitle(); - - // Check if we have Zotero credentials, if not show settings first - if (!userPreferences.hasZoteroCredentials()) { - startActivity(new Intent(this, SettingsActivity.class)); - } else { - loadCovers(); - } - - // Handle widget click intent - if (getIntent().hasExtra("fromWidget")) { - handleWidgetClick(getIntent()); - } -} +public class MainActivity extends AppCompatActivity { -@Override -protected void onNewIntent(Intent intent) { - super.onNewIntent(intent); - if (intent.hasExtra("fromWidget")) { - handleWidgetClick(intent); - } -} + private static final int REQUEST_CODE_SELECT_COLLECTION = 1001; -private void handleWidgetClick(Intent intent) { - if (intent.hasExtra("itemId") && intent.hasExtra("username")) { - String itemId = intent.getStringExtra("itemId"); - String username = intent.getStringExtra("username"); - - // Open the Zotero web library - String url = "https://www.zotero.org/" + username + "/items/" + itemId+"/reader"; - Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - startActivity(browserIntent); - } -} + private TabLayout tabLayout; + private ViewPager2 viewPager; + private FloatingActionButton fabAddTab; + private CollectionTabAdapter tabAdapter; + private TabStateManager tabStateManager; + private UserPreferences userPreferences; + private TabLayoutMediator tabLayoutMediator; -private int calculateSpanCount() { - // Calculate number of columns based on screen width - float density = getResources().getDisplayMetrics().density; - int screenWidthDp = (int) (getResources().getDisplayMetrics().widthPixels / density); - int itemWidthDp = 120; // Target width for each grid item - return Math.max(2, screenWidthDp / itemWidthDp); -} + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); -private void loadCovers() { - if (!userPreferences.hasZoteroCredentials()) { - showEmptyState("Please enter your Zotero credentials in settings"); - swipeRefreshLayout.setRefreshing(false); - return; - } - - // Check if user has enabled any file types - if (!userPreferences.hasAnyFileTypeEnabled()) { - showEmptyState("Please enable at least one file type (EPUB or PDF) in settings"); - swipeRefreshLayout.setRefreshing(false); - return; - } + userPreferences = new UserPreferences(this); + tabStateManager = new TabStateManager(this); - showLoading(); - - // Always try to load from cache first for instant display - coverRepository.getFilteredCovers(new EpubCoverRepository.CoverRepositoryCallback() { - @Override - public void onCoversLoaded(List cachedCovers) { - runOnUiThread(() -> { - if (!cachedCovers.isEmpty()) { - // Show cached data immediately - updateUI(cachedCovers); - - // Then try to update from network if available - if (NetworkUtils.isNetworkAvailable(MainActivity.this)) { - isOfflineMode = false; - // Load from API in background to update cache - loadCoversFromApiInBackground(); - } else { - isOfflineMode = true; - Toast.makeText(MainActivity.this, "Offline mode - showing cached covers", Toast.LENGTH_SHORT).show(); - swipeRefreshLayout.setRefreshing(false); - updateTitle(); - } - } else { - // No cached data, try network - if (NetworkUtils.isNetworkAvailable(MainActivity.this)) { - isOfflineMode = false; - loadCoversFromApi(); - } else { - isOfflineMode = true; - showEmptyState("No internet connection and no cached data available"); - swipeRefreshLayout.setRefreshing(false); - updateTitle(); - } - } - }); + tabLayout = findViewById(R.id.tabLayout); + viewPager = findViewById(R.id.viewPager); + fabAddTab = findViewById(R.id.fabAddTab); + + // Check if we have Zotero credentials, if not show settings first + if (!userPreferences.hasZoteroCredentials()) { + startActivity(new Intent(this, SettingsActivity.class)); + return; } - @Override - public void onError(String message) { - runOnUiThread(() -> { - Log.e("MainActivity", "Error loading cached covers: " + message); - // If cache fails and we have network, try API - if (NetworkUtils.isNetworkAvailable(MainActivity.this)) { - loadCoversFromApi(); - } else { - showEmptyState("No cached data available and no internet connection"); - swipeRefreshLayout.setRefreshing(false); - } - }); + setupTabs(); + setupFab(); + + // Handle widget click intent + if (getIntent().hasExtra("fromWidget")) { + handleWidgetClick(getIntent()); } - }); -} + } -private void refreshCovers() { - if (!NetworkUtils.isNetworkAvailable(this)) { - Toast.makeText(this, "No internet connection. Showing cached data.", Toast.LENGTH_LONG).show(); - swipeRefreshLayout.setRefreshing(false); - return; + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + if (intent.hasExtra("fromWidget")) { + handleWidgetClick(intent); + } } - - loadCoversFromApi(); -} -private void loadCoversFromApi() { - String userId = userPreferences.getZoteroUserId(); - String apiKey = userPreferences.getZoteroApiKey(); - String collectionKey = userPreferences.getSelectedCollectionKey(); + private void handleWidgetClick(Intent intent) { + if (intent.hasExtra("itemId") && intent.hasExtra("username")) { + String itemId = intent.getStringExtra("itemId"); + String username = intent.getStringExtra("username"); + + // Open the Zotero web library + String url = "https://www.zotero.org/" + username + "/items/" + itemId + "/reader"; + Intent browserIntent = new Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url)); + startActivity(browserIntent); + } + } + + private void setupTabs() { + List tabs = tabStateManager.getOpenTabs(); - Log.d("MainActivity", "Loading covers from API - Collection: " + collectionKey); + tabAdapter = new CollectionTabAdapter(this, tabs); + viewPager.setAdapter(tabAdapter); - // Use the new paginated method that fetches ALL items - zoteroApiClient.getAllEbookItemsWithMetadata(userId, apiKey, collectionKey, new ZoteroApiClient.ZoteroCallback>() { - @Override - public void onSuccess(List zoteroItems) { - Log.d("MainActivity", "Received " + zoteroItems.size() + " items from API"); - processZoteroItems(zoteroItems); + // Connect TabLayout with ViewPager2 + if (tabLayoutMediator != null) { + tabLayoutMediator.detach(); } - @Override - public void onError(String errorMessage) { - Log.e("MainActivity", "Error loading covers: " + errorMessage); - runOnUiThread(() -> { - // If API fails but we have cached data, show that - coverRepository.hasCachedCovers(hasCovers -> { - if (hasCovers) { - loadCachedCovers(); - Toast.makeText(MainActivity.this, - "Failed to update from Zotero: " + errorMessage, - Toast.LENGTH_LONG).show(); - } else { - showEmptyState("Error: " + errorMessage); - swipeRefreshLayout.setRefreshing(false); + tabLayoutMediator = new TabLayoutMediator(tabLayout, viewPager, + (tab, position) -> { + TabStateManager.TabInfo tabInfo = tabAdapter.getTabAt(position); + if (tabInfo != null) { + tab.setText(tabInfo.getCollectionName()); + + // Add close button for tabs (except if it's the only tab) + if (tabs.size() > 1) { + tab.view.setOnLongClickListener(v -> { + showCloseTabDialog(position); + return true; + }); + } } - }); - }); - } - }); -} + } + ); + tabLayoutMediator.attach(); -private void loadCoversFromApiInBackground() { - // This method updates the cache in background while showing cached data - String userId = userPreferences.getZoteroUserId(); - String apiKey = userPreferences.getZoteroApiKey(); - String collectionKey = userPreferences.getSelectedCollectionKey(); - - zoteroApiClient.getAllEbookItemsWithMetadata(userId, apiKey, collectionKey, new ZoteroApiClient.ZoteroCallback>() { - @Override - public void onSuccess(List zoteroItems) { - Log.d("MainActivity", "Background update: Received " + zoteroItems.size() + " items from API"); - - // Process items and update cache, but don't necessarily update UI - processZoteroItemsForCache(zoteroItems); - - runOnUiThread(() -> { - swipeRefreshLayout.setRefreshing(false); - Toast.makeText(MainActivity.this, "Library updated from Zotero", Toast.LENGTH_SHORT).show(); - }); + // Restore last selected tab + int currentTab = tabStateManager.getCurrentTabIndex(); + if (currentTab < tabs.size()) { + viewPager.setCurrentItem(currentTab, false); } - @Override - public void onError(String errorMessage) { - Log.e("MainActivity", "Background update error: " + errorMessage); - runOnUiThread(() -> { - swipeRefreshLayout.setRefreshing(false); - // Don't show error toast for background updates unless it's critical - }); - } - }); -} - -private void processZoteroItemsForCache(List zoteroItems) { - if (zoteroItems.isEmpty()) { - return; + // Save current tab when user switches + viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { + @Override + public void onPageSelected(int position) { + super.onPageSelected(position); + tabStateManager.setCurrentTabIndex(position); + } + }); } - // Process items and save to cache without necessarily updating UI - for (ZoteroItem item : zoteroItems) { - zoteroApiClient.downloadEbook(item, new ZoteroApiClient.FileCallback() { - @Override - public void onFileDownloaded(ZoteroItem item, String filePath) { - // Extract cover and save to database - CoverExtractor.extractCover(filePath, new CoverExtractor.CoverCallback() { - @Override - public void onCoverExtracted(String coverPath) { - // Save to database using the enhanced method - coverRepository.saveCoverFromZoteroItem(item, coverPath); - } + private void setupFab() { + fabAddTab.setOnClickListener(v -> { + if (!tabStateManager.canAddMoreTabs()) { + Toast.makeText(this, "Maximum " + tabStateManager.getMaxTabs() + + " tabs reached", Toast.LENGTH_SHORT).show(); + return; + } - @Override - public void onError(String errorMessage) { - // Save without cover - coverRepository.saveCoverFromZoteroItem(item, null); - } - }); + if (!userPreferences.hasZoteroCredentials()) { + Toast.makeText(this, R.string.enter_credentials, Toast.LENGTH_SHORT).show(); + return; } - - @Override - public void onError(ZoteroItem item, String errorMessage) { - // Save item metadata even if download failed - coverRepository.saveCoverFromZoteroItem(item, null); + + if (!NetworkUtils.isNetworkAvailable(this)) { + Toast.makeText(this, "No internet connection. Cannot fetch collections.", + Toast.LENGTH_LONG).show(); + return; } + + // Launch the collection tree activity + Intent intent = new Intent(this, CollectionTreeActivity.class); + startActivityForResult(intent, REQUEST_CODE_SELECT_COLLECTION); }); } -} -private void loadCachedCovers() { - coverRepository.getFilteredCovers(new EpubCoverRepository.CoverRepositoryCallback() { - @Override - public void onCoversLoaded(List covers) { - runOnUiThread(() -> { - if (covers.isEmpty()) { - showEmptyState("No cached covers found"); - } else { - updateUI(covers); - if (isOfflineMode) { - Toast.makeText(MainActivity.this, "Offline mode - showing cached covers", Toast.LENGTH_SHORT).show(); - } - } - swipeRefreshLayout.setRefreshing(false); - }); - } + private void showCloseTabDialog(int position) { + TabStateManager.TabInfo tabInfo = tabAdapter.getTabAt(position); + if (tabInfo == null) return; - @Override - public void onError(String message) { - runOnUiThread(() -> { - showEmptyState("Error loading cached covers: " + message); - swipeRefreshLayout.setRefreshing(false); - }); - } - }); -} + new AlertDialog.Builder(this) + .setTitle("Close Tab") + .setMessage("Close tab '" + tabInfo.getCollectionName() + "'?") + .setPositiveButton("Close", (dialog, which) -> closeTab(position)) + .setNegativeButton("Cancel", null) + .show(); + } -private void processZoteroItems(List zoteroItems) { - if (zoteroItems.isEmpty()) { - runOnUiThread(() -> { - showEmptyState("No EPUB or PDF files found matching your filters"); - swipeRefreshLayout.setRefreshing(false); - }); - return; + private void closeTab(int position) { + tabStateManager.removeTab(position); + refreshTabs(); } - Log.d("MainActivity", "Processing " + zoteroItems.size() + " Zotero items"); - - List newCoverItems = new ArrayList<>(); - final int totalItems = zoteroItems.size(); - final int[] processedCount = {0}; - - // Collections for batch database save - final List itemsToSave = Collections.synchronizedList(new ArrayList<>()); - final List coverPathsToSave = Collections.synchronizedList(new ArrayList<>()); - - for (ZoteroItem item : zoteroItems) { - zoteroApiClient.downloadEbook(item, new ZoteroApiClient.FileCallback() { - @Override - public void onFileDownloaded(ZoteroItem item, String filePath) { - CoverExtractor.extractCover(filePath, new CoverExtractor.CoverCallback() { - @Override - public void onCoverExtracted(String coverPath) { - EpubCoverItem coverItem = new EpubCoverItem( - item.getKey(), - item.getTitle(), - coverPath, - item.getAuthors(), - userPreferences.getZoteroUsername() - ); - - synchronized (newCoverItems) { - newCoverItems.add(coverItem); - - // Add to batch save lists - itemsToSave.add(item); - coverPathsToSave.add(coverPath); - - processedCount[0]++; - - // Save to database immediately for each item (ensures persistence) - new Thread(() -> { - coverRepository.saveCoverFromZoteroItemSync(item, coverPath); - }).start(); - - if (processedCount[0] == totalItems) { - updateUI(newCoverItems); - } - } - } + private void refreshTabs() { + List tabs = tabStateManager.getOpenTabs(); + tabAdapter.updateTabs(tabs); + + // Reattach mediator + if (tabLayoutMediator != null) { + tabLayoutMediator.detach(); + } - @Override - public void onError(String errorMessage) { - EpubCoverItem coverItem = new EpubCoverItem( - item.getKey(), - item.getTitle(), - null, - item.getAuthors(), - userPreferences.getZoteroUsername() - ); - - synchronized (newCoverItems) { - newCoverItems.add(coverItem); - - // Save without cover - itemsToSave.add(item); - coverPathsToSave.add(null); - - processedCount[0]++; - - new Thread(() -> { - coverRepository.saveCoverFromZoteroItemSync(item, null); - }).start(); - - if (processedCount[0] == totalItems) { - updateUI(newCoverItems); - } + tabLayoutMediator = new TabLayoutMediator(tabLayout, viewPager, + (tab, position) -> { + TabStateManager.TabInfo tabInfo = tabAdapter.getTabAt(position); + if (tabInfo != null) { + tab.setText(tabInfo.getCollectionName()); + + if (tabs.size() > 1) { + tab.view.setOnLongClickListener(v -> { + showCloseTabDialog(position); + return true; + }); } } - }); - } - - @Override - public void onError(ZoteroItem item, String errorMessage) { - EpubCoverItem coverItem = new EpubCoverItem( - item.getKey(), - item.getTitle() + " (Download failed)", - null, - item.getAuthors(), - userPreferences.getZoteroUsername() - ); - - synchronized (newCoverItems) { - newCoverItems.add(coverItem); - - itemsToSave.add(item); - coverPathsToSave.add(null); - - processedCount[0]++; - - new Thread(() -> { - coverRepository.saveCoverFromZoteroItemSync(item, null); - }).start(); - - if (processedCount[0] == totalItems) { - updateUI(newCoverItems); - } } - } - }); + ); + tabLayoutMediator.attach(); + + // Make sure we're on a valid tab + int currentTab = viewPager.getCurrentItem(); + if (currentTab >= tabs.size()) { + viewPager.setCurrentItem(tabs.size() - 1, true); + } } -} + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.main_menu, menu); + + // Set the checkable state for file type toggles + MenuItem epubToggle = menu.findItem(R.id.action_toggle_epubs); + MenuItem pdfToggle = menu.findItem(R.id.action_toggle_pdfs); -private void updateUI(final List newItems) { - runOnUiThread(() -> { - coverItems.clear(); - coverItems.addAll(newItems); - - // Re-create the adapter with the current display mode - int displayMode = userPreferences.getDisplayMode(); - adapter = new CoverGridAdapter(this, coverItems, this, displayMode); - recyclerView.setAdapter(adapter); - - if (coverItems.isEmpty()) { - showEmptyState("No EPUB files found"); - } else { - hideEmptyState(); + if (epubToggle != null) { + epubToggle.setChecked(userPreferences.getShowEpubs()); + } + if (pdfToggle != null) { + pdfToggle.setChecked(userPreferences.getShowPdfs()); } - - progressBar.setVisibility(View.GONE); - swipeRefreshLayout.setRefreshing(false); - }); -} -private void showLoading() { - progressBar.setVisibility(View.VISIBLE); - emptyView.setVisibility(View.GONE); -} + return true; + } -private void showEmptyState(String message) { - progressBar.setVisibility(View.GONE); - emptyView.setVisibility(View.VISIBLE); - emptyView.setText(message); -} + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + MenuItem epubToggle = menu.findItem(R.id.action_toggle_epubs); + MenuItem pdfToggle = menu.findItem(R.id.action_toggle_pdfs); -private void hideEmptyState() { - emptyView.setVisibility(View.GONE); -} + if (epubToggle != null) { + epubToggle.setChecked(userPreferences.getShowEpubs()); + } + if (pdfToggle != null) { + pdfToggle.setChecked(userPreferences.getShowPdfs()); + } -@Override -public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.main_menu, menu); - - // Set the checkable state for file type toggles - MenuItem epubToggle = menu.findItem(R.id.action_toggle_epubs); - MenuItem pdfToggle = menu.findItem(R.id.action_toggle_pdfs); - - if (epubToggle != null) { - epubToggle.setChecked(userPreferences.getShowEpubs()); - } - if (pdfToggle != null) { - pdfToggle.setChecked(userPreferences.getShowPdfs()); + return super.onPrepareOptionsMenu(menu); } - - return true; -} -@Override -public boolean onPrepareOptionsMenu(Menu menu) { - // Update the checkable state every time the menu is shown - MenuItem epubToggle = menu.findItem(R.id.action_toggle_epubs); - MenuItem pdfToggle = menu.findItem(R.id.action_toggle_pdfs); - - if (epubToggle != null) { - epubToggle.setChecked(userPreferences.getShowEpubs()); - } - if (pdfToggle != null) { - pdfToggle.setChecked(userPreferences.getShowPdfs()); - } - - return super.onPrepareOptionsMenu(menu); -} + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + switch (item.getItemId()) { + case R.id.action_settings: + startActivity(new Intent(this, SettingsActivity.class)); + return true; + + case R.id.action_refresh: + if (!NetworkUtils.isNetworkAvailable(this)) { + Toast.makeText(this, "No internet connection available", + Toast.LENGTH_SHORT).show(); + } else { + refreshCurrentTab(); + } + return true; -@Override -public boolean onOptionsItemSelected(@NonNull MenuItem item) { - switch (item.getItemId()) { - case R.id.action_settings: - startActivity(new Intent(this, SettingsActivity.class)); - return true; - case R.id.action_refresh: - if (!NetworkUtils.isNetworkAvailable(this)) { - Toast.makeText(this, "No internet connection available", Toast.LENGTH_SHORT).show(); - } else { - refreshCovers(); - } - return true; - case R.id.action_info: - showInfoDialog(); - return true; - case R.id.action_select_collection: - showCollectionSelector(); - return true; - case R.id.action_change_display: - showDisplayModeDialog(); - return true; - case R.id.action_toggle_epubs: - toggleEpubsEnabled(item); - return true; - case R.id.action_toggle_pdfs: - togglePdfsEnabled(item); - return true; - default: - return super.onOptionsItemSelected(item); - } -} + case R.id.action_info: + showInfoDialog(); + return true; -private void toggleEpubsEnabled(MenuItem item) { - boolean currentState = userPreferences.getShowEpubs(); - boolean newState = !currentState; - - // Don't allow disabling if it's the only enabled type - if (!newState && !userPreferences.getShowPdfs()) { - Toast.makeText(this, "At least one file type must be enabled", Toast.LENGTH_SHORT).show(); - return; - } - - userPreferences.setShowEpubs(newState); - item.setChecked(newState); - - // Clear cache and reload - coverRepository.clearCovers(); - Toast.makeText(this, newState ? "EPUBs enabled" : "EPUBs disabled", Toast.LENGTH_SHORT).show(); - - if (NetworkUtils.isNetworkAvailable(this)) { - loadCoversFromApi(); - } else { - loadCovers(); // This will handle offline mode appropriately - } -} + case R.id.action_select_collection: + fabAddTab.performClick(); + return true; -private void showInfoDialog() { - AlertDialog.Builder builder = new AlertDialog.Builder(this); - builder.setTitle("About Zotero EPUB Covers"); - - // Use a custom layout with scrolling for the dialog - LayoutInflater inflater = getLayoutInflater(); - View dialogView = inflater.inflate(R.layout.dialog_info, null); - builder.setView(dialogView); - - builder.setPositiveButton("OK", (dialog, which) -> dialog.dismiss()); - - AlertDialog dialog = builder.create(); - dialog.show(); -} + case R.id.action_change_display: + showDisplayModeDialog(); + return true; -private void togglePdfsEnabled(MenuItem item) { - boolean currentState = userPreferences.getShowPdfs(); - boolean newState = !currentState; - - // Don't allow disabling if it's the only enabled type - if (!newState && !userPreferences.getShowEpubs()) { - Toast.makeText(this, "At least one file type must be enabled", Toast.LENGTH_SHORT).show(); - return; - } - - userPreferences.setShowPdfs(newState); - item.setChecked(newState); - - // Clear cache and reload - coverRepository.clearCovers(); - Toast.makeText(this, newState ? "PDFs enabled" : "PDFs disabled", Toast.LENGTH_SHORT).show(); - - if (NetworkUtils.isNetworkAvailable(this)) { - loadCoversFromApi(); - } else { - loadCovers(); // This will handle offline mode appropriately - } -} + case R.id.action_toggle_epubs: + toggleEpubsEnabled(item); + return true; -@Override -protected void onResume() { - super.onResume(); - - // Check if we need to reload due to settings changes - boolean needsReload = false; - - // If credentials are available but we have no covers, reload - if (userPreferences.hasZoteroCredentials() && coverItems.isEmpty()) { - needsReload = true; + case R.id.action_toggle_pdfs: + togglePdfsEnabled(item); + return true; + + default: + return super.onOptionsItemSelected(item); + } } - - // If file type preferences changed, we should reload - // (This is a simple approach - you could also store the previous preferences and compare) - if (!coverItems.isEmpty() && userPreferences.hasZoteroCredentials()) { - // Clear cache when returning from settings to ensure file type changes take effect - coverRepository.clearCovers(); - needsReload = true; + + private void refreshCurrentTab() { + int currentPosition = viewPager.getCurrentItem(); + CollectionFragment fragment = getCurrentFragment(); + if (fragment != null) { + fragment.refresh(); + } } - - if (needsReload) { - loadCovers(); + + private CollectionFragment getCurrentFragment() { + int currentPosition = viewPager.getCurrentItem(); + return (CollectionFragment) getSupportFragmentManager() + .findFragmentByTag("f" + currentPosition); } - - // Always update the title in case collection selection changed - updateTitle(); -} -private void updateTitle() { - if (getSupportActionBar() != null) { - String collectionName = userPreferences.getSelectedCollectionName(); - if (collectionName == null || collectionName.isEmpty()) { - collectionName = "All Collections"; + private void toggleEpubsEnabled(MenuItem item) { + boolean currentState = userPreferences.getShowEpubs(); + boolean newState = !currentState; + + if (!newState && !userPreferences.getShowPdfs()) { + Toast.makeText(this, "At least one file type must be enabled", + Toast.LENGTH_SHORT).show(); + return; } - getSupportActionBar().setSubtitle(collectionName + - (isOfflineMode ? " (Offline)" : "")); + + userPreferences.setShowEpubs(newState); + item.setChecked(newState); + + Toast.makeText(this, newState ? "EPUBs enabled" : "EPUBs disabled", + Toast.LENGTH_SHORT).show(); + + // Refresh all tabs + refreshAllTabs(); } -} -@Override -public void onCoverClick(EpubCoverItem item) { - // Open the Zotero web library when a cover is clicked - String zoteroUsername = userPreferences.getZoteroUsername(); - if (zoteroUsername == null || zoteroUsername.isEmpty()) { - return; + private void togglePdfsEnabled(MenuItem item) { + boolean currentState = userPreferences.getShowPdfs(); + boolean newState = !currentState; + + if (!newState && !userPreferences.getShowEpubs()) { + Toast.makeText(this, "At least one file type must be enabled", + Toast.LENGTH_SHORT).show(); + return; + } + + userPreferences.setShowPdfs(newState); + item.setChecked(newState); + + Toast.makeText(this, newState ? "PDFs enabled" : "PDFs disabled", + Toast.LENGTH_SHORT).show(); + + // Refresh all tabs + refreshAllTabs(); } - - if (!NetworkUtils.isNetworkAvailable(this)) { - Toast.makeText(this, "Cannot open reader - no internet connection", Toast.LENGTH_SHORT).show(); - return; + + private void refreshAllTabs() { + // Recreate the adapter to force all fragments to reload + setupTabs(); } - - String url = "https://www.zotero.org/" + zoteroUsername + "/items/" + item.getId()+"/reader"; - Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - startActivity(intent); -} -private void showCollectionSelector() { - if (!userPreferences.hasZoteroCredentials()) { - Toast.makeText(this, R.string.enter_credentials, Toast.LENGTH_SHORT).show(); - return; + private void showInfoDialog() { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("About ZotShelf"); + + android.view.LayoutInflater inflater = getLayoutInflater(); + View dialogView = inflater.inflate(R.layout.dialog_info, null); + builder.setView(dialogView); + + builder.setPositiveButton("OK", (dialog, which) -> dialog.dismiss()); + + AlertDialog dialog = builder.create(); + dialog.show(); } - - if (!NetworkUtils.isNetworkAvailable(this)) { - Toast.makeText(this, "No internet connection. Cannot fetch collections.", Toast.LENGTH_LONG).show(); - return; + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == REQUEST_CODE_SELECT_COLLECTION && resultCode == RESULT_OK) { + String collectionKey = userPreferences.getSelectedCollectionKey(); + String collectionName = userPreferences.getSelectedCollectionName(); + + if (collectionName == null || collectionName.isEmpty()) { + collectionName = "All Collections"; + } + + // Add new tab with the selected collection + tabStateManager.addTab(collectionKey, collectionName); + refreshTabs(); + + // Switch to the new tab + List tabs = tabStateManager.getOpenTabs(); + viewPager.setCurrentItem(tabs.size() - 1, true); + + Toast.makeText(this, "Opened " + collectionName + " in new tab", + Toast.LENGTH_SHORT).show(); + } } - - // Launch the collection tree activity - Intent intent = new Intent(this, CollectionTreeActivity.class); - startActivityForResult(intent, REQUEST_CODE_SELECT_COLLECTION); -} -@Override -protected void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - - if (requestCode == REQUEST_CODE_SELECT_COLLECTION && resultCode == RESULT_OK) { - // Collection was selected, update the UI - updateTitle(); - - // Clear the cache when switching collections - coverRepository.clearCovers(); - - // Reload covers with the new collection - if (NetworkUtils.isNetworkAvailable(this)) { - loadCoversFromApi(); - } else { - Toast.makeText(this, "No internet connection. Cannot load new collection.", Toast.LENGTH_LONG).show(); + @Override + protected void onResume() { + super.onResume(); + + // Check if credentials are available + if (!userPreferences.hasZoteroCredentials()) { + // If no credentials, we can't do much + return; + } + + // Check if we need to refresh tabs due to settings changes + if (!userPreferences.hasAnyFileTypeEnabled()) { + Toast.makeText(this, "Please enable at least one file type (EPUB or PDF)", + Toast.LENGTH_LONG).show(); } } -} -private void showDisplayModeDialog() { - AlertDialog.Builder builder = new AlertDialog.Builder(this); - builder.setTitle("Display Mode"); - - String[] options = {"Title only", "Author only", "Author - Title"}; - int currentMode = userPreferences.getDisplayMode(); - - builder.setSingleChoiceItems(options, currentMode, (dialog, which) -> { - userPreferences.setDisplayMode(which); - dialog.dismiss(); - - // Refresh the adapter to show the new display mode - adapter = new CoverGridAdapter(this, coverItems, this, which); - recyclerView.setAdapter(adapter); - }); - - builder.setNegativeButton("Cancel", (dialog, which) -> dialog.dismiss()); - - AlertDialog dialog = builder.create(); - dialog.show(); -} + private void showDisplayModeDialog() { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("Display Mode"); + + String[] options = {"Title only", "Author only", "Author - Title"}; + int currentMode = userPreferences.getDisplayMode(); -} \ No newline at end of file + builder.setSingleChoiceItems(options, currentMode, (dialog, which) -> { + userPreferences.setDisplayMode(which); + dialog.dismiss(); + + // Update display mode in all visible fragments + updateDisplayModeInAllFragments(); + }); + + builder.setNegativeButton("Cancel", (dialog, which) -> dialog.dismiss()); + + AlertDialog dialog = builder.create(); + dialog.show(); + } + + private void updateDisplayModeInAllFragments() { + // Recreate adapter to update all fragments + setupTabs(); + } +} diff --git a/app/src/main/java/oyvindbs/zotshelf/TabStateManager.java b/app/src/main/java/oyvindbs/zotshelf/TabStateManager.java new file mode 100644 index 0000000..2317c0d --- /dev/null +++ b/app/src/main/java/oyvindbs/zotshelf/TabStateManager.java @@ -0,0 +1,127 @@ +package oyvindbs.zotshelf; + +import android.content.Context; +import android.content.SharedPreferences; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +public class TabStateManager { + private static final String PREF_NAME = "TabStatePref"; + private static final String KEY_TABS = "open_tabs"; + private static final String KEY_CURRENT_TAB = "current_tab_index"; + private static final int MAX_TABS = 10; + + private final SharedPreferences preferences; + private final Gson gson; + + public TabStateManager(Context context) { + preferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); + gson = new Gson(); + } + + public static class TabInfo { + private String collectionKey; + private String collectionName; + + public TabInfo(String collectionKey, String collectionName) { + this.collectionKey = collectionKey; + this.collectionName = collectionName; + } + + public String getCollectionKey() { + return collectionKey; + } + + public String getCollectionName() { + return collectionName; + } + } + + public List getOpenTabs() { + String json = preferences.getString(KEY_TABS, null); + if (json == null || json.isEmpty()) { + // Return default tab (All Collections) + List defaultTabs = new ArrayList<>(); + defaultTabs.add(new TabInfo(null, "All Collections")); + return defaultTabs; + } + + Type type = new TypeToken>(){}.getType(); + List tabs = gson.fromJson(json, type); + return tabs != null ? tabs : new ArrayList<>(); + } + + public void saveOpenTabs(List tabs) { + String json = gson.toJson(tabs); + preferences.edit().putString(KEY_TABS, json).apply(); + } + + public void addTab(String collectionKey, String collectionName) { + List tabs = getOpenTabs(); + + // Check if tab already exists + for (TabInfo tab : tabs) { + if ((tab.getCollectionKey() == null && collectionKey == null) || + (tab.getCollectionKey() != null && tab.getCollectionKey().equals(collectionKey))) { + // Tab already exists, don't add duplicate + return; + } + } + + // Check max tabs limit + if (tabs.size() >= MAX_TABS) { + return; // Don't add more tabs than the limit + } + + tabs.add(new TabInfo(collectionKey, collectionName)); + saveOpenTabs(tabs); + } + + public void removeTab(int position) { + List tabs = getOpenTabs(); + if (position >= 0 && position < tabs.size()) { + tabs.remove(position); + + // Ensure at least one tab remains + if (tabs.isEmpty()) { + tabs.add(new TabInfo(null, "All Collections")); + } + + saveOpenTabs(tabs); + + // Adjust current tab index if necessary + int currentTab = getCurrentTabIndex(); + if (currentTab >= tabs.size()) { + setCurrentTabIndex(tabs.size() - 1); + } + } + } + + public int getCurrentTabIndex() { + return preferences.getInt(KEY_CURRENT_TAB, 0); + } + + public void setCurrentTabIndex(int index) { + preferences.edit().putInt(KEY_CURRENT_TAB, index).apply(); + } + + public void clearAllTabs() { + preferences.edit() + .remove(KEY_TABS) + .remove(KEY_CURRENT_TAB) + .apply(); + } + + public boolean canAddMoreTabs() { + return getOpenTabs().size() < MAX_TABS; + } + + public int getMaxTabs() { + return MAX_TABS; + } +} diff --git a/app/src/main/java/oyvindbs/zotshelf/database/EpubCoverRepository.java b/app/src/main/java/oyvindbs/zotshelf/database/EpubCoverRepository.java index 4ebfd96..18fe097 100644 --- a/app/src/main/java/oyvindbs/zotshelf/database/EpubCoverRepository.java +++ b/app/src/main/java/oyvindbs/zotshelf/database/EpubCoverRepository.java @@ -120,23 +120,52 @@ public void getFilteredCovers(CoverRepositoryCallback callback) { }); } + public void getFilteredCoversForCollection(String collectionKey, CoverRepositoryCallback callback) { + executor.execute(() -> { + try { + List entities = getFilteredEntitiesForCollection(collectionKey); + List coverItems = convertEntitiesToCoverItems(entities); + mainHandler.post(() -> callback.onCoversLoaded(coverItems)); + } catch (Exception e) { + Log.e(TAG, "Error loading filtered covers for collection", e); + mainHandler.post(() -> callback.onError("Error loading covers: " + e.getMessage())); + } + }); + } + private List getFilteredEntities() { boolean booksOnly = userPreferences.getBooksOnly(); boolean showEpubs = userPreferences.getShowEpubs(); boolean showPdfs = userPreferences.getShowPdfs(); String collectionKey = userPreferences.getSelectedCollectionKey(); - + List entities; if (collectionKey != null && !collectionKey.isEmpty()) { entities = database.epubCoverDao().getCoversByCollection(collectionKey, booksOnly, showEpubs, showPdfs); } else { entities = database.epubCoverDao().getCoversByPreferences(booksOnly, showEpubs, showPdfs); } - + Log.d(TAG, "Loaded " + entities.size() + " covers from database with filters applied"); return entities; } + private List getFilteredEntitiesForCollection(String collectionKey) { + boolean booksOnly = userPreferences.getBooksOnly(); + boolean showEpubs = userPreferences.getShowEpubs(); + boolean showPdfs = userPreferences.getShowPdfs(); + + List entities; + if (collectionKey != null && !collectionKey.isEmpty()) { + entities = database.epubCoverDao().getCoversByCollection(collectionKey, booksOnly, showEpubs, showPdfs); + } else { + entities = database.epubCoverDao().getCoversByPreferences(booksOnly, showEpubs, showPdfs); + } + + Log.d(TAG, "Loaded " + entities.size() + " covers for collection " + collectionKey); + return entities; + } + private List convertEntitiesToCoverItems(List entities) { List coverItems = new ArrayList<>(); for (EpubCoverEntity entity : entities) { diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 6668739..239412f 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,5 +1,5 @@ - - + app:layout_constraintEnd_toEndOf="parent" /> - - - - - + app:layout_constraintEnd_toEndOf="parent" /> - + app:layout_constraintEnd_toEndOf="parent" /> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_collection.xml b/app/src/main/res/layout/fragment_collection.xml new file mode 100644 index 0000000..67b75c0 --- /dev/null +++ b/app/src/main/res/layout/fragment_collection.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + From f54f64e53e207898cedc1f70a80fa8546551d485 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Oct 2025 19:55:31 +0000 Subject: [PATCH 2/8] Make gradlew executable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- gradlew | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 gradlew diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 From 363f97d11983f59488275fdd51d645a57d372fca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Oct 2025 21:10:13 +0000 Subject: [PATCH 3/8] Fix: App not loading collections after adding credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed critical initialization bug where tabs weren't set up if credentials were missing on first launch. This caused the app to show a blank screen after users added their credentials. Changes: - Move setupTabs() and setupFab() before credential check in onCreate - Remove early return that prevented tab initialization - Add isFirstResume flag to avoid unnecessary refreshes on initial load - Improve refreshCurrentTab() with null checks and ViewPager2 lifecycle awareness - Add refresh in onResume to reload data when returning from settings This ensures tabs are always initialized and fragments can handle the "no credentials" state gracefully, then automatically refresh when credentials become available. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../java/oyvindbs/zotshelf/MainActivity.java | 37 +++++++++++++++---- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/oyvindbs/zotshelf/MainActivity.java b/app/src/main/java/oyvindbs/zotshelf/MainActivity.java index e7e5647..923e9a2 100644 --- a/app/src/main/java/oyvindbs/zotshelf/MainActivity.java +++ b/app/src/main/java/oyvindbs/zotshelf/MainActivity.java @@ -31,6 +31,7 @@ public class MainActivity extends AppCompatActivity { private TabStateManager tabStateManager; private UserPreferences userPreferences; private TabLayoutMediator tabLayoutMediator; + private boolean isFirstResume = true; @Override protected void onCreate(Bundle savedInstanceState) { @@ -44,15 +45,15 @@ protected void onCreate(Bundle savedInstanceState) { viewPager = findViewById(R.id.viewPager); fabAddTab = findViewById(R.id.fabAddTab); + // Setup tabs regardless of credentials (fragments will handle empty state) + setupTabs(); + setupFab(); + // Check if we have Zotero credentials, if not show settings first if (!userPreferences.hasZoteroCredentials()) { startActivity(new Intent(this, SettingsActivity.class)); - return; } - setupTabs(); - setupFab(); - // Handle widget click intent if (getIntent().hasExtra("fromWidget")) { handleWidgetClick(getIntent()); @@ -274,15 +275,25 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) { } private void refreshCurrentTab() { - int currentPosition = viewPager.getCurrentItem(); - CollectionFragment fragment = getCurrentFragment(); - if (fragment != null) { - fragment.refresh(); + if (tabAdapter == null || viewPager.getAdapter() == null) { + return; } + + // Give ViewPager2 time to create fragments if needed + viewPager.post(() -> { + CollectionFragment fragment = getCurrentFragment(); + if (fragment != null && fragment.isAdded()) { + fragment.refresh(); + } + }); } private CollectionFragment getCurrentFragment() { + if (tabAdapter == null) { + return null; + } int currentPosition = viewPager.getCurrentItem(); + // ViewPager2 uses "f" + itemId as fragment tag return (CollectionFragment) getSupportFragmentManager() .findFragmentByTag("f" + currentPosition); } @@ -375,6 +386,12 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) { protected void onResume() { super.onResume(); + // Skip refresh on first resume (fragments will load automatically) + if (isFirstResume) { + isFirstResume = false; + return; + } + // Check if credentials are available if (!userPreferences.hasZoteroCredentials()) { // If no credentials, we can't do much @@ -385,7 +402,11 @@ protected void onResume() { if (!userPreferences.hasAnyFileTypeEnabled()) { Toast.makeText(this, "Please enable at least one file type (EPUB or PDF)", Toast.LENGTH_LONG).show(); + return; } + + // Refresh current tab to pick up any changes from settings + refreshCurrentTab(); } private void showDisplayModeDialog() { From b777c7127f0fe0e46c6718c943f8bb2841248c9c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Oct 2025 21:35:53 +0000 Subject: [PATCH 4/8] Implement sorting functionality for collections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added sorting feature that allows users to sort book covers by title or author. The sort button in the menu now displays a dialog where users can choose their preferred sort order. Changes: - Added action_sort case handler in MainActivity.onOptionsItemSelected() - Created showSortDialog() method to display sort options dialog - Created applySortingToCurrentTab() method to apply sorting to active tab - Added applySorting() method in CollectionFragment to sort and refresh items - Modified updateUI() in CollectionFragment to automatically apply current sort mode when loading covers - Utilizes existing CoverSorter class for title and author-based sorting - Sort preference is persisted via UserPreferences Features: - Sort by Title (default) - alphabetical with smart article handling - Sort by Author - sorts by first author's last name - Sort setting persists across app sessions - Instant visual feedback with toast notification - Sorts current tab when user changes preference 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../oyvindbs/zotshelf/CollectionFragment.java | 25 ++++++++++++ .../java/oyvindbs/zotshelf/MainActivity.java | 38 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/app/src/main/java/oyvindbs/zotshelf/CollectionFragment.java b/app/src/main/java/oyvindbs/zotshelf/CollectionFragment.java index c6678a4..a4765e6 100644 --- a/app/src/main/java/oyvindbs/zotshelf/CollectionFragment.java +++ b/app/src/main/java/oyvindbs/zotshelf/CollectionFragment.java @@ -425,6 +425,10 @@ private void updateUI(final List newItems) { coverItems.clear(); coverItems.addAll(newItems); + // Apply current sort mode + int sortMode = userPreferences.getSortMode(); + CoverSorter.sortCovers(coverItems, sortMode); + int displayMode = userPreferences.getDisplayMode(); adapter = new CoverGridAdapter(requireContext(), coverItems, this, displayMode); recyclerView.setAdapter(adapter); @@ -493,4 +497,25 @@ public void updateDisplayMode() { recyclerView.setAdapter(adapter); } } + + public void applySorting() { + if (getActivity() == null) return; + + getActivity().runOnUiThread(() -> { + if (!coverItems.isEmpty()) { + // Apply current sort mode + int sortMode = userPreferences.getSortMode(); + CoverSorter.sortCovers(coverItems, sortMode); + + // Refresh the adapter + if (adapter != null) { + adapter.notifyDataSetChanged(); + } else { + int displayMode = userPreferences.getDisplayMode(); + adapter = new CoverGridAdapter(requireContext(), coverItems, this, displayMode); + recyclerView.setAdapter(adapter); + } + } + }); + } } diff --git a/app/src/main/java/oyvindbs/zotshelf/MainActivity.java b/app/src/main/java/oyvindbs/zotshelf/MainActivity.java index 923e9a2..34abd7c 100644 --- a/app/src/main/java/oyvindbs/zotshelf/MainActivity.java +++ b/app/src/main/java/oyvindbs/zotshelf/MainActivity.java @@ -249,6 +249,10 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) { } return true; + case R.id.action_sort: + showSortDialog(); + return true; + case R.id.action_info: showInfoDialog(); return true; @@ -409,6 +413,40 @@ protected void onResume() { refreshCurrentTab(); } + private void showSortDialog() { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("Sort By"); + + String[] options = {"Title", "Author"}; + int currentMode = userPreferences.getSortMode(); + + builder.setSingleChoiceItems(options, currentMode, (dialog, which) -> { + userPreferences.setSortMode(which); + dialog.dismiss(); + + // Apply sorting to current tab + applySortingToCurrentTab(); + + Toast.makeText(this, + "Sorted by " + (which == UserPreferences.SORT_BY_TITLE ? "Title" : "Author"), + Toast.LENGTH_SHORT).show(); + }); + + builder.setNegativeButton("Cancel", (dialog, which) -> dialog.dismiss()); + + AlertDialog dialog = builder.create(); + dialog.show(); + } + + private void applySortingToCurrentTab() { + viewPager.post(() -> { + CollectionFragment fragment = getCurrentFragment(); + if (fragment != null && fragment.isAdded()) { + fragment.applySorting(); + } + }); + } + private void showDisplayModeDialog() { AlertDialog.Builder builder = new AlertDialog.Builder(this); builder.setTitle("Display Mode"); From b24c4c99f3dd34f97b851665296936f0a96714e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Oct 2025 21:58:59 +0000 Subject: [PATCH 5/8] Implement tag-based filtering for collections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added comprehensive tag filtering functionality that allows users to create tabs filtered by Zotero tags. Users can now filter items by tags, collections, or a combination of both. Key changes: - Updated TabStateManager.TabInfo to support tags in addition to collections with three tab types: collection-only, tag-only, or both - Added getDisplayName() method to show appropriate tab titles based on filter type (e.g., "Tags: fiction; history") - Extended ZoteroApiClient API methods to accept tag query parameters for filtering Zotero API requests - Modified getAllEbookItemsWithMetadata and related methods to support tag-based filtering via Zotero API - Updated CollectionFragment to handle tag filtering in addition to collection filtering - Modified CollectionTabAdapter to pass tags to fragment instances - Enhanced MainActivity with tag input dialog allowing semicolon- separated tag entry - Updated FAB to show "Add New Tab" dialog with options: "By Collection" or "By Tags" - Updated all tab display names to use getDisplayName() for consistent labeling Features: - Create tabs filtered by tags only - Create tabs filtered by collection + tags - Multiple tags supported (semicolon-separated) - Tab titles clearly indicate filter type - Tags persist across app restarts - Zotero API filters items matching all specified tags Users can now: 1. Click FAB → "By Tags" → Enter "fiction; sci-fi" → See only books with both tags 2. Create multiple tag-based tabs alongside collection tabs 3. Mix and match filtering approaches as needed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../oyvindbs/zotshelf/CollectionFragment.java | 14 +++- .../zotshelf/CollectionTabAdapter.java | 3 +- .../java/oyvindbs/zotshelf/MainActivity.java | 76 ++++++++++++++++--- .../oyvindbs/zotshelf/TabStateManager.java | 74 +++++++++++++++++- .../oyvindbs/zotshelf/ZoteroApiClient.java | 48 +++++++----- 5 files changed, 182 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/oyvindbs/zotshelf/CollectionFragment.java b/app/src/main/java/oyvindbs/zotshelf/CollectionFragment.java index a4765e6..951c871 100644 --- a/app/src/main/java/oyvindbs/zotshelf/CollectionFragment.java +++ b/app/src/main/java/oyvindbs/zotshelf/CollectionFragment.java @@ -29,9 +29,11 @@ public class CollectionFragment extends Fragment implements CoverGridAdapter.Cov private static final String ARG_COLLECTION_KEY = "collection_key"; private static final String ARG_COLLECTION_NAME = "collection_name"; + private static final String ARG_TAGS = "tags"; private String collectionKey; private String collectionName; + private String tags; private RecyclerView recyclerView; private CoverGridAdapter adapter; @@ -45,10 +47,15 @@ public class CollectionFragment extends Fragment implements CoverGridAdapter.Cov private boolean isOfflineMode = false; public static CollectionFragment newInstance(String collectionKey, String collectionName) { + return newInstance(collectionKey, collectionName, null); + } + + public static CollectionFragment newInstance(String collectionKey, String collectionName, String tags) { CollectionFragment fragment = new CollectionFragment(); Bundle args = new Bundle(); args.putString(ARG_COLLECTION_KEY, collectionKey); args.putString(ARG_COLLECTION_NAME, collectionName); + args.putString(ARG_TAGS, tags); fragment.setArguments(args); return fragment; } @@ -59,6 +66,7 @@ public void onCreate(@Nullable Bundle savedInstanceState) { if (getArguments() != null) { collectionKey = getArguments().getString(ARG_COLLECTION_KEY); collectionName = getArguments().getString(ARG_COLLECTION_NAME); + tags = getArguments().getString(ARG_TAGS); } userPreferences = new UserPreferences(requireContext()); @@ -183,9 +191,9 @@ private void loadCoversFromApi() { String userId = userPreferences.getZoteroUserId(); String apiKey = userPreferences.getZoteroApiKey(); - Log.d("CollectionFragment", "Loading covers from API - Collection: " + collectionKey); + Log.d("CollectionFragment", "Loading covers from API - Collection: " + collectionKey + ", Tags: " + tags); - zoteroApiClient.getAllEbookItemsWithMetadata(userId, apiKey, collectionKey, + zoteroApiClient.getAllEbookItemsWithMetadata(userId, apiKey, collectionKey, tags, new ZoteroApiClient.ZoteroCallback>() { @Override public void onSuccess(List zoteroItems) { @@ -219,7 +227,7 @@ private void loadCoversFromApiInBackground() { String userId = userPreferences.getZoteroUserId(); String apiKey = userPreferences.getZoteroApiKey(); - zoteroApiClient.getAllEbookItemsWithMetadata(userId, apiKey, collectionKey, + zoteroApiClient.getAllEbookItemsWithMetadata(userId, apiKey, collectionKey, tags, new ZoteroApiClient.ZoteroCallback>() { @Override public void onSuccess(List zoteroItems) { diff --git a/app/src/main/java/oyvindbs/zotshelf/CollectionTabAdapter.java b/app/src/main/java/oyvindbs/zotshelf/CollectionTabAdapter.java index 7da4aeb..f61e5f5 100644 --- a/app/src/main/java/oyvindbs/zotshelf/CollectionTabAdapter.java +++ b/app/src/main/java/oyvindbs/zotshelf/CollectionTabAdapter.java @@ -23,7 +23,8 @@ public Fragment createFragment(int position) { TabStateManager.TabInfo tab = tabs.get(position); return CollectionFragment.newInstance( tab.getCollectionKey(), - tab.getCollectionName() + tab.getCollectionName(), + tab.getTags() ); } diff --git a/app/src/main/java/oyvindbs/zotshelf/MainActivity.java b/app/src/main/java/oyvindbs/zotshelf/MainActivity.java index 34abd7c..05e9222 100644 --- a/app/src/main/java/oyvindbs/zotshelf/MainActivity.java +++ b/app/src/main/java/oyvindbs/zotshelf/MainActivity.java @@ -95,7 +95,7 @@ private void setupTabs() { (tab, position) -> { TabStateManager.TabInfo tabInfo = tabAdapter.getTabAt(position); if (tabInfo != null) { - tab.setText(tabInfo.getCollectionName()); + tab.setText(tabInfo.getDisplayName()); // Add close button for tabs (except if it's the only tab) if (tabs.size() > 1) { @@ -138,16 +138,74 @@ private void setupFab() { return; } - if (!NetworkUtils.isNetworkAvailable(this)) { - Toast.makeText(this, "No internet connection. Cannot fetch collections.", - Toast.LENGTH_LONG).show(); + // Show dialog to choose tab type + showAddTabDialog(); + }); + } + + private void showAddTabDialog() { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("Add New Tab"); + + String[] options = {"By Collection", "By Tags"}; + + builder.setItems(options, (dialog, which) -> { + if (which == 0) { + // By Collection + if (!NetworkUtils.isNetworkAvailable(this)) { + Toast.makeText(this, "No internet connection. Cannot fetch collections.", + Toast.LENGTH_LONG).show(); + return; + } + Intent intent = new Intent(this, CollectionTreeActivity.class); + startActivityForResult(intent, REQUEST_CODE_SELECT_COLLECTION); + } else { + // By Tags + showTagInputDialog(); + } + }); + + builder.setNegativeButton("Cancel", null); + builder.show(); + } + + private void showTagInputDialog() { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("Filter by Tags"); + + // Create input field + android.widget.EditText input = new android.widget.EditText(this); + input.setHint("Enter tags (separated by semicolons)"); + input.setInputType(android.text.InputType.TYPE_CLASS_TEXT); + + android.widget.LinearLayout.LayoutParams lp = new android.widget.LinearLayout.LayoutParams( + android.widget.LinearLayout.LayoutParams.MATCH_PARENT, + android.widget.LinearLayout.LayoutParams.WRAP_CONTENT); + lp.setMargins(50, 0, 50, 0); + input.setLayoutParams(lp); + + builder.setView(input); + + builder.setPositiveButton("Create Tab", (dialog, which) -> { + String tags = input.getText().toString().trim(); + if (tags.isEmpty()) { + Toast.makeText(this, "Please enter at least one tag", Toast.LENGTH_SHORT).show(); return; } - // Launch the collection tree activity - Intent intent = new Intent(this, CollectionTreeActivity.class); - startActivityForResult(intent, REQUEST_CODE_SELECT_COLLECTION); + // Add new tag-based tab + tabStateManager.addTagTab(tags); + refreshTabs(); + + // Switch to the new tab + List allTabs = tabStateManager.getOpenTabs(); + viewPager.setCurrentItem(allTabs.size() - 1, true); + + Toast.makeText(this, "Created tag filter: " + tags, Toast.LENGTH_SHORT).show(); }); + + builder.setNegativeButton("Cancel", null); + builder.show(); } private void showCloseTabDialog(int position) { @@ -156,7 +214,7 @@ private void showCloseTabDialog(int position) { new AlertDialog.Builder(this) .setTitle("Close Tab") - .setMessage("Close tab '" + tabInfo.getCollectionName() + "'?") + .setMessage("Close tab '" + tabInfo.getDisplayName() + "'?") .setPositiveButton("Close", (dialog, which) -> closeTab(position)) .setNegativeButton("Cancel", null) .show(); @@ -180,7 +238,7 @@ private void refreshTabs() { (tab, position) -> { TabStateManager.TabInfo tabInfo = tabAdapter.getTabAt(position); if (tabInfo != null) { - tab.setText(tabInfo.getCollectionName()); + tab.setText(tabInfo.getDisplayName()); if (tabs.size() > 1) { tab.view.setOnLongClickListener(v -> { diff --git a/app/src/main/java/oyvindbs/zotshelf/TabStateManager.java b/app/src/main/java/oyvindbs/zotshelf/TabStateManager.java index 2317c0d..0c3c1da 100644 --- a/app/src/main/java/oyvindbs/zotshelf/TabStateManager.java +++ b/app/src/main/java/oyvindbs/zotshelf/TabStateManager.java @@ -27,10 +27,31 @@ public TabStateManager(Context context) { public static class TabInfo { private String collectionKey; private String collectionName; + private String tags; // Semicolon-separated tags + private String tabType; // "collection", "tags", or "both" + // Constructor for collection-only tabs (backward compatibility) public TabInfo(String collectionKey, String collectionName) { this.collectionKey = collectionKey; this.collectionName = collectionName; + this.tags = null; + this.tabType = "collection"; + } + + // Constructor for tag-only tabs + public TabInfo(String tags) { + this.collectionKey = null; + this.collectionName = null; + this.tags = tags; + this.tabType = "tags"; + } + + // Constructor for combined collection + tags + public TabInfo(String collectionKey, String collectionName, String tags) { + this.collectionKey = collectionKey; + this.collectionName = collectionName; + this.tags = tags; + this.tabType = "both"; } public String getCollectionKey() { @@ -40,6 +61,32 @@ public String getCollectionKey() { public String getCollectionName() { return collectionName; } + + public String getTags() { + return tags; + } + + public String getTabType() { + return tabType != null ? tabType : "collection"; + } + + public String getDisplayName() { + if ("tags".equals(getTabType())) { + return "Tags: " + tags; + } else if ("both".equals(getTabType())) { + return collectionName + " [" + tags + "]"; + } else { + return collectionName != null ? collectionName : "All Collections"; + } + } + + public boolean hasTags() { + return tags != null && !tags.trim().isEmpty(); + } + + public boolean hasCollection() { + return collectionKey != null && !collectionKey.isEmpty(); + } } public List getOpenTabs() { @@ -62,12 +109,24 @@ public void saveOpenTabs(List tabs) { } public void addTab(String collectionKey, String collectionName) { + addTab(collectionKey, collectionName, null); + } + + public void addTagTab(String tags) { + addTab(null, null, tags); + } + + public void addTab(String collectionKey, String collectionName, String tags) { List tabs = getOpenTabs(); // Check if tab already exists for (TabInfo tab : tabs) { - if ((tab.getCollectionKey() == null && collectionKey == null) || - (tab.getCollectionKey() != null && tab.getCollectionKey().equals(collectionKey))) { + boolean collectionMatches = (tab.getCollectionKey() == null && collectionKey == null) || + (tab.getCollectionKey() != null && tab.getCollectionKey().equals(collectionKey)); + boolean tagsMatch = (tab.getTags() == null && tags == null) || + (tab.getTags() != null && tab.getTags().equals(tags)); + + if (collectionMatches && tagsMatch) { // Tab already exists, don't add duplicate return; } @@ -78,7 +137,16 @@ public void addTab(String collectionKey, String collectionName) { return; // Don't add more tabs than the limit } - tabs.add(new TabInfo(collectionKey, collectionName)); + TabInfo newTab; + if (tags != null && !tags.trim().isEmpty() && collectionKey != null) { + newTab = new TabInfo(collectionKey, collectionName, tags); + } else if (tags != null && !tags.trim().isEmpty()) { + newTab = new TabInfo(tags); + } else { + newTab = new TabInfo(collectionKey, collectionName); + } + + tabs.add(newTab); saveOpenTabs(tabs); } diff --git a/app/src/main/java/oyvindbs/zotshelf/ZoteroApiClient.java b/app/src/main/java/oyvindbs/zotshelf/ZoteroApiClient.java index d3e36a3..d422e39 100644 --- a/app/src/main/java/oyvindbs/zotshelf/ZoteroApiClient.java +++ b/app/src/main/java/oyvindbs/zotshelf/ZoteroApiClient.java @@ -105,7 +105,8 @@ Call> getItemsPaginated( @Query("format") String format, @Query("itemType") String itemType, @Query("start") int start, - @Query("limit") int limit + @Query("limit") int limit, + @Query("tag") String tag ); @GET("users/{userId}/collections/{collectionKey}/items") @@ -116,7 +117,8 @@ Call> getItemsByCollectionPaginated( @Query("format") String format, @Query("itemType") String itemType, @Query("start") int start, - @Query("limit") int limit + @Query("limit") int limit, + @Query("tag") String tag ); @GET @@ -720,25 +722,29 @@ public void onError(String errorMessage) { } public void getAllEbookItemsByCollection(String userId, String apiKey, String collectionKey, ZoteroCallback> callback) { + getAllEbookItemsByCollection(userId, apiKey, collectionKey, null, callback); + } + + public void getAllEbookItemsByCollection(String userId, String apiKey, String collectionKey, String tags, ZoteroCallback> callback) { executor.execute(() -> { if (collectionKey == null || collectionKey.isEmpty()) { - getAllEbookItems(userId, apiKey, callback); + getAllEbookItems(userId, apiKey, tags, callback); return; } - getAllEbookItemsPaginated(userId, apiKey, collectionKey, new ArrayList<>(), 0, callback); + getAllEbookItemsPaginated(userId, apiKey, collectionKey, tags, new ArrayList<>(), 0, callback); }); } - private void getAllEbookItemsPaginated(String userId, String apiKey, String collectionKey, - List allItems, int start, + private void getAllEbookItemsPaginated(String userId, String apiKey, String collectionKey, String tags, + List allItems, int start, ZoteroCallback> callback) { - + Call> call; - + if (collectionKey == null || collectionKey.isEmpty()) { - call = zoteroService.getItemsPaginated(userId, apiKey, "json", "attachment", start, 100); + call = zoteroService.getItemsPaginated(userId, apiKey, "json", "attachment", start, 100, tags); } else { - call = zoteroService.getItemsByCollectionPaginated(userId, collectionKey, apiKey, "json", "attachment", start, 100); + call = zoteroService.getItemsByCollectionPaginated(userId, collectionKey, apiKey, "json", "attachment", start, 100, tags); } try { @@ -748,9 +754,9 @@ private void getAllEbookItemsPaginated(String userId, String apiKey, String coll List filteredItems = filterItemsByUserPreferences(items); allItems.addAll(filteredItems); - + if (items.size() == 100) { - getAllEbookItemsPaginated(userId, apiKey, collectionKey, allItems, start + 100, callback); + getAllEbookItemsPaginated(userId, apiKey, collectionKey, tags, allItems, start + 100, callback); } else { Log.d(TAG, "Fetched total of " + allItems.size() + " ebook items"); callback.onSuccess(allItems); @@ -764,12 +770,20 @@ private void getAllEbookItemsPaginated(String userId, String apiKey, String coll } } public void getAllEbookItems(String userId, String apiKey, ZoteroCallback> callback) { + getAllEbookItems(userId, apiKey, null, callback); +} + +public void getAllEbookItems(String userId, String apiKey, String tags, ZoteroCallback> callback) { executor.execute(() -> { - getAllEbookItemsPaginated(userId, apiKey, null, new ArrayList<>(), 0, callback); + getAllEbookItemsPaginated(userId, apiKey, null, tags, new ArrayList<>(), 0, callback); }); } public void getAllEbookItemsWithMetadata(String userId, String apiKey, String collectionKey, ZoteroCallback> callback) { + getAllEbookItemsWithMetadata(userId, apiKey, collectionKey, null, callback); +} + +public void getAllEbookItemsWithMetadata(String userId, String apiKey, String collectionKey, String tags, ZoteroCallback> callback) { ZoteroCallback> ebookCallback = new ZoteroCallback>() { @Override public void onSuccess(List ebookItems) { @@ -846,11 +860,11 @@ public void onError(String errorMessage) { callback.onError(errorMessage); } }; - + if (collectionKey == null || collectionKey.isEmpty()) { - getAllEbookItems(userId, apiKey, ebookCallback); + getAllEbookItems(userId, apiKey, tags, ebookCallback); } else { - getAllEbookItemsByCollection(userId, apiKey, collectionKey, ebookCallback); + getAllEbookItemsByCollection(userId, apiKey, collectionKey, tags, ebookCallback); } -} +} } From a4306b12acbb857fa86c2dae4e9c941cb241a510 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Oct 2025 07:08:19 +0000 Subject: [PATCH 6/8] Fix tag filtering and add combined collection+tag support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed critical issues with tag filtering and enhanced functionality: 1. Fixed Tags API Integration: - Changed tag parameter from String to List in ZoteroService - Added parseTagsToList() helper to convert semicolon-separated tags - Zotero API now receives multiple tag parameters for proper AND logic - Tags are properly split and trimmed before API calls 2. Fixed Tab Text Case Sensitivity: - Added TabTextStyle with textAllCaps=false to themes.xml - Applied style to TabLayout in activity_main.xml - Tab titles now preserve original case (e.g., "fiction" not "FICTION") - Critical for matching case-sensitive Zotero tags 3. Added Collection + Tags Combined Filtering: - New third option in Add Tab dialog: "By Collection + Tags" - Two-step flow: Enter tags first, then select collection - Added REQUEST_CODE_SELECT_COLLECTION_WITH_TAGS constant - Added pendingTags field to store tags between dialogs - Enhanced onActivityResult to handle combined filtering - Tab displays as: "Collection Name [tag1; tag2]" Features now available: - Filter by tags only: Enter "fiction; sci-fi" → See all matching items - Filter by collection only: Select a collection - Filter by both: Enter tags, then select collection → See only items in that collection matching ALL tags Bug fixes: - Tags now work correctly with Zotero API - Tab names preserve case for readability - Multiple tags properly combined with AND logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../java/oyvindbs/zotshelf/MainActivity.java | 68 +++++++++++++++---- .../oyvindbs/zotshelf/ZoteroApiClient.java | 30 ++++++-- app/src/main/res/layout/activity_main.xml | 2 + app/src/main/res/values/themes.xml | 5 ++ 4 files changed, 87 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/oyvindbs/zotshelf/MainActivity.java b/app/src/main/java/oyvindbs/zotshelf/MainActivity.java index 05e9222..ace426a 100644 --- a/app/src/main/java/oyvindbs/zotshelf/MainActivity.java +++ b/app/src/main/java/oyvindbs/zotshelf/MainActivity.java @@ -23,6 +23,8 @@ public class MainActivity extends AppCompatActivity { private static final int REQUEST_CODE_SELECT_COLLECTION = 1001; + private static final int REQUEST_CODE_SELECT_COLLECTION_WITH_TAGS = 1002; + private String pendingTags = null; private TabLayout tabLayout; private ViewPager2 viewPager; @@ -147,11 +149,11 @@ private void showAddTabDialog() { AlertDialog.Builder builder = new AlertDialog.Builder(this); builder.setTitle("Add New Tab"); - String[] options = {"By Collection", "By Tags"}; + String[] options = {"By Collection", "By Tags", "By Collection + Tags"}; builder.setItems(options, (dialog, which) -> { if (which == 0) { - // By Collection + // By Collection only if (!NetworkUtils.isNetworkAvailable(this)) { Toast.makeText(this, "No internet connection. Cannot fetch collections.", Toast.LENGTH_LONG).show(); @@ -159,9 +161,12 @@ private void showAddTabDialog() { } Intent intent = new Intent(this, CollectionTreeActivity.class); startActivityForResult(intent, REQUEST_CODE_SELECT_COLLECTION); + } else if (which == 1) { + // By Tags only + showTagInputDialog(false); } else { - // By Tags - showTagInputDialog(); + // By Collection + Tags + showTagInputDialog(true); } }); @@ -169,9 +174,9 @@ private void showAddTabDialog() { builder.show(); } - private void showTagInputDialog() { + private void showTagInputDialog(boolean withCollection) { AlertDialog.Builder builder = new AlertDialog.Builder(this); - builder.setTitle("Filter by Tags"); + builder.setTitle(withCollection ? "Filter by Tags (then select collection)" : "Filter by Tags"); // Create input field android.widget.EditText input = new android.widget.EditText(this); @@ -186,22 +191,35 @@ private void showTagInputDialog() { builder.setView(input); - builder.setPositiveButton("Create Tab", (dialog, which) -> { + builder.setPositiveButton(withCollection ? "Next: Select Collection" : "Create Tab", (dialog, which) -> { String tags = input.getText().toString().trim(); if (tags.isEmpty()) { Toast.makeText(this, "Please enter at least one tag", Toast.LENGTH_SHORT).show(); return; } - // Add new tag-based tab - tabStateManager.addTagTab(tags); - refreshTabs(); + if (withCollection) { + // Store tags and show collection selector + pendingTags = tags; + if (!NetworkUtils.isNetworkAvailable(this)) { + Toast.makeText(this, "No internet connection. Cannot fetch collections.", + Toast.LENGTH_LONG).show(); + pendingTags = null; + return; + } + Intent intent = new Intent(this, CollectionTreeActivity.class); + startActivityForResult(intent, REQUEST_CODE_SELECT_COLLECTION_WITH_TAGS); + } else { + // Add new tag-only tab + tabStateManager.addTagTab(tags); + refreshTabs(); - // Switch to the new tab - List allTabs = tabStateManager.getOpenTabs(); - viewPager.setCurrentItem(allTabs.size() - 1, true); + // Switch to the new tab + List allTabs = tabStateManager.getOpenTabs(); + viewPager.setCurrentItem(allTabs.size() - 1, true); - Toast.makeText(this, "Created tag filter: " + tags, Toast.LENGTH_SHORT).show(); + Toast.makeText(this, "Created tag filter: " + tags, Toast.LENGTH_SHORT).show(); + } }); builder.setNegativeButton("Cancel", null); @@ -441,6 +459,28 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) { Toast.makeText(this, "Opened " + collectionName + " in new tab", Toast.LENGTH_SHORT).show(); + } else if (requestCode == REQUEST_CODE_SELECT_COLLECTION_WITH_TAGS && resultCode == RESULT_OK) { + String collectionKey = userPreferences.getSelectedCollectionKey(); + String collectionName = userPreferences.getSelectedCollectionName(); + + if (collectionName == null || collectionName.isEmpty()) { + collectionName = "All Collections"; + } + + // Add new tab with both collection and tags + if (pendingTags != null) { + tabStateManager.addTab(collectionKey, collectionName, pendingTags); + refreshTabs(); + + // Switch to the new tab + List tabs = tabStateManager.getOpenTabs(); + viewPager.setCurrentItem(tabs.size() - 1, true); + + Toast.makeText(this, "Opened " + collectionName + " with tags: " + pendingTags, + Toast.LENGTH_SHORT).show(); + + pendingTags = null; + } } } diff --git a/app/src/main/java/oyvindbs/zotshelf/ZoteroApiClient.java b/app/src/main/java/oyvindbs/zotshelf/ZoteroApiClient.java index d422e39..64ba478 100644 --- a/app/src/main/java/oyvindbs/zotshelf/ZoteroApiClient.java +++ b/app/src/main/java/oyvindbs/zotshelf/ZoteroApiClient.java @@ -106,7 +106,7 @@ Call> getItemsPaginated( @Query("itemType") String itemType, @Query("start") int start, @Query("limit") int limit, - @Query("tag") String tag + @Query("tag") List tags ); @GET("users/{userId}/collections/{collectionKey}/items") @@ -118,7 +118,7 @@ Call> getItemsByCollectionPaginated( @Query("itemType") String itemType, @Query("start") int start, @Query("limit") int limit, - @Query("tag") String tag + @Query("tag") List tags ); @GET @@ -740,11 +740,12 @@ private void getAllEbookItemsPaginated(String userId, String apiKey, String coll ZoteroCallback> callback) { Call> call; + List tagList = parseTagsToList(tags); if (collectionKey == null || collectionKey.isEmpty()) { - call = zoteroService.getItemsPaginated(userId, apiKey, "json", "attachment", start, 100, tags); + call = zoteroService.getItemsPaginated(userId, apiKey, "json", "attachment", start, 100, tagList); } else { - call = zoteroService.getItemsByCollectionPaginated(userId, collectionKey, apiKey, "json", "attachment", start, 100, tags); + call = zoteroService.getItemsByCollectionPaginated(userId, collectionKey, apiKey, "json", "attachment", start, 100, tagList); } try { @@ -867,4 +868,25 @@ public void onError(String errorMessage) { getAllEbookItemsByCollection(userId, apiKey, collectionKey, tags, ebookCallback); } } + + /** + * Convert semicolon-separated tags string into a List for Zotero API + * Zotero API requires multiple tag parameters for AND logic + */ + private List parseTagsToList(String tags) { + if (tags == null || tags.trim().isEmpty()) { + return null; + } + + List tagList = new ArrayList<>(); + String[] tagArray = tags.split(";"); + for (String tag : tagArray) { + String trimmed = tag.trim(); + if (!trimmed.isEmpty()) { + tagList.add(trimmed); + } + } + + return tagList.isEmpty() ? null : tagList; + } } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 239412f..e316e1e 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -12,9 +12,11 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:background="?attr/colorPrimary" + android:textAllCaps="false" app:tabMode="scrollable" app:tabTextColor="@android:color/white" app:tabSelectedTextColor="@android:color/white" + app:tabTextAppearance="@style/TabTextStyle" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" /> diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 04b5fe7..3974d88 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -14,4 +14,9 @@ ?attr/colorPrimaryVariant + + + \ No newline at end of file From df5761a176278e608a3a659d05db80c1d8739483 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Oct 2025 11:28:54 +0000 Subject: [PATCH 7/8] Add comprehensive tag filtering diagnostics and fix API calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improved tag filtering with better error handling and separated API methods: Changes: - Split Retrofit API methods into separate versions with/without tags to prevent passing null/empty tag parameters to Zotero API - Added getItemsPaginatedWithTags() for tag-filtered requests - Added getItemsByCollectionPaginatedWithTags() for combined filtering - Conditional API method selection based on whether tags are present - Enhanced error logging to show actual Zotero API error responses - Added detailed logging of tag parsing and API URLs for debugging - Improved error messages shown to users with actual error content The separate API methods ensure clean URL generation: - Without tags: /users/{id}/items?format=json&itemType=attachment - With tags: /users/{id}/items?format=json&itemType=attachment&tag=fiction&tag=sci-fi This prevents Retrofit from adding tag=null which could cause API errors. Debugging improvements: - Log shows parsed tag list before API call - Log shows complete API request URL - Error messages now include HTTP response body from Zotero - Better error context for troubleshooting tag issues 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../oyvindbs/zotshelf/ZoteroApiClient.java | 57 +++++++++++++++++-- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/oyvindbs/zotshelf/ZoteroApiClient.java b/app/src/main/java/oyvindbs/zotshelf/ZoteroApiClient.java index 64ba478..302036f 100644 --- a/app/src/main/java/oyvindbs/zotshelf/ZoteroApiClient.java +++ b/app/src/main/java/oyvindbs/zotshelf/ZoteroApiClient.java @@ -100,6 +100,16 @@ Call> getItemsByCollection( @GET("users/{userId}/items") Call> getItemsPaginated( + @Path("userId") String userId, + @Header("Zotero-API-Key") String apiKey, + @Query("format") String format, + @Query("itemType") String itemType, + @Query("start") int start, + @Query("limit") int limit + ); + + @GET("users/{userId}/items") + Call> getItemsPaginatedWithTags( @Path("userId") String userId, @Header("Zotero-API-Key") String apiKey, @Query("format") String format, @@ -111,6 +121,17 @@ Call> getItemsPaginated( @GET("users/{userId}/collections/{collectionKey}/items") Call> getItemsByCollectionPaginated( + @Path("userId") String userId, + @Path("collectionKey") String collectionKey, + @Header("Zotero-API-Key") String apiKey, + @Query("format") String format, + @Query("itemType") String itemType, + @Query("start") int start, + @Query("limit") int limit + ); + + @GET("users/{userId}/collections/{collectionKey}/items") + Call> getItemsByCollectionPaginatedWithTags( @Path("userId") String userId, @Path("collectionKey") String collectionKey, @Header("Zotero-API-Key") String apiKey, @@ -742,17 +763,32 @@ private void getAllEbookItemsPaginated(String userId, String apiKey, String coll Call> call; List tagList = parseTagsToList(tags); - if (collectionKey == null || collectionKey.isEmpty()) { - call = zoteroService.getItemsPaginated(userId, apiKey, "json", "attachment", start, 100, tagList); + Log.d(TAG, "Fetching items with tags: " + tags + " -> parsed to list: " + tagList); + + // Use different API methods based on whether we have tags + if (tagList != null && !tagList.isEmpty()) { + // With tags + if (collectionKey == null || collectionKey.isEmpty()) { + call = zoteroService.getItemsPaginatedWithTags(userId, apiKey, "json", "attachment", start, 100, tagList); + } else { + call = zoteroService.getItemsByCollectionPaginatedWithTags(userId, collectionKey, apiKey, "json", "attachment", start, 100, tagList); + } } else { - call = zoteroService.getItemsByCollectionPaginated(userId, collectionKey, apiKey, "json", "attachment", start, 100, tagList); + // Without tags + if (collectionKey == null || collectionKey.isEmpty()) { + call = zoteroService.getItemsPaginated(userId, apiKey, "json", "attachment", start, 100); + } else { + call = zoteroService.getItemsByCollectionPaginated(userId, collectionKey, apiKey, "json", "attachment", start, 100); + } } - + + Log.d(TAG, "API Request URL: " + call.request().url()); + try { Response> response = call.execute(); if (response.isSuccessful() && response.body() != null) { List items = response.body(); - + List filteredItems = filterItemsByUserPreferences(items); allItems.addAll(filteredItems); @@ -763,7 +799,16 @@ private void getAllEbookItemsPaginated(String userId, String apiKey, String coll callback.onSuccess(allItems); } } else { - callback.onError("Failed to fetch items: " + response.code()); + String errorMsg = "Failed to fetch items: HTTP " + response.code(); + try { + if (response.errorBody() != null) { + errorMsg = response.errorBody().string(); + Log.e(TAG, "API Error Response: " + errorMsg); + } + } catch (IOException e) { + Log.e(TAG, "Could not read error body", e); + } + callback.onError(errorMsg); } } catch (IOException e) { Log.e(TAG, "API error", e); From 1722bf1e951ea4e48c2ce9131ea49adef028a460 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Oct 2025 12:05:52 +0000 Subject: [PATCH 8/8] Add comprehensive tag filtering diagnostics and improve error visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit addresses the issue where tag filtering errors were impossible to read because they appeared on top of book covers. It also adds detailed diagnostic information to help troubleshoot tag filtering issues. Changes: - Created DiagnosticInfo.java to track API call details - Modified CollectionFragment to show errors in AlertDialog instead of TextView - Added "Show Diagnostics" button in error dialogs when using tags - Diagnostics include: - API URL being called - Tags and collection being filtered - HTTP response codes - Number of items received and filtered - Helpful suggestions for why tags might not match - Added ability to copy diagnostics to clipboard for bug reporting - Improved empty results handling with specific guidance for tag issues - Construct approximate API URL for diagnostic display Users can now clearly see error messages and understand why tag filtering might not be working (case sensitivity, non-existent tags, etc.) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../oyvindbs/zotshelf/CollectionFragment.java | 146 +++++++++++++++++- .../oyvindbs/zotshelf/DiagnosticInfo.java | 131 ++++++++++++++++ 2 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/oyvindbs/zotshelf/DiagnosticInfo.java diff --git a/app/src/main/java/oyvindbs/zotshelf/CollectionFragment.java b/app/src/main/java/oyvindbs/zotshelf/CollectionFragment.java index 951c871..115db4c 100644 --- a/app/src/main/java/oyvindbs/zotshelf/CollectionFragment.java +++ b/app/src/main/java/oyvindbs/zotshelf/CollectionFragment.java @@ -1,5 +1,8 @@ package oyvindbs.zotshelf; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Bundle; @@ -13,6 +16,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -45,6 +49,7 @@ public class CollectionFragment extends Fragment implements CoverGridAdapter.Cov private UserPreferences userPreferences; private EpubCoverRepository coverRepository; private boolean isOfflineMode = false; + private DiagnosticInfo lastDiagnosticInfo; public static CollectionFragment newInstance(String collectionKey, String collectionName) { return newInstance(collectionKey, collectionName, null); @@ -191,6 +196,9 @@ private void loadCoversFromApi() { String userId = userPreferences.getZoteroUserId(); String apiKey = userPreferences.getZoteroApiKey(); + // Prepare diagnostic info + prepareDiagnosticInfo(); + Log.d("CollectionFragment", "Loading covers from API - Collection: " + collectionKey + ", Tags: " + tags); zoteroApiClient.getAllEbookItemsWithMetadata(userId, apiKey, collectionKey, tags, @@ -198,6 +206,13 @@ private void loadCoversFromApi() { @Override public void onSuccess(List zoteroItems) { Log.d("CollectionFragment", "Received " + zoteroItems.size() + " items from API"); + + // Update diagnostic info + if (lastDiagnosticInfo != null) { + lastDiagnosticInfo.setItemsReceived(zoteroItems.size()); + lastDiagnosticInfo.setHttpResponseCode(200); + } + processZoteroItems(zoteroItems); } @@ -206,16 +221,42 @@ public void onError(String errorMessage) { Log.e("CollectionFragment", "Error loading covers: " + errorMessage); if (getActivity() == null) return; + // Update diagnostic info with error + if (lastDiagnosticInfo != null) { + lastDiagnosticInfo.setErrorMessage(errorMessage); + // Try to extract HTTP code from error message + if (errorMessage.contains("HTTP ")) { + try { + int codeStart = errorMessage.indexOf("HTTP ") + 5; + int codeEnd = errorMessage.indexOf(" ", codeStart); + if (codeEnd == -1) codeEnd = errorMessage.length(); + String codeStr = errorMessage.substring(codeStart, codeEnd); + lastDiagnosticInfo.setHttpResponseCode(Integer.parseInt(codeStr)); + } catch (Exception e) { + // Couldn't parse code, that's fine + } + } + } + getActivity().runOnUiThread(() -> { coverRepository.hasCachedCovers(hasCovers -> { if (hasCovers) { loadCachedCovers(); - Toast.makeText(requireContext(), + // Show error dialog if tags are being used + if (tags != null && !tags.isEmpty()) { + showErrorDialog("Tag Filter Error", + "Failed to load items with tag filter.\n\n" + errorMessage + + "\n\nShowing cached data instead.\n\nClick 'Show Diagnostics' for more details."); + } else { + Toast.makeText(requireContext(), "Failed to update from Zotero: " + errorMessage, Toast.LENGTH_LONG).show(); + } } else { - showEmptyState("Error: " + errorMessage); + progressBar.setVisibility(View.GONE); swipeRefreshLayout.setRefreshing(false); + // Always show error dialog for better visibility + showErrorDialog("Error Loading Items", errorMessage); } }); }); @@ -322,9 +363,32 @@ private void processZoteroItems(List zoteroItems) { if (zoteroItems.isEmpty()) { if (getActivity() == null) return; + // Update diagnostic info + if (lastDiagnosticInfo != null) { + lastDiagnosticInfo.setItemsFiltered(0); + } + getActivity().runOnUiThread(() -> { - showEmptyState("No EPUB or PDF files found matching your filters"); + progressBar.setVisibility(View.GONE); swipeRefreshLayout.setRefreshing(false); + + // Show diagnostic dialog if using tags, otherwise show simple empty state + if (tags != null && !tags.isEmpty()) { + StringBuilder message = new StringBuilder(); + message.append("No items found matching your filters.\n\n"); + message.append("This could mean:\n"); + message.append("• The tags don't exist in your library\n"); + message.append("• Tag names are case-sensitive (check capitalization)\n"); + message.append("• No items have ALL the specified tags\n"); + if (collectionKey != null && !collectionKey.isEmpty()) { + message.append("• The collection doesn't have items with these tags\n"); + } + message.append("\nTags entered: ").append(tags); + + showErrorDialog("No Items Found", message.toString()); + } else { + showEmptyState("No EPUB or PDF files found matching your filters"); + } }); return; } @@ -526,4 +590,80 @@ public void applySorting() { } }); } + + /** + * Show an error dialog with diagnostic information + */ + private void showErrorDialog(String title, String message) { + if (getActivity() == null) return; + + AlertDialog.Builder builder = new AlertDialog.Builder(requireContext()); + builder.setTitle(title); + builder.setMessage(message); + builder.setPositiveButton("OK", null); + + // Add diagnostics button if we have diagnostic info + if (lastDiagnosticInfo != null && (tags != null && !tags.isEmpty())) { + builder.setNeutralButton("Show Diagnostics", (dialog, which) -> { + showDiagnosticsDialog(); + }); + } + + builder.show(); + } + + /** + * Show detailed diagnostics in a dialog + */ + private void showDiagnosticsDialog() { + if (getActivity() == null || lastDiagnosticInfo == null) return; + + String diagnosticReport = lastDiagnosticInfo.generateReport(); + + AlertDialog.Builder builder = new AlertDialog.Builder(requireContext()); + builder.setTitle("Diagnostic Information"); + builder.setMessage(diagnosticReport); + builder.setPositiveButton("Close", null); + builder.setNegativeButton("Copy to Clipboard", (dialog, which) -> { + ClipboardManager clipboard = (ClipboardManager) requireContext().getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = ClipData.newPlainText("ZotShelf Diagnostics", diagnosticReport); + clipboard.setPrimaryClip(clip); + Toast.makeText(requireContext(), "Diagnostics copied to clipboard", Toast.LENGTH_SHORT).show(); + }); + + builder.show(); + } + + /** + * Create and populate diagnostic info for the current API call + */ + private void prepareDiagnosticInfo() { + lastDiagnosticInfo = new DiagnosticInfo(); + lastDiagnosticInfo.setTags(tags); + lastDiagnosticInfo.setCollectionKey(collectionKey); + lastDiagnosticInfo.setCollectionName(collectionName); + + // Construct approximate API URL for diagnostics + String userId = userPreferences.getZoteroUserId(); + StringBuilder urlBuilder = new StringBuilder("https://api.zotero.org/users/"); + urlBuilder.append(userId); + + if (collectionKey != null && !collectionKey.isEmpty()) { + urlBuilder.append("/collections/").append(collectionKey); + } + + urlBuilder.append("/items?format=json&itemType=attachment"); + + if (tags != null && !tags.isEmpty()) { + String[] tagArray = tags.split(";"); + for (String tag : tagArray) { + String trimmed = tag.trim(); + if (!trimmed.isEmpty()) { + urlBuilder.append("&tag=").append(trimmed.replace(" ", "%20")); + } + } + } + + lastDiagnosticInfo.setApiUrl(urlBuilder.toString()); + } } diff --git a/app/src/main/java/oyvindbs/zotshelf/DiagnosticInfo.java b/app/src/main/java/oyvindbs/zotshelf/DiagnosticInfo.java new file mode 100644 index 0000000..15601b1 --- /dev/null +++ b/app/src/main/java/oyvindbs/zotshelf/DiagnosticInfo.java @@ -0,0 +1,131 @@ +package oyvindbs.zotshelf; + +/** + * Holds diagnostic information about API calls for debugging purposes + */ +public class DiagnosticInfo { + private String apiUrl; + private String tags; + private String collectionKey; + private String collectionName; + private int httpResponseCode; + private String errorMessage; + private int itemsReceived; + private int itemsFiltered; + + public DiagnosticInfo() { + } + + public String getApiUrl() { + return apiUrl; + } + + public void setApiUrl(String apiUrl) { + this.apiUrl = apiUrl; + } + + public String getTags() { + return tags; + } + + public void setTags(String tags) { + this.tags = tags; + } + + public String getCollectionKey() { + return collectionKey; + } + + public void setCollectionKey(String collectionKey) { + this.collectionKey = collectionKey; + } + + public String getCollectionName() { + return collectionName; + } + + public void setCollectionName(String collectionName) { + this.collectionName = collectionName; + } + + public int getHttpResponseCode() { + return httpResponseCode; + } + + public void setHttpResponseCode(int httpResponseCode) { + this.httpResponseCode = httpResponseCode; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public int getItemsReceived() { + return itemsReceived; + } + + public void setItemsReceived(int itemsReceived) { + this.itemsReceived = itemsReceived; + } + + public int getItemsFiltered() { + return itemsFiltered; + } + + public void setItemsFiltered(int itemsFiltered) { + this.itemsFiltered = itemsFiltered; + } + + /** + * Generate a formatted diagnostic report for display or copying + */ + public String generateReport() { + StringBuilder report = new StringBuilder(); + report.append("=== ZotShelf Tag Diagnostics ===\n\n"); + + if (collectionKey != null && !collectionKey.isEmpty()) { + report.append("Collection: ").append(collectionName).append("\n"); + report.append("Collection Key: ").append(collectionKey).append("\n"); + } else { + report.append("Collection: All Collections\n"); + } + + if (tags != null && !tags.isEmpty()) { + report.append("Tags Filter: ").append(tags).append("\n"); + } else { + report.append("Tags Filter: None\n"); + } + + report.append("\nAPI Request:\n"); + if (apiUrl != null) { + report.append(apiUrl).append("\n"); + } else { + report.append("(URL not captured)\n"); + } + + report.append("\nResponse:\n"); + if (httpResponseCode > 0) { + report.append("HTTP Status: ").append(httpResponseCode).append("\n"); + } + + if (itemsReceived >= 0) { + report.append("Items received from API: ").append(itemsReceived).append("\n"); + } + + if (itemsFiltered >= 0) { + report.append("Items after filtering: ").append(itemsFiltered).append("\n"); + } + + if (errorMessage != null && !errorMessage.isEmpty()) { + report.append("\nError Message:\n").append(errorMessage).append("\n"); + } + + report.append("\n=== End Diagnostics ==="); + + return report.toString(); + } +}