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..115db4c --- /dev/null +++ b/app/src/main/java/oyvindbs/zotshelf/CollectionFragment.java @@ -0,0 +1,669 @@ +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; +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.appcompat.app.AlertDialog; +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 static final String ARG_TAGS = "tags"; + + private String collectionKey; + private String collectionName; + private String tags; + + 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; + private DiagnosticInfo lastDiagnosticInfo; + + 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; + } + + @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); + tags = getArguments().getString(ARG_TAGS); + } + + 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(); + + // Prepare diagnostic info + prepareDiagnosticInfo(); + + Log.d("CollectionFragment", "Loading covers from API - Collection: " + collectionKey + ", Tags: " + tags); + + zoteroApiClient.getAllEbookItemsWithMetadata(userId, apiKey, collectionKey, tags, + new ZoteroApiClient.ZoteroCallback>() { + @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); + } + + @Override + 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(); + // 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 { + progressBar.setVisibility(View.GONE); + swipeRefreshLayout.setRefreshing(false); + // Always show error dialog for better visibility + showErrorDialog("Error Loading Items", errorMessage); + } + }); + }); + } + }); + } + + private void loadCoversFromApiInBackground() { + String userId = userPreferences.getZoteroUserId(); + String apiKey = userPreferences.getZoteroApiKey(); + + zoteroApiClient.getAllEbookItemsWithMetadata(userId, apiKey, collectionKey, tags, + 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; + + // Update diagnostic info + if (lastDiagnosticInfo != null) { + lastDiagnosticInfo.setItemsFiltered(0); + } + + getActivity().runOnUiThread(() -> { + 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; + } + + 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); + + // 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); + + 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); + } + } + + 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); + } + } + }); + } + + /** + * 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/CollectionTabAdapter.java b/app/src/main/java/oyvindbs/zotshelf/CollectionTabAdapter.java new file mode 100644 index 0000000..f61e5f5 --- /dev/null +++ b/app/src/main/java/oyvindbs/zotshelf/CollectionTabAdapter.java @@ -0,0 +1,52 @@ +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(), + tab.getTags() + ); + } + + @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/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(); + } +} diff --git a/app/src/main/java/oyvindbs/zotshelf/MainActivity.java b/app/src/main/java/oyvindbs/zotshelf/MainActivity.java index 8503b2b..ace426a 100644 --- a/app/src/main/java/oyvindbs/zotshelf/MainActivity.java +++ b/app/src/main/java/oyvindbs/zotshelf/MainActivity.java @@ -1,717 +1,573 @@ 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 static final int REQUEST_CODE_SELECT_COLLECTION_WITH_TAGS = 1002; + private String pendingTags = null; -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 boolean isFirstResume = true; -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); - @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); - } - }); + // 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)); } - }); -} -private void refreshCovers() { - if (!NetworkUtils.isNetworkAvailable(this)) { - Toast.makeText(this, "No internet connection. Showing cached data.", Toast.LENGTH_LONG).show(); - swipeRefreshLayout.setRefreshing(false); - return; + // Handle widget click intent + if (getIntent().hasExtra("fromWidget")) { + handleWidgetClick(getIntent()); + } } - - loadCoversFromApi(); -} -private void loadCoversFromApi() { - String userId = userPreferences.getZoteroUserId(); - String apiKey = userPreferences.getZoteroApiKey(); - String collectionKey = userPreferences.getSelectedCollectionKey(); + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + if (intent.hasExtra("fromWidget")) { + handleWidgetClick(intent); + } + } - Log.d("MainActivity", "Loading covers from API - Collection: " + collectionKey); + private void handleWidgetClick(Intent intent) { + if (intent.hasExtra("itemId") && intent.hasExtra("username")) { + String itemId = intent.getStringExtra("itemId"); + String username = intent.getStringExtra("username"); - // 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); + // 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); } + } - @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); - } - }); - }); - } - }); -} + private void setupTabs() { + List tabs = tabStateManager.getOpenTabs(); -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(); - }); - } + tabAdapter = new CollectionTabAdapter(this, tabs); + viewPager.setAdapter(tabAdapter); - @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 - }); + // Connect TabLayout with ViewPager2 + if (tabLayoutMediator != null) { + tabLayoutMediator.detach(); } - }); -} -private void processZoteroItemsForCache(List zoteroItems) { - if (zoteroItems.isEmpty()) { - return; - } + tabLayoutMediator = new TabLayoutMediator(tabLayout, viewPager, + (tab, position) -> { + TabStateManager.TabInfo tabInfo = tabAdapter.getTabAt(position); + if (tabInfo != null) { + tab.setText(tabInfo.getDisplayName()); + + // 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(); + + // Restore last selected tab + int currentTab = tabStateManager.getCurrentTabIndex(); + if (currentTab < tabs.size()) { + viewPager.setCurrentItem(currentTab, false); + } - // Process items and save to cache without necessarily updating UI - for (ZoteroItem item : zoteroItems) { - zoteroApiClient.downloadEbook(item, new ZoteroApiClient.FileCallback() { + // Save current tab when user switches + viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { @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); - } + public void onPageSelected(int position) { + super.onPageSelected(position); + tabStateManager.setCurrentTabIndex(position); + } + }); + } - @Override - public void onError(String errorMessage) { - // Save without cover - coverRepository.saveCoverFromZoteroItem(item, null); - } - }); + 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(ZoteroItem item, String errorMessage) { - // Save item metadata even if download failed - coverRepository.saveCoverFromZoteroItem(item, null); + + if (!userPreferences.hasZoteroCredentials()) { + Toast.makeText(this, R.string.enter_credentials, Toast.LENGTH_SHORT).show(); + return; } + + // Show dialog to choose tab type + showAddTabDialog(); }); } -} -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 showAddTabDialog() { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("Add New Tab"); - @Override - public void onError(String message) { - runOnUiThread(() -> { - showEmptyState("Error loading cached covers: " + message); - swipeRefreshLayout.setRefreshing(false); - }); - } - }); -} + String[] options = {"By Collection", "By Tags", "By Collection + Tags"}; -private void processZoteroItems(List zoteroItems) { - if (zoteroItems.isEmpty()) { - runOnUiThread(() -> { - showEmptyState("No EPUB or PDF files found matching your filters"); - swipeRefreshLayout.setRefreshing(false); + builder.setItems(options, (dialog, which) -> { + if (which == 0) { + // By Collection only + 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 if (which == 1) { + // By Tags only + showTagInputDialog(false); + } else { + // By Collection + Tags + showTagInputDialog(true); + } }); - return; + + builder.setNegativeButton("Cancel", null); + builder.show(); } - 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 showTagInputDialog(boolean withCollection) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(withCollection ? "Filter by Tags (then select collection)" : "Filter by Tags"); - @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); - } - } - } - }); + // 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(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; } - - @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); - } + + 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); + + Toast.makeText(this, "Created tag filter: " + tags, Toast.LENGTH_SHORT).show(); } }); + + builder.setNegativeButton("Cancel", null); + builder.show(); } -} + private void showCloseTabDialog(int position) { + TabStateManager.TabInfo tabInfo = tabAdapter.getTabAt(position); + if (tabInfo == null) return; -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(); - } - - progressBar.setVisibility(View.GONE); - swipeRefreshLayout.setRefreshing(false); - }); -} + new AlertDialog.Builder(this) + .setTitle("Close Tab") + .setMessage("Close tab '" + tabInfo.getDisplayName() + "'?") + .setPositiveButton("Close", (dialog, which) -> closeTab(position)) + .setNegativeButton("Cancel", null) + .show(); + } -private void showLoading() { - progressBar.setVisibility(View.VISIBLE); - emptyView.setVisibility(View.GONE); -} + private void closeTab(int position) { + tabStateManager.removeTab(position); + refreshTabs(); + } -private void showEmptyState(String message) { - progressBar.setVisibility(View.GONE); - emptyView.setVisibility(View.VISIBLE); - emptyView.setText(message); -} + private void refreshTabs() { + List tabs = tabStateManager.getOpenTabs(); + tabAdapter.updateTabs(tabs); -private void hideEmptyState() { - emptyView.setVisibility(View.GONE); -} + // Reattach mediator + if (tabLayoutMediator != null) { + tabLayoutMediator.detach(); + } + + tabLayoutMediator = new TabLayoutMediator(tabLayout, viewPager, + (tab, position) -> { + TabStateManager.TabInfo tabInfo = tabAdapter.getTabAt(position); + if (tabInfo != null) { + tab.setText(tabInfo.getDisplayName()); + + if (tabs.size() > 1) { + tab.view.setOnLongClickListener(v -> { + showCloseTabDialog(position); + return true; + }); + } + } + } + ); + tabLayoutMediator.attach(); -@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()); + // Make sure we're on a valid tab + int currentTab = viewPager.getCurrentItem(); + if (currentTab >= tabs.size()) { + viewPager.setCurrentItem(tabs.size() - 1, true); + } } - 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 true; } - - 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()); + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + 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); } - if (pdfToggle != null) { - pdfToggle.setChecked(userPreferences.getShowPdfs()); + + @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; + + case R.id.action_sort: + showSortDialog(); + return true; + + case R.id.action_info: + showInfoDialog(); + return true; + + case R.id.action_select_collection: + fabAddTab.performClick(); + 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); + } } - - 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 { - refreshCovers(); + private void refreshCurrentTab() { + 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(); } - 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); + }); } -} -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 + 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); } -} -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(); -} + private void toggleEpubsEnabled(MenuItem item) { + boolean currentState = userPreferences.getShowEpubs(); + boolean newState = !currentState; -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 + 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); + + Toast.makeText(this, newState ? "EPUBs enabled" : "EPUBs disabled", + Toast.LENGTH_SHORT).show(); + + // Refresh all tabs + refreshAllTabs(); } -} -@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; + 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 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 refreshAllTabs() { + // Recreate the adapter to force all fragments to reload + setupTabs(); } - - if (needsReload) { - loadCovers(); + + 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(); } - - // 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"; + @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(); + } 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; + } } - getSupportActionBar().setSubtitle(collectionName + - (isOfflineMode ? " (Offline)" : "")); } -} -@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; - } - - if (!NetworkUtils.isNetworkAvailable(this)) { - Toast.makeText(this, "Cannot open reader - no internet connection", Toast.LENGTH_SHORT).show(); - return; + @Override + 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 + 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(); + return; + } + + // Refresh current tab to pick up any changes from settings + refreshCurrentTab(); } - - 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 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(); } - - if (!NetworkUtils.isNetworkAvailable(this)) { - Toast.makeText(this, "No internet connection. Cannot fetch collections.", Toast.LENGTH_LONG).show(); - return; + + private void applySortingToCurrentTab() { + viewPager.post(() -> { + CollectionFragment fragment = getCurrentFragment(); + if (fragment != null && fragment.isAdded()) { + fragment.applySorting(); + } + }); } - - // 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(); - } + 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(); + + // Update display mode in all visible fragments + updateDisplayModeInAllFragments(); + }); + + 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(); - - 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 updateDisplayModeInAllFragments() { + // Recreate adapter to update all fragments + setupTabs(); + } } - -} \ No newline at end of file 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..0c3c1da --- /dev/null +++ b/app/src/main/java/oyvindbs/zotshelf/TabStateManager.java @@ -0,0 +1,195 @@ +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; + 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() { + return collectionKey; + } + + 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() { + 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) { + 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) { + 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; + } + } + + // Check max tabs limit + if (tabs.size() >= MAX_TABS) { + return; // Don't add more tabs than the limit + } + + 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); + } + + 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/ZoteroApiClient.java b/app/src/main/java/oyvindbs/zotshelf/ZoteroApiClient.java index d3e36a3..302036f 100644 --- a/app/src/main/java/oyvindbs/zotshelf/ZoteroApiClient.java +++ b/app/src/main/java/oyvindbs/zotshelf/ZoteroApiClient.java @@ -108,6 +108,17 @@ Call> getItemsPaginated( @Query("limit") int limit ); + @GET("users/{userId}/items") + Call> getItemsPaginatedWithTags( + @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, + @Query("tag") List tags + ); + @GET("users/{userId}/collections/{collectionKey}/items") Call> getItemsByCollectionPaginated( @Path("userId") String userId, @@ -118,6 +129,18 @@ Call> getItemsByCollectionPaginated( @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, + @Query("format") String format, + @Query("itemType") String itemType, + @Query("start") int start, + @Query("limit") int limit, + @Query("tag") List tags + ); @GET @Streaming @@ -720,43 +743,72 @@ 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); + List tagList = parseTagsToList(tags); + + 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); + // 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); - + 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); } } 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); @@ -764,12 +816,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 +906,32 @@ 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); + } +} + + /** + * 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/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..e316e1e 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 @@ + + + + + + + + + + + + + + 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 diff --git a/gradlew b/gradlew old mode 100644 new mode 100755