diff --git a/app/src/main/java/de/schliweb/makeacopy/songbird/SongbirdPrefsHelper.java b/app/src/main/java/de/schliweb/makeacopy/songbird/SongbirdPrefsHelper.java index f12642b5..948c4896 100644 --- a/app/src/main/java/de/schliweb/makeacopy/songbird/SongbirdPrefsHelper.java +++ b/app/src/main/java/de/schliweb/makeacopy/songbird/SongbirdPrefsHelper.java @@ -28,6 +28,10 @@ public final class SongbirdPrefsHelper { private static final String KEY_BASE_URL = "songbird_base_url"; private static final String KEY_USERNAME = "songbird_username"; private static final String KEY_PASSWORD = "songbird_password"; + private static final String KEY_DEFAULT_TAGS = "songbird_default_tags"; + + /** Out-of-box default tag (preserves the documented {@code ["sermon"]} import default). */ + private static final String DEFAULT_TAGS_FALLBACK = "sermon"; private SongbirdPrefsHelper() {} @@ -64,6 +68,21 @@ public static void setPassword(Context ctx, String value) { if (p != null) p.edit().putString(KEY_PASSWORD, value == null ? "" : value).apply(); } + /** + * The operator's default tags (comma-separated) to prefill the edit screen. Defaults to {@code + * "sermon"} until the operator sets (or explicitly blanks) it in Settings. + */ + public static String getDefaultTags(Context ctx) { + SharedPreferences p = open(ctx); + return p == null ? DEFAULT_TAGS_FALLBACK : p.getString(KEY_DEFAULT_TAGS, DEFAULT_TAGS_FALLBACK); + } + + /** Persists the default tags (trimmed). An explicit blank is honored (empty tag box). */ + public static void setDefaultTags(Context ctx, String value) { + SharedPreferences p = open(ctx); + if (p != null) p.edit().putString(KEY_DEFAULT_TAGS, value == null ? "" : value.trim()).apply(); + } + public static boolean isConfigured(Context ctx) { return SongbirdSettings.canSend(getBaseUrl(ctx), getUsername(ctx), getPassword(ctx)); } diff --git a/app/src/main/java/de/schliweb/makeacopy/ui/edit/EditFragment.java b/app/src/main/java/de/schliweb/makeacopy/ui/edit/EditFragment.java index 6b7b904e..e361552a 100644 --- a/app/src/main/java/de/schliweb/makeacopy/ui/edit/EditFragment.java +++ b/app/src/main/java/de/schliweb/makeacopy/ui/edit/EditFragment.java @@ -19,10 +19,12 @@ import android.widget.ArrayAdapter; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.view.ViewCompat; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; import androidx.navigation.Navigation; import de.schliweb.makeacopy.R; +import de.schliweb.makeacopy.songbird.SongbirdPrefsHelper; import de.schliweb.makeacopy.anchor.BookNames; import de.schliweb.makeacopy.anchor.SpanResolution; import de.schliweb.makeacopy.anchor.VerseTable; @@ -75,7 +77,11 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat android.widget.Toast.LENGTH_LONG); } String combined = CombinedOcrTextProvider.fromPages(session.getPages().getValue()); - viewModel.initialize(combined, table, LocalDate.now(ZoneId.systemDefault()).toString()); + viewModel.initialize( + combined, + table, + LocalDate.now(ZoneId.systemDefault()).toString(), + SongbirdPrefsHelper.getDefaultTags(requireContext())); setupBookPicker(); bindInitialFields(); @@ -83,6 +89,14 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat wireDatePicker(); wireContinue(); + // Keep the Continue button clear of the system nav bar (edge-to-edge). + ViewCompat.setOnApplyWindowInsetsListener( + binding.buttonContinue, + (v, insets) -> { + UIUtils.adjustMarginForSystemInsets(binding.buttonContinue, 16); + return insets; + }); + viewModel.getState().observe(getViewLifecycleOwner(), this::renderDerived); } diff --git a/app/src/main/java/de/schliweb/makeacopy/ui/edit/EditViewModel.java b/app/src/main/java/de/schliweb/makeacopy/ui/edit/EditViewModel.java index d9ade682..8f6aecb5 100644 --- a/app/src/main/java/de/schliweb/makeacopy/ui/edit/EditViewModel.java +++ b/app/src/main/java/de/schliweb/makeacopy/ui/edit/EditViewModel.java @@ -78,15 +78,18 @@ public LiveData getState() { * @param combinedText the F2 combined OCR string * @param verseTable the bundled verse-count table (injected; loaded by the fragment) * @param todayIso today's date as {@code yyyy-MM-dd} + * @param defaultTags the operator's configured default tags (comma-separated) to prefill the tag + * box; passed verbatim (the {@code "sermon"} fallback lives in the prefs helper) */ - public void initialize(String combinedText, VerseTable verseTable, String todayIso) { + public void initialize( + String combinedText, VerseTable verseTable, String todayIso, String defaultTags) { if (initialized) return; initialized = true; this.table = verseTable; this.editedText = combinedText == null ? "" : combinedText; this.title = ""; this.dateIso = todayIso == null ? "" : todayIso; - this.tagsText = "sermon"; + this.tagsText = defaultTags == null ? "" : defaultTags; Optional found = AnchorFinder.find(this.editedText); if (found.isPresent()) { diff --git a/app/src/main/java/de/schliweb/makeacopy/ui/edit/SermonDraftViewModel.java b/app/src/main/java/de/schliweb/makeacopy/ui/edit/SermonDraftViewModel.java index 2a72f746..1593915f 100644 --- a/app/src/main/java/de/schliweb/makeacopy/ui/edit/SermonDraftViewModel.java +++ b/app/src/main/java/de/schliweb/makeacopy/ui/edit/SermonDraftViewModel.java @@ -30,4 +30,9 @@ public LiveData getDraft() { public void setDraft(SermonDraft d) { draft.setValue(d); } + + /** Clears the handed-off draft (e.g. after a successful send, before returning to start). */ + public void clear() { + draft.setValue(null); + } } diff --git a/app/src/main/java/de/schliweb/makeacopy/ui/export/ExportFragment.java b/app/src/main/java/de/schliweb/makeacopy/ui/export/ExportFragment.java index 2eb3a952..457357b8 100644 --- a/app/src/main/java/de/schliweb/makeacopy/ui/export/ExportFragment.java +++ b/app/src/main/java/de/schliweb/makeacopy/ui/export/ExportFragment.java @@ -995,6 +995,14 @@ public void handleOnBackPressed() { return insets; }); + // Keep the bottom-anchored Continue container clear of the system nav bar (edge-to-edge). + ViewCompat.setOnApplyWindowInsetsListener( + binding.exportOptionsGroup, + (v, insets) -> { + UIUtils.adjustMarginForSystemInsets(binding.exportOptionsGroup, 8); + return insets; + }); + exportViewModel .isDocumentReady() .observe( diff --git a/app/src/main/java/de/schliweb/makeacopy/ui/finalize/FinalizeFragment.java b/app/src/main/java/de/schliweb/makeacopy/ui/finalize/FinalizeFragment.java index 42adeb41..26b07416 100644 --- a/app/src/main/java/de/schliweb/makeacopy/ui/finalize/FinalizeFragment.java +++ b/app/src/main/java/de/schliweb/makeacopy/ui/finalize/FinalizeFragment.java @@ -21,6 +21,7 @@ import androidx.core.content.FileProvider; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.NavOptions; import androidx.navigation.Navigation; import de.schliweb.makeacopy.BuildConfig; import de.schliweb.makeacopy.R; @@ -30,7 +31,10 @@ import de.schliweb.makeacopy.songbird.ImportResult; import de.schliweb.makeacopy.songbird.ShareFilename; import de.schliweb.makeacopy.songbird.SongbirdPrefsHelper; +import de.schliweb.makeacopy.ui.camera.CameraViewModel; +import de.schliweb.makeacopy.ui.crop.CropViewModel; import de.schliweb.makeacopy.ui.edit.SermonDraftViewModel; +import de.schliweb.makeacopy.ui.export.session.ExportSessionViewModel; import de.schliweb.makeacopy.ui.finalize.FinalizeViewModel.Phase; import de.schliweb.makeacopy.utils.ui.UIUtils; import java.io.File; @@ -45,10 +49,14 @@ @dagger.hilt.android.AndroidEntryPoint public class FinalizeFragment extends Fragment { + /** Brief pause so the operator sees the created/skipped result before the screen returns to start. */ + private static final long AUTO_RETURN_DELAY_MS = 1500L; + private FragmentFinalizeBinding binding; private FinalizeViewModel viewModel; private SermonDraft draft; private String json; + private boolean returning; // one-shot guard: a successful send auto-returns exactly once @Override public View onCreateView( @@ -138,10 +146,47 @@ private void renderState(FinalizeViewModel.SendUiState state) { binding.resultText.setText(R.string.finalize_sending); } else if (state.phase() == Phase.DONE && state.result() != null) { binding.resultText.setText(describe(state.result())); + maybeAutoReturn(state.result()); } updateSendGate(); } + /** + * A clean import (SUCCESS, no rejected entries) ends the workflow: briefly show the result, then + * clear the session and return to the camera start screen so the next handout starts fresh. This + * also prevents re-tapping Send on a note that already imported. Failures stay put so the operator + * can read the error and retry. + */ + private void maybeAutoReturn(ImportResult r) { + if (returning || r == null || !r.isSuccess() || r.failed() > 0) return; + returning = true; + binding.buttonSend.setEnabled(false); + binding.getRoot().postDelayed(this::returnToStart, AUTO_RETURN_DELAY_MS); + } + + /** Mirrors the page hub's "start over" reset (ExportFragment): wipe session + capture state. */ + private void returnToStart() { + if (binding == null || !isAdded()) return; + ViewModelProvider activity = new ViewModelProvider(requireActivity()); + activity.get(ExportSessionViewModel.class).setInitial(null); + activity.get(SermonDraftViewModel.class).clear(); + CameraViewModel cameraViewModel = activity.get(CameraViewModel.class); + CropViewModel cropViewModel = activity.get(CropViewModel.class); + cameraViewModel.setImageUri(null); + cropViewModel.setImageCropped(false); + cropViewModel.setImageBitmap(null); + cropViewModel.setOriginalImageBitmap(null); + cropViewModel.setImageLoaded(false); + NavOptions navOptions = + new NavOptions.Builder().setPopUpTo(R.id.navigation_camera, true).build(); + try { + Navigation.findNavController(requireView()) + .navigate(R.id.navigation_camera, null, navOptions); + } catch (IllegalArgumentException | IllegalStateException ignored) { + // destination unavailable — no-op + } + } + private String describe(ImportResult r) { switch (r.status()) { case SUCCESS: diff --git a/app/src/main/java/de/schliweb/makeacopy/ui/settings/SettingsFragment.java b/app/src/main/java/de/schliweb/makeacopy/ui/settings/SettingsFragment.java index c9c66a0b..f02e147d 100644 --- a/app/src/main/java/de/schliweb/makeacopy/ui/settings/SettingsFragment.java +++ b/app/src/main/java/de/schliweb/makeacopy/ui/settings/SettingsFragment.java @@ -16,6 +16,7 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.view.ViewCompat; import androidx.fragment.app.Fragment; import androidx.navigation.Navigation; import de.schliweb.makeacopy.R; @@ -47,7 +48,16 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat binding.baseUrlField.setText(SongbirdPrefsHelper.getBaseUrl(requireContext())); binding.usernameField.setText(SongbirdPrefsHelper.getUsername(requireContext())); binding.passwordField.setText(SongbirdPrefsHelper.getPassword(requireContext())); + binding.defaultTagsField.setText(SongbirdPrefsHelper.getDefaultTags(requireContext())); binding.buttonSaveSettings.setOnClickListener(v -> save()); + + // Keep the Save button clear of the system nav bar (edge-to-edge). + ViewCompat.setOnApplyWindowInsetsListener( + binding.buttonSaveSettings, + (v, insets) -> { + UIUtils.adjustMarginForSystemInsets(binding.buttonSaveSettings, 16); + return insets; + }); } private void save() { @@ -57,9 +67,14 @@ private void save() { binding.usernameField.getText() == null ? "" : binding.usernameField.getText().toString(); String password = binding.passwordField.getText() == null ? "" : binding.passwordField.getText().toString(); + String defaultTags = + binding.defaultTagsField.getText() == null + ? "" + : binding.defaultTagsField.getText().toString(); SongbirdPrefsHelper.setBaseUrl(requireContext(), baseUrl); // normalized inside SongbirdPrefsHelper.setUsername(requireContext(), username); SongbirdPrefsHelper.setPassword(requireContext(), password); + SongbirdPrefsHelper.setDefaultTags(requireContext(), defaultTags); UIUtils.showToast(requireContext(), getString(R.string.settings_saved), Toast.LENGTH_SHORT); try { Navigation.findNavController(requireView()).popBackStack(); diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml index 7012bf72..09975a9c 100644 --- a/app/src/main/res/layout/fragment_settings.xml +++ b/app/src/main/res/layout/fragment_settings.xml @@ -68,6 +68,22 @@ android:importantForAutofill="no" /> + + + + + Base URL (e.g. http://host:8000) Username Password + Default tags (comma-separated) Settings saved diff --git a/app/src/test/java/de/schliweb/makeacopy/ui/edit/EditViewModelTest.java b/app/src/test/java/de/schliweb/makeacopy/ui/edit/EditViewModelTest.java index 919136cf..15ac55ae 100644 --- a/app/src/test/java/de/schliweb/makeacopy/ui/edit/EditViewModelTest.java +++ b/app/src/test/java/de/schliweb/makeacopy/ui/edit/EditViewModelTest.java @@ -78,7 +78,7 @@ private EditUiState s() { @Test public void initialize_prefillsTextAndAnchorFromFinder() { - vm.initialize("The army gathered. (1Sam. 22:1)", table, "2026-06-10"); + vm.initialize("The army gathered. (1Sam. 22:1)", table, "2026-06-10", "sermon"); EditUiState st = s(); assertEquals("The army gathered. (1Sam. 22:1)", st.editedText()); assertEquals("1SA", st.bookUsfm()); @@ -92,9 +92,25 @@ public void initialize_prefillsTextAndAnchorFromFinder() { assertEquals(SpanResolution.Status.RESOLVED, st.anchorStatus()); } + @Test + public void initialize_prefillsConfiguredDefaultTags() { + vm.initialize("", table, "2026-06-10", "sermon, majestic view"); + EditUiState st = s(); + assertEquals("sermon, majestic view", st.tagsText()); + assertEquals(Arrays.asList("sermon", "majestic view"), st.tags()); + } + + @Test + public void initialize_blankDefaultTags_yieldsNoTags() { + vm.initialize("", table, "2026-06-10", ""); + EditUiState st = s(); + assertEquals("", st.tagsText()); + assertTrue(st.tags().isEmpty()); + } + @Test public void initialize_noAnchor_leavesFieldsEmptyAndBlocks() { - vm.initialize("No scripture references here at all.", table, "2026-06-10"); + vm.initialize("No scripture references here at all.", table, "2026-06-10", "sermon"); EditUiState st = s(); assertEquals("", st.bookUsfm()); assertEquals("", st.chapterText()); @@ -104,9 +120,9 @@ public void initialize_noAnchor_leavesFieldsEmptyAndBlocks() { @Test public void initialize_isGuarded_secondCallDoesNotWipeEdits() { - vm.initialize("(1Sam. 22:1)", table, "2026-06-10"); + vm.initialize("(1Sam. 22:1)", table, "2026-06-10", "sermon"); vm.setChapter("5"); - vm.initialize("Totally different text", table, "2026-06-11"); + vm.initialize("Totally different text", table, "2026-06-11", "sermon"); EditUiState st = s(); assertEquals("5", st.chapterText()); assertEquals("1SA", st.bookUsfm()); @@ -114,7 +130,7 @@ public void initialize_isGuarded_secondCallDoesNotWipeEdits() { @Test public void chapterOnly_resolvesWholeChapter() { - vm.initialize("", table, "2026-06-10"); + vm.initialize("", table, "2026-06-10", "sermon"); vm.setBookUsfm("PSA"); vm.setChapter("23"); EditUiState st = s(); @@ -126,7 +142,7 @@ public void chapterOnly_resolvesWholeChapter() { @Test public void chapterOutOfRange_blocks() { - vm.initialize("", table, "2026-06-10"); + vm.initialize("", table, "2026-06-10", "sermon"); vm.setBookUsfm("MAT"); // sample MAT has 5 chapters vm.setChapter("9"); EditUiState st = s(); @@ -136,7 +152,7 @@ public void chapterOutOfRange_blocks() { @Test public void incompleteAnchor_blocks() { - vm.initialize("", table, "2026-06-10"); + vm.initialize("", table, "2026-06-10", "sermon"); vm.setBookUsfm("PSA"); vm.setChapter(""); // no chapter yet EditUiState st = s(); @@ -146,7 +162,7 @@ public void incompleteAnchor_blocks() { @Test public void reversedVerseRange_warnsButDoesNotBlock() { - vm.initialize("", table, "2026-06-10"); + vm.initialize("", table, "2026-06-10", "sermon"); vm.setBookUsfm("1SA"); vm.setChapter("25"); vm.setVerseFrom("6"); @@ -160,7 +176,7 @@ public void reversedVerseRange_warnsButDoesNotBlock() { @Test public void setText_doesNotMoveTheAnchor() { - vm.initialize("(1Sam. 22:1)", table, "2026-06-10"); + vm.initialize("(1Sam. 22:1)", table, "2026-06-10", "sermon"); vm.setText("Now it says John 1:1 instead"); EditUiState st = s(); assertEquals("1SA", st.bookUsfm()); @@ -170,7 +186,7 @@ public void setText_doesNotMoveTheAnchor() { @Test public void titleWarning_trueWhenBlank_falseWhenSet() { - vm.initialize("", table, "2026-06-10"); + vm.initialize("", table, "2026-06-10", "sermon"); assertTrue(s().titleWarning()); vm.setTitle("A Story about David & Abigail"); assertFalse(s().titleWarning()); @@ -178,7 +194,7 @@ public void titleWarning_trueWhenBlank_falseWhenSet() { @Test public void buildDraft_assemblesAllInputs() { - vm.initialize("edited body text", table, "2026-06-10"); + vm.initialize("edited body text", table, "2026-06-10", "sermon"); vm.setBookUsfm("1SA"); vm.setChapter("25"); vm.setVerseFrom("3"); @@ -198,7 +214,7 @@ public void buildDraft_assemblesAllInputs() { @Test public void buildDraft_nullWhenCannotProceed() { - vm.initialize("", table, "2026-06-10"); + vm.initialize("", table, "2026-06-10", "sermon"); assertNull(vm.buildDraft()); }