From 83abfc84830e40337faa86b664ce691d5b0b99fa Mon Sep 17 00:00:00 2001 From: Sultan Zhalifunnas Musyaffa Date: Sat, 9 May 2026 14:45:23 +0700 Subject: [PATCH 1/4] Rewrite to Kotlin + MVVM + Room with Material 3 redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full architectural overhaul of the app. Replaces the Java/SQLite prototype with a clean MVVM stack and a Material 3 user interface, plus adds onboarding, dashboard, PDF export, JSON backup/restore, theme toggle, and Google Books ISBN auto-fill. Architecture - Single-Activity host (MainActivity) + Navigation Component graph for Dashboard, Library, Detail, Add/Edit, Settings. Auth + Onboarding live in their own activities. - Repository pattern between ViewModels and Room. Reactive reads via Flow/StateFlow. - Hand-rolled ServiceLocator for DI; ViewModelFactory wires constructors. Data layer - Room database with UserEntity + BookEntity (cover URI, status, rating, progress, notes, dateAdded, lastModified). DAOs use suspend + Flow. - PreferencesManager: typed wrapper around SharedPreferences for session, theme, last-used sort/filter. - Salted SHA-256 password hashing replaces plaintext storage. UI - Material 3 theme tokens (light + dark) with brand palette and typed text styles. - Onboarding (3-slide ViewPager2 with dot indicators), Login/Register with inline validation, Dashboard with status breakdown + recent rail, Library with search / sort / filter (persisted) and swipe-to-delete + Snackbar UNDO, Detail with rating + reading progress + Share, Add/Edit form with cover picker and ISBN auto-fill, Settings with theme toggle + PDF export + JSON backup/restore + sign-out. - 25+ Material vector icons and gradient drawables. Features - Cover image picker via the modern Photo Picker (no permission on Android 13+); bytes copied to internal storage so they survive app restarts. - Reading status (Want / Reading / Finished) with progress tracking (current / total pages) for "Currently Reading" books. - 5-star rating with sortable ranking on the dashboard average. - Sort by title / date / rating; filter by reading status; preferences persisted. - PDF export via platform PdfDocument API (no third-party PDF lib). - JSON backup/restore via org.json, written through MediaStore on Q+, FileProvider on older devices. - Google Books ISBN auto-fill: pre-populates title, author, year, category, and page count from a 10/13-digit ISBN. Network done with HttpURLConnection only β€” no new dependencies. User-typed input is preserved (never overwritten). - Theme toggle: System / Light / Dark. AppCompatDelegate-driven. - Splash screen with brand gradient (no white flash on cold launch). Tooling - Kotlin 2.0.21, AGP 8.11, Gradle 8.13, JDK 17. compileSdk=35, minSdk=21, targetSdk=34. - ViewBinding, KSP, Navigation Safe Args, Coil 2.7. - Coroutines + Flow. lifecycle-runtime-ktx for repeatOnLifecycle. Testing - 8 unit-test classes covering PasswordHasher, enum storage values, both repos, all ViewModels (auth/list/edit), and the Google Books parser. JUnit + MockK + Turbine + kotlinx-coroutines-test. Project hygiene - LICENSE (MIT) added. - GitHub Actions CI: build + unit tests + debug APK artifact on every push/PR. - screenshots/ directory with capture instructions for the README placeholders. - .gitignore extended to exclude Claude session metadata and keystore files. - README rewritten with architecture diagram, full feature list, tech stack table, module map, test inventory, screenshots placeholders, and roadmap. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/android.yml | 60 +++ .gitignore | 9 + LICENSE | 21 + README.md | 273 +++++++++++ app/build.gradle.kts | 103 +++- app/proguard-rules.pro | 52 ++- .../ExampleInstrumentedTest.java | 26 -- app/src/main/AndroidManifest.xml | 59 ++- .../minibooklibrary/AddBookActivity.java | 206 -------- .../com/example/minibooklibrary/Book.java | 113 ----- .../example/minibooklibrary/BookAdapter.java | 77 --- .../minibooklibrary/DatabaseHelper.java | 301 ------------ .../minibooklibrary/LoginActivity.java | 98 ---- .../example/minibooklibrary/MainActivity.java | 405 ---------------- .../minibooklibrary/MiniBookLibraryApp.kt | 22 + .../minibooklibrary/RegisterActivity.java | 104 ----- .../minibooklibrary/SettingsActivity.java | 44 -- .../minibooklibrary/StatisticsActivity.java | 77 --- .../data/local/BookDatabase.kt | 54 +++ .../minibooklibrary/data/local/dao/BookDao.kt | 50 ++ .../minibooklibrary/data/local/dao/UserDao.kt | 23 + .../data/local/entity/BookEntity.kt | 46 ++ .../data/local/entity/UserEntity.kt | 26 ++ .../data/preferences/PreferencesManager.kt | 83 ++++ .../data/remote/GoogleBooksService.kt | 122 +++++ .../data/repository/AuthResult.kt | 17 + .../data/repository/BookRepository.kt | 67 +++ .../data/repository/UserRepository.kt | 107 +++++ .../minibooklibrary/di/ServiceLocator.kt | 78 ++++ .../minibooklibrary/domain/BookLookup.kt | 14 + .../minibooklibrary/domain/ReadingStatus.kt | 19 + .../minibooklibrary/domain/SortOrder.kt | 16 + .../minibooklibrary/domain/StatusFilter.kt | 18 + .../minibooklibrary/ui/RouterActivity.kt | 32 ++ .../minibooklibrary/ui/auth/AuthActivity.kt | 43 ++ .../minibooklibrary/ui/auth/AuthViewModel.kt | 91 ++++ .../minibooklibrary/ui/auth/LoginFragment.kt | 79 ++++ .../ui/auth/RegisterFragment.kt | 92 ++++ .../ui/books/detail/BookDetailFragment.kt | 165 +++++++ .../ui/books/detail/BookDetailViewModel.kt | 58 +++ .../ui/books/edit/AddEditBookFragment.kt | 273 +++++++++++ .../ui/books/edit/AddEditBookViewModel.kt | 202 ++++++++ .../ui/books/list/BookAdapter.kt | 128 +++++ .../ui/books/list/BookListFragment.kt | 178 +++++++ .../ui/books/list/BookListViewModel.kt | 142 ++++++ .../ui/common/ViewModelFactory.kt | 43 ++ .../ui/dashboard/DashboardFragment.kt | 102 ++++ .../ui/dashboard/DashboardViewModel.kt | 98 ++++ .../minibooklibrary/ui/main/MainActivity.kt | 57 +++ .../ui/onboarding/OnboardingActivity.kt | 97 ++++ .../ui/onboarding/OnboardingPage.kt | 11 + .../ui/onboarding/OnboardingPagerAdapter.kt | 40 ++ .../ui/settings/SettingsFragment.kt | 158 +++++++ .../ui/settings/SettingsViewModel.kt | 118 +++++ .../minibooklibrary/util/BackupManager.kt | 126 +++++ .../minibooklibrary/util/Extensions.kt | 42 ++ .../minibooklibrary/util/ImageStorage.kt | 50 ++ .../minibooklibrary/util/PasswordHasher.kt | 48 ++ .../minibooklibrary/util/PdfExporter.kt | 150 ++++++ .../main/res/color/bottom_nav_item_color.xml | 5 + app/src/main/res/drawable/accent_bar.xml | 5 - ...{dot_indicator.xml => bg_circle_glass.xml} | 2 +- .../res/drawable/bg_cover_placeholder.xml | 10 + .../res/drawable/bg_dot_indicator_active.xml | 7 + .../drawable/bg_dot_indicator_inactive.xml | 6 + .../main/res/drawable/bg_gradient_header.xml | 13 + .../main/res/drawable/bg_progress_track.xml | 6 + app/src/main/res/drawable/bg_splash.xml | 10 + app/src/main/res/drawable/bg_status_chip.xml | 6 + .../main/res/drawable/book_icon_gradient.xml | 9 - app/src/main/res/drawable/card_background.xml | 8 - app/src/main/res/drawable/fab_gradient.xml | 9 - app/src/main/res/drawable/gradient_circle.xml | 9 - app/src/main/res/drawable/gradient_header.xml | 9 - .../res/drawable/gradient_header_rounded.xml | 12 - app/src/main/res/drawable/ic_add.xml | 8 +- app/src/main/res/drawable/ic_back.xml | 10 + app/src/main/res/drawable/ic_backup.xml | 10 + app/src/main/res/drawable/ic_book.xml | 8 +- app/src/main/res/drawable/ic_camera.xml | 10 + app/src/main/res/drawable/ic_check.xml | 10 + .../main/res/drawable/ic_chevron_right.xml | 10 + app/src/main/res/drawable/ic_dark_mode.xml | 8 +- app/src/main/res/drawable/ic_delete.xml | 10 + app/src/main/res/drawable/ic_edit.xml | 10 + app/src/main/res/drawable/ic_filter.xml | 8 +- app/src/main/res/drawable/ic_home.xml | 10 + .../res/drawable/ic_launcher_background.xml | 179 +------ .../res/drawable/ic_launcher_foreground.xml | 36 +- app/src/main/res/drawable/ic_library.xml | 10 + app/src/main/res/drawable/ic_light_mode.xml | 10 + app/src/main/res/drawable/ic_logo_book.xml | 12 + app/src/main/res/drawable/ic_logout.xml | 10 + app/src/main/res/drawable/ic_pdf.xml | 10 + app/src/main/res/drawable/ic_restore.xml | 10 + app/src/main/res/drawable/ic_save.xml | 10 + app/src/main/res/drawable/ic_search.xml | 10 + app/src/main/res/drawable/ic_settings.xml | 8 +- app/src/main/res/drawable/ic_share.xml | 10 + app/src/main/res/drawable/ic_sort.xml | 8 +- app/src/main/res/drawable/ic_star.xml | 10 + app/src/main/res/drawable/ic_star_outline.xml | 10 + app/src/main/res/drawable/ic_stats.xml | 10 - app/src/main/res/drawable/icon_button_bg.xml | 9 - .../main/res/drawable/icon_button_glass.xml | 12 - .../drawable/illustration_onboarding_1.xml | 22 + .../drawable/illustration_onboarding_2.xml | 25 + .../drawable/illustration_onboarding_3.xml | 22 + .../main/res/drawable/progress_reading.xml | 17 + .../res/drawable/progress_status_finished.xml | 17 + .../res/drawable/progress_status_reading.xml | 17 + .../res/drawable/progress_status_want.xml | 17 + .../main/res/drawable/search_background.xml | 8 - app/src/main/res/drawable/tag_background.xml | 5 - .../res/drawable/tag_background_secondary.xml | 8 - app/src/main/res/drawable/tag_modern.xml | 5 - .../res/drawable/tag_modern_secondary.xml | 5 - app/src/main/res/layout/activity_add_book.xml | 183 -------- app/src/main/res/layout/activity_auth.xml | 11 + app/src/main/res/layout/activity_login.xml | 146 ------ app/src/main/res/layout/activity_main.xml | 288 +----------- .../main/res/layout/activity_onboarding.xml | 61 +++ app/src/main/res/layout/activity_register.xml | 177 ------- app/src/main/res/layout/activity_settings.xml | 134 ------ .../main/res/layout/activity_statistics.xml | 438 ------------------ .../res/layout/fragment_add_edit_book.xml | 371 +++++++++++++++ .../main/res/layout/fragment_book_detail.xml | 325 +++++++++++++ .../main/res/layout/fragment_book_list.xml | 233 ++++++++++ .../main/res/layout/fragment_dashboard.xml | 371 +++++++++++++++ app/src/main/res/layout/fragment_login.xml | 142 ++++++ app/src/main/res/layout/fragment_register.xml | 169 +++++++ app/src/main/res/layout/fragment_settings.xml | 233 ++++++++++ app/src/main/res/layout/item_book.xml | 142 ------ .../main/res/layout/item_book_horizontal.xml | 65 +++ .../main/res/layout/item_book_vertical.xml | 143 ++++++ app/src/main/res/layout/item_dropdown.xml | 8 + .../main/res/layout/item_onboarding_page.xml | 44 ++ .../main/res/layout/view_indicator_dot.xml | 6 + app/src/main/res/layout/view_settings_row.xml | 52 +++ app/src/main/res/menu/bottom_nav_menu.xml | 15 + .../main/res/navigation/auth_nav_graph.xml | 28 ++ .../main/res/navigation/main_nav_graph.xml | 66 +++ app/src/main/res/values-night/colors.xml | 7 + app/src/main/res/values-night/themes.xml | 47 +- app/src/main/res/values/colors.xml | 67 ++- app/src/main/res/values/strings.xml | 154 +++++- app/src/main/res/values/themes.xml | 83 +++- app/src/main/res/xml/backup_rules.xml | 19 +- .../main/res/xml/data_extraction_rules.xml | 22 +- app/src/main/res/xml/file_provider_paths.xml | 6 + .../minibooklibrary/ExampleUnitTest.java | 17 - .../data/remote/GoogleBooksParserTest.kt | 126 +++++ .../data/repository/BookRepositoryTest.kt | 79 ++++ .../data/repository/UserRepositoryTest.kt | 135 ++++++ .../minibooklibrary/domain/EnumStorageTest.kt | 39 ++ .../ui/auth/AuthViewModelTest.kt | 95 ++++ .../ui/books/edit/AddEditBookViewModelTest.kt | 211 +++++++++ .../ui/books/list/BookListViewModelTest.kt | 174 +++++++ .../util/PasswordHasherTest.kt | 48 ++ build.gradle.kts | 8 +- gradle.properties | 30 +- gradle/libs.versions.toml | 95 +++- screenshots/.gitkeep | 0 screenshots/README.md | 42 ++ 164 files changed, 8087 insertions(+), 3461 deletions(-) create mode 100644 .github/workflows/android.yml create mode 100644 LICENSE create mode 100644 README.md delete mode 100644 app/src/androidTest/java/com/example/minibooklibrary/ExampleInstrumentedTest.java delete mode 100644 app/src/main/java/com/example/minibooklibrary/AddBookActivity.java delete mode 100644 app/src/main/java/com/example/minibooklibrary/Book.java delete mode 100644 app/src/main/java/com/example/minibooklibrary/BookAdapter.java delete mode 100644 app/src/main/java/com/example/minibooklibrary/DatabaseHelper.java delete mode 100644 app/src/main/java/com/example/minibooklibrary/LoginActivity.java delete mode 100644 app/src/main/java/com/example/minibooklibrary/MainActivity.java create mode 100644 app/src/main/java/com/example/minibooklibrary/MiniBookLibraryApp.kt delete mode 100644 app/src/main/java/com/example/minibooklibrary/RegisterActivity.java delete mode 100644 app/src/main/java/com/example/minibooklibrary/SettingsActivity.java delete mode 100644 app/src/main/java/com/example/minibooklibrary/StatisticsActivity.java create mode 100644 app/src/main/java/com/example/minibooklibrary/data/local/BookDatabase.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/data/local/dao/BookDao.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/data/local/dao/UserDao.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/data/local/entity/BookEntity.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/data/local/entity/UserEntity.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/data/preferences/PreferencesManager.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/data/remote/GoogleBooksService.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/data/repository/AuthResult.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/data/repository/BookRepository.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/data/repository/UserRepository.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/di/ServiceLocator.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/domain/BookLookup.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/domain/ReadingStatus.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/domain/SortOrder.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/domain/StatusFilter.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/ui/RouterActivity.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/ui/auth/AuthActivity.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/ui/auth/AuthViewModel.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/ui/auth/LoginFragment.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/ui/auth/RegisterFragment.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/ui/books/detail/BookDetailFragment.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/ui/books/detail/BookDetailViewModel.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/ui/books/edit/AddEditBookFragment.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/ui/books/edit/AddEditBookViewModel.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/ui/books/list/BookAdapter.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/ui/books/list/BookListFragment.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/ui/books/list/BookListViewModel.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/ui/common/ViewModelFactory.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/ui/dashboard/DashboardFragment.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/ui/dashboard/DashboardViewModel.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/ui/main/MainActivity.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/ui/onboarding/OnboardingActivity.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/ui/onboarding/OnboardingPage.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/ui/onboarding/OnboardingPagerAdapter.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/ui/settings/SettingsFragment.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/ui/settings/SettingsViewModel.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/util/BackupManager.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/util/Extensions.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/util/ImageStorage.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/util/PasswordHasher.kt create mode 100644 app/src/main/java/com/example/minibooklibrary/util/PdfExporter.kt create mode 100644 app/src/main/res/color/bottom_nav_item_color.xml delete mode 100644 app/src/main/res/drawable/accent_bar.xml rename app/src/main/res/drawable/{dot_indicator.xml => bg_circle_glass.xml} (77%) create mode 100644 app/src/main/res/drawable/bg_cover_placeholder.xml create mode 100644 app/src/main/res/drawable/bg_dot_indicator_active.xml create mode 100644 app/src/main/res/drawable/bg_dot_indicator_inactive.xml create mode 100644 app/src/main/res/drawable/bg_gradient_header.xml create mode 100644 app/src/main/res/drawable/bg_progress_track.xml create mode 100644 app/src/main/res/drawable/bg_splash.xml create mode 100644 app/src/main/res/drawable/bg_status_chip.xml delete mode 100644 app/src/main/res/drawable/book_icon_gradient.xml delete mode 100644 app/src/main/res/drawable/card_background.xml delete mode 100644 app/src/main/res/drawable/fab_gradient.xml delete mode 100644 app/src/main/res/drawable/gradient_circle.xml delete mode 100644 app/src/main/res/drawable/gradient_header.xml delete mode 100644 app/src/main/res/drawable/gradient_header_rounded.xml create mode 100644 app/src/main/res/drawable/ic_back.xml create mode 100644 app/src/main/res/drawable/ic_backup.xml create mode 100644 app/src/main/res/drawable/ic_camera.xml create mode 100644 app/src/main/res/drawable/ic_check.xml create mode 100644 app/src/main/res/drawable/ic_chevron_right.xml create mode 100644 app/src/main/res/drawable/ic_delete.xml create mode 100644 app/src/main/res/drawable/ic_edit.xml create mode 100644 app/src/main/res/drawable/ic_home.xml create mode 100644 app/src/main/res/drawable/ic_library.xml create mode 100644 app/src/main/res/drawable/ic_light_mode.xml create mode 100644 app/src/main/res/drawable/ic_logo_book.xml create mode 100644 app/src/main/res/drawable/ic_logout.xml create mode 100644 app/src/main/res/drawable/ic_pdf.xml create mode 100644 app/src/main/res/drawable/ic_restore.xml create mode 100644 app/src/main/res/drawable/ic_save.xml create mode 100644 app/src/main/res/drawable/ic_search.xml create mode 100644 app/src/main/res/drawable/ic_share.xml create mode 100644 app/src/main/res/drawable/ic_star.xml create mode 100644 app/src/main/res/drawable/ic_star_outline.xml delete mode 100644 app/src/main/res/drawable/ic_stats.xml delete mode 100644 app/src/main/res/drawable/icon_button_bg.xml delete mode 100644 app/src/main/res/drawable/icon_button_glass.xml create mode 100644 app/src/main/res/drawable/illustration_onboarding_1.xml create mode 100644 app/src/main/res/drawable/illustration_onboarding_2.xml create mode 100644 app/src/main/res/drawable/illustration_onboarding_3.xml create mode 100644 app/src/main/res/drawable/progress_reading.xml create mode 100644 app/src/main/res/drawable/progress_status_finished.xml create mode 100644 app/src/main/res/drawable/progress_status_reading.xml create mode 100644 app/src/main/res/drawable/progress_status_want.xml delete mode 100644 app/src/main/res/drawable/search_background.xml delete mode 100644 app/src/main/res/drawable/tag_background.xml delete mode 100644 app/src/main/res/drawable/tag_background_secondary.xml delete mode 100644 app/src/main/res/drawable/tag_modern.xml delete mode 100644 app/src/main/res/drawable/tag_modern_secondary.xml delete mode 100644 app/src/main/res/layout/activity_add_book.xml create mode 100644 app/src/main/res/layout/activity_auth.xml delete mode 100644 app/src/main/res/layout/activity_login.xml create mode 100644 app/src/main/res/layout/activity_onboarding.xml delete mode 100644 app/src/main/res/layout/activity_register.xml delete mode 100644 app/src/main/res/layout/activity_settings.xml delete mode 100644 app/src/main/res/layout/activity_statistics.xml create mode 100644 app/src/main/res/layout/fragment_add_edit_book.xml create mode 100644 app/src/main/res/layout/fragment_book_detail.xml create mode 100644 app/src/main/res/layout/fragment_book_list.xml create mode 100644 app/src/main/res/layout/fragment_dashboard.xml create mode 100644 app/src/main/res/layout/fragment_login.xml create mode 100644 app/src/main/res/layout/fragment_register.xml create mode 100644 app/src/main/res/layout/fragment_settings.xml delete mode 100644 app/src/main/res/layout/item_book.xml create mode 100644 app/src/main/res/layout/item_book_horizontal.xml create mode 100644 app/src/main/res/layout/item_book_vertical.xml create mode 100644 app/src/main/res/layout/item_dropdown.xml create mode 100644 app/src/main/res/layout/item_onboarding_page.xml create mode 100644 app/src/main/res/layout/view_indicator_dot.xml create mode 100644 app/src/main/res/layout/view_settings_row.xml create mode 100644 app/src/main/res/menu/bottom_nav_menu.xml create mode 100644 app/src/main/res/navigation/auth_nav_graph.xml create mode 100644 app/src/main/res/navigation/main_nav_graph.xml create mode 100644 app/src/main/res/values-night/colors.xml create mode 100644 app/src/main/res/xml/file_provider_paths.xml delete mode 100644 app/src/test/java/com/example/minibooklibrary/ExampleUnitTest.java create mode 100644 app/src/test/java/com/example/minibooklibrary/data/remote/GoogleBooksParserTest.kt create mode 100644 app/src/test/java/com/example/minibooklibrary/data/repository/BookRepositoryTest.kt create mode 100644 app/src/test/java/com/example/minibooklibrary/data/repository/UserRepositoryTest.kt create mode 100644 app/src/test/java/com/example/minibooklibrary/domain/EnumStorageTest.kt create mode 100644 app/src/test/java/com/example/minibooklibrary/ui/auth/AuthViewModelTest.kt create mode 100644 app/src/test/java/com/example/minibooklibrary/ui/books/edit/AddEditBookViewModelTest.kt create mode 100644 app/src/test/java/com/example/minibooklibrary/ui/books/list/BookListViewModelTest.kt create mode 100644 app/src/test/java/com/example/minibooklibrary/util/PasswordHasherTest.kt create mode 100644 screenshots/.gitkeep create mode 100644 screenshots/README.md diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..5214bb4 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,60 @@ +name: Android CI + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + workflow_dispatch: {} + +# Cancel an older run on the same branch when a newer push lands so we don't +# burn minutes on stale revisions. +concurrency: + group: android-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + name: Build & unit tests + runs-on: ubuntu-latest + timeout-minutes: 25 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + with: + # Cache build outputs across runs to keep wall-clock time predictable. + cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master' }} + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Run unit tests + run: ./gradlew testDebugUnitTest --stacktrace + + - name: Assemble debug APK + run: ./gradlew assembleDebug --stacktrace + + - name: Upload test report (on failure) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: unit-test-report + path: app/build/reports/tests/ + + - name: Upload debug APK + uses: actions/upload-artifact@v4 + with: + name: app-debug + path: app/build/outputs/apk/debug/app-debug.apk + if-no-files-found: warn + retention-days: 14 diff --git a/.gitignore b/.gitignore index aa724b7..5771e43 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,16 @@ /.idea/assetWizardSettings.xml .DS_Store /build +**/build/ /captures .externalNativeBuild .cxx local.properties + +# Claude Code session metadata β€” never commit. +.claude/ + +# Common keystore files that should never be committed. +*.jks +*.keystore +keystore.properties diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..648c5d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Sultan Zhalifunnas Musyaffa + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6655d28 --- /dev/null +++ b/README.md @@ -0,0 +1,273 @@ +# Mini Book Library + +[![CI](https://github.com/SultanZhalifa/MiniBookLibrary/actions/workflows/android.yml/badge.svg)](https://github.com/SultanZhalifa/MiniBookLibrary/actions/workflows/android.yml) +[![Kotlin](https://img.shields.io/badge/Kotlin-2.0.21-7F52FF?logo=kotlin&logoColor=white)](https://kotlinlang.org/) +[![Min SDK](https://img.shields.io/badge/minSdk-21-3DDC84?logo=android&logoColor=white)](https://developer.android.com) +[![Target SDK](https://img.shields.io/badge/targetSdk-34-3DDC84?logo=android&logoColor=white)](https://developer.android.com) +[![Material](https://img.shields.io/badge/Material-3-757575?logo=materialdesign&logoColor=white)](https://m3.material.io/) +[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![APK](https://img.shields.io/badge/Download-APK-success?logo=android&logoColor=white)](#download) + +> A modern, offline-first Android app for tracking your personal book collection β€” built with Kotlin, MVVM, Room, Coroutines, and Material 3. + +Mini Book Library is a portfolio-grade Android application that lets you sign in, build a personal library, track reading progress, and export your collection. It's designed to demonstrate clean architecture, reactive data flow, and a polished Material 3 user interface. + +--- + +## ✨ Features + +### Library management +- πŸ“– **Add, edit, and delete** books with cover images, author, year, category, and personal notes +- πŸ” **Real-time search** across title, author, and category +- 🎯 **Sort and filter** by title, date added, rating, or reading status β€” preferences persist across launches +- ⬅️ **Swipe-to-delete** with one-tap **Undo** via Snackbar + +### Reading tracker +- πŸ“š Three-state reading status: **Want to Read**, **Currently Reading**, **Finished** +- πŸ“Š **Page progress tracking** with a progress bar for "Currently Reading" books +- ⭐ **5-star rating** per book β€” sortable, surfaced on the dashboard average + +### Dashboard +- πŸ‘‹ Personalized greeting based on time of day +- πŸ“ˆ Live stats: total books, average rating, status breakdown (with colored progress bars) +- πŸ” Recently added carousel with horizontal scrolling + +### Cover images +- πŸ–Ό Pick a cover from gallery via the modern **Photo Picker** (no permissions needed on Android 13+) +- πŸ’Ύ Cover bytes copied to internal storage so they survive app restarts and backups +- ⚑ Image loading via [Coil](https://coil-kt.github.io/coil/) + +### ISBN auto-fill +- πŸ”Ž Type an ISBN-10 or ISBN-13 on the Add/Edit screen and tap **Auto-fill** β€” the app calls the public **Google Books API** and pre-populates title, author, year, category, and page count. +- πŸ›‘ Doesn't overwrite anything you've already typed β€” your local input always wins. +- πŸ“¦ Zero new dependencies: uses platform `HttpURLConnection` + `org.json`. No OkHttp, no Retrofit. +- ⏱ 8s timeout so a flaky connection never hangs the UI. + +### Data export & backup +- πŸ“„ **Export to PDF** using the platform `PdfDocument` API β€” no third-party PDF dependency +- πŸ’Ύ **JSON backup** of your full library, savable to Downloads +- ♻️ **Restore from JSON** with conflict-free re-import (dataset is appended, not overwritten) + +### Account & UX +- πŸ” Login & registration with **salted SHA-256 hashing** (passwords never stored in plaintext) +- πŸŒ— **Theme toggle**: System / Light / Dark, with full Material 3 color tokens +- πŸš€ **3-slide onboarding** (ViewPager2 with dot indicators) shown only on first launch +- πŸ›‘ Session persistence with `SharedPreferences` β€” open the app and pick up where you left off + +--- + +## πŸ— Architecture + +The app follows clean **MVVM + Repository** architecture with unidirectional data flow: + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ UI β”‚ +β”‚ Onboarding Β· Auth Β· Dashboard Β· BookList Β· Detail Β· Edit Β· Settingsβ”‚ +β”‚ (Activities + Fragments) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ collects StateFlow + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ ViewModels β”‚ +β”‚ AuthVM Β· DashboardVM Β· BookListVM Β· BookDetailVM Β· AddEditVM Β· β”‚ +β”‚ SettingsVM β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ suspend / Flow + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Repositories β”‚ +β”‚ UserRepository Β· BookRepository β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β–Ό β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Room Database β”‚ β”‚ PreferencesManager β”‚ + β”‚ (UserDao, β”‚ β”‚ (theme, session, β”‚ + β”‚ BookDao) β”‚ β”‚ sort/filter) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Why this shape:** +- **Single source of truth** β€” Room emits `Flow>`. The list, dashboard, and counters subscribe independently. +- **Repository hides storage details** β€” switching from Room to a remote API later wouldn't change a single ViewModel. +- **State is derived, not pushed** β€” every UI screen consumes a `StateFlow`. Combined with `repeatOnLifecycle`, the UI auto-pauses and resumes with the lifecycle. +- **Hand-rolled DI via `ServiceLocator`** β€” Hilt would be overkill for one module; a tiny container is more interview-friendly to read. + +--- + +## 🧰 Tech stack + +| Layer | Technology | +| ---------------- | ---------- | +| Language | Kotlin 2.0 | +| Architecture | MVVM + Repository, single-Activity + Navigation Component | +| Async | Kotlin Coroutines + Flow / StateFlow | +| Database | Room 2.6 (KSP) | +| UI | Material 3, ViewBinding, ConstraintLayout, ViewPager2 | +| Image loading | Coil 2.7 | +| Navigation | Jetpack Navigation Component (Safe Args) | +| Networking | Platform `HttpURLConnection` + `org.json` (Google Books API) | +| PDF export | Android `PdfDocument` (platform) | +| Backup format | Hand-rolled JSON via `org.json` | +| Testing | JUnit 4, MockK, Turbine, kotlinx-coroutines-test, Room testing | +| CI | GitHub Actions (build + unit tests + APK artifact) | + +--- + +## πŸ“ Module structure + +``` +app/src/main/java/com/example/minibooklibrary/ +β”œβ”€β”€ MiniBookLibraryApp.kt # Application entry β€” applies persisted theme +β”œβ”€β”€ di/ +β”‚ └── ServiceLocator.kt # Hand-rolled DI container +β”œβ”€β”€ domain/ +β”‚ β”œβ”€β”€ ReadingStatus.kt # Want / Reading / Finished enum +β”‚ β”œβ”€β”€ SortOrder.kt # Title/date/rating sorts +β”‚ └── StatusFilter.kt # Library list filter +β”œβ”€β”€ data/ +β”‚ β”œβ”€β”€ local/ +β”‚ β”‚ β”œβ”€β”€ BookDatabase.kt # Room database singleton +β”‚ β”‚ β”œβ”€β”€ dao/{UserDao, BookDao} # Data access objects (suspend + Flow) +β”‚ β”‚ └── entity/{User, Book} # @Entity-annotated tables +β”‚ β”œβ”€β”€ preferences/ +β”‚ β”‚ └── PreferencesManager.kt # Typed wrapper around SharedPreferences +β”‚ β”œβ”€β”€ remote/ +β”‚ β”‚ └── GoogleBooksService.kt # ISBN lookup via Google Books, parser separated for tests +β”‚ └── repository/ +β”‚ β”œβ”€β”€ UserRepository.kt # Auth + session +β”‚ β”œβ”€β”€ BookRepository.kt # CRUD + reactive reads +β”‚ └── AuthResult.kt # Sealed result type +β”œβ”€β”€ ui/ +β”‚ β”œβ”€β”€ RouterActivity.kt # First-screen routing decision +β”‚ β”œβ”€β”€ onboarding/ # 3-slide ViewPager2 onboarding +β”‚ β”œβ”€β”€ auth/ # AuthActivity + Login/Register fragments +β”‚ β”œβ”€β”€ main/MainActivity.kt # Single-Activity host with bottom nav +β”‚ β”œβ”€β”€ dashboard/ # Home screen with stats + recent rail +β”‚ β”œβ”€β”€ books/ +β”‚ β”‚ β”œβ”€β”€ list/ # Library tab + RecyclerView adapter +β”‚ β”‚ β”œβ”€β”€ detail/ # Book detail screen +β”‚ β”‚ └── edit/ # Add/Edit form +β”‚ β”œβ”€β”€ settings/ # Theme, backup/restore, PDF export +β”‚ └── common/ViewModelFactory.kt # Builds ViewModels from ServiceLocator +└── util/ + β”œβ”€β”€ PasswordHasher.kt # Salted SHA-256 + β”œβ”€β”€ ImageStorage.kt # Copies cover URIs into internal storage + β”œβ”€β”€ PdfExporter.kt # Renders the library to a PDF + β”œβ”€β”€ BackupManager.kt # JSON export / import + └── Extensions.kt # Toast, view-visibility, date formatting +``` + +Tests live under `app/src/test/java/com/example/minibooklibrary/` mirroring the same package structure. + +--- + +## πŸ§ͺ Testing + +Run the unit test suite: + +```bash +./gradlew test +``` + +What's covered: +- `PasswordHasherTest` β€” determinism, salt-isolation, verify success/failure +- `EnumStorageTest` β€” guards `ReadingStatus`, `SortOrder`, `StatusFilter` storage values from accidental rename/reorder +- `UserRepositoryTest` β€” registration validation, duplicate-username, hash-not-plaintext, login success/failure paths, session persistence +- `BookRepositoryTest` β€” timestamp stamping on insert/update, dao forwarding +- `GoogleBooksParserTest` β€” ISBN-lookup JSON parsing (happy path, missing fields, multiple authors, taxonomy splitting, year extraction) plus service-level network and validation paths +- `AuthViewModelTest` β€” login state transitions, register validation, error consumption, logout event +- `BookListViewModelTest` β€” search filters, status filters, A-Z sort, sort/filter persistence, swipe-delete + restore +- `AddEditBookViewModelTest` β€” form validation rules (title, year range, page progress), persistence of new books, edit hydration, ISBN auto-fill (preserves user input, surfaces failures, rejects too-short input) + +--- + +## πŸš€ Getting started + +### Prerequisites +- Android Studio Hedgehog (2023.1.1) or newer +- JDK 17 +- Android SDK Platform 34 + +### Clone and run + +```bash +git clone https://github.com/SultanZhalifa/MiniBookLibrary.git +cd MiniBookLibrary +./gradlew assembleDebug +``` + +Then open the project in Android Studio and run on any device/emulator with API 21+. + +### First launch flow + +1. **Onboarding** β€” three slides introducing core features (skippable) +2. **Register** an account, then **sign in** +3. Land on the **Dashboard** with empty stats +4. Tap **Add a book** to populate your library +5. From **Settings**, try Light/Dark theme, **Export to PDF**, or **Backup to JSON** + +--- + +## πŸ“Έ Screenshots + +> Add real device screenshots to `screenshots/` and they'll show up here. The filenames below are the expected slots. + +| Onboarding | Login | Dashboard | +| :---: | :---: | :---: | +| ![Onboarding](screenshots/01_onboarding.png) | ![Login](screenshots/02_login.png) | ![Dashboard](screenshots/03_dashboard.png) | + +| Library | Book detail | Add / Edit | +| :---: | :---: | :---: | +| ![Library](screenshots/04_library.png) | ![Detail](screenshots/05_detail.png) | ![Edit](screenshots/06_edit.png) | + +| Settings | Dark mode | PDF export | +| :---: | :---: | :---: | +| ![Settings](screenshots/07_settings.png) | ![Dark mode](screenshots/08_dark_mode.png) | ![PDF](screenshots/09_pdf.png) | + +--- + +## πŸ—Ί Roadmap + +- [x] ISBN auto-fill via Google Books API +- [ ] **Barcode scanner** for ISBN (ML Kit) β€” feeds the existing auto-fill flow +- [ ] Cloud sync via Firebase or a self-hosted backend +- [ ] Reading streak / analytics charts (MPAndroidChart) +- [ ] Book recommendations based on rating history +- [ ] Multi-user library sharing +- [ ] Widget for "Currently Reading" + +--- + +## πŸ“₯ Download + +Pre-built APKs will be attached to GitHub Releases. To produce your own: + +```bash +./gradlew assembleRelease +# APK at: app/build/outputs/apk/release/app-release.apk +``` + +For an unsigned debug build: + +```bash +./gradlew assembleDebug +``` + +--- + +## πŸ™‹ About + +Built by **Sultan Zhalifunnas Musyaffa** as a portfolio piece showcasing modern Android development practices. The original Java prototype has been fully rewritten in Kotlin with a clean MVVM architecture, Room migration, and a Material 3 redesign. + +If this project helped you, ⭐ the repo or reach out β€” feedback is always welcome. + +--- + +## πŸ“„ License + +``` +MIT License β€” see LICENSE for details. +``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5f11f95..393a73a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,50 +1,111 @@ plugins { alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.ksp) + alias(libs.plugins.navigation.safeargs) } android { namespace = "com.example.minibooklibrary" - compileSdk { - version = release(36) - } + compileSdk = 35 defaultConfig { applicationId = "com.example.minibooklibrary" - minSdk = 36 - targetSdk = 36 - versionCode = 1 - versionName = "1.0" + minSdk = 21 + targetSdk = 34 + versionCode = 2 + versionName = "2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables.useSupportLibrary = true + } + + buildFeatures { + viewBinding = true + buildConfig = true } buildTypes { - release { + debug { isMinifyEnabled = false + } + release { + isMinifyEnabled = true + isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } + compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + isReturnDefaultValues = true + } } } dependencies { - implementation(libs.appcompat) + // AndroidX core + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.recyclerview) + implementation(libs.androidx.viewpager2) + implementation(libs.androidx.swiperefreshlayout) + implementation(libs.androidx.preference.ktx) implementation(libs.material) - implementation(libs.activity) - implementation(libs.constraintlayout) - implementation("androidx.appcompat:appcompat:1.6.1") - implementation("com.google.android.material:material:1.11.0") - implementation("androidx.constraintlayout:constraintlayout:2.1.4") - implementation("androidx.cardview:cardview:1.0.0") - implementation("androidx.recyclerview:recyclerview:1.3.2") + // Lifecycle (ViewModel + LiveData) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.livedata.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + + // Coroutines + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.core) + + // Room + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + + // Navigation + implementation(libs.androidx.navigation.fragment.ktx) + implementation(libs.androidx.navigation.ui.ktx) + + // Coil for image loading + implementation(libs.coil) + + // Unit testing testImplementation(libs.junit) - androidTestImplementation(libs.ext.junit) - androidTestImplementation(libs.espresso.core) -} \ No newline at end of file + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.androidx.arch.core.testing) + testImplementation(libs.turbine) + testImplementation(libs.androidx.room.testing) + + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.mockk.android) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb43..5df3362 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,21 +1,31 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# Mini Book Library - ProGuard rules + +# Preserve generics, annotations and inner classes used by libraries via reflection. +-keepattributes Signature, *Annotation*, InnerClasses, EnclosingMethod + +# Keep stack traces meaningful for crash reporting. +-keepattributes SourceFile,LineNumberTable +-renamesourcefileattribute SourceFile + +# Room +-keep class * extends androidx.room.RoomDatabase +-keep @androidx.room.Entity class * +-keep @androidx.room.Dao class * + +# Kotlin coroutines +-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} +-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} + +# Navigation Component +-keep class androidx.navigation.** { *; } + +# Material Components +-keep class com.google.android.material.** { *; } + +# Domain models used by JSON backup/restore +-keep class com.example.minibooklibrary.data.local.entity.** { *; } +-keep class com.example.minibooklibrary.domain.** { *; } + +# Coil +-keep class coil.** { *; } +-dontwarn coil.** diff --git a/app/src/androidTest/java/com/example/minibooklibrary/ExampleInstrumentedTest.java b/app/src/androidTest/java/com/example/minibooklibrary/ExampleInstrumentedTest.java deleted file mode 100644 index 52c3045..0000000 --- a/app/src/androidTest/java/com/example/minibooklibrary/ExampleInstrumentedTest.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.example.minibooklibrary; - -import android.content.Context; - -import androidx.test.platform.app.InstrumentationRegistry; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.junit.Test; -import org.junit.runner.RunWith; - -import static org.junit.Assert.*; - -/** - * Instrumented test, which will execute on an Android device. - * - * @see Testing documentation - */ -@RunWith(AndroidJUnit4.class) -public class ExampleInstrumentedTest { - @Test - public void useAppContext() { - // Context of the app under test. - Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); - assertEquals("com.example.minibooklibrary", appContext.getPackageName()); - } -} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d6f57e7..5af0637 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,18 +1,34 @@ + xmlns:tools="http://schemas.android.com/tools"> + + + + + + + + + android:theme="@style/Theme.MiniBookLibrary" + tools:targetApi="34"> + + android:name=".ui.RouterActivity" + android:exported="true" + android:theme="@style/Theme.MiniBookLibrary.Splash"> @@ -20,26 +36,29 @@ - - + android:name=".ui.onboarding.OnboardingActivity" + android:exported="false" + android:theme="@style/Theme.MiniBookLibrary" /> + android:name=".ui.auth.AuthActivity" + android:exported="false" + android:theme="@style/Theme.MiniBookLibrary" /> - - - + android:name=".ui.main.MainActivity" + android:exported="false" + android:theme="@style/Theme.MiniBookLibrary" + android:windowSoftInputMode="adjustResize" /> + + + - diff --git a/app/src/main/java/com/example/minibooklibrary/AddBookActivity.java b/app/src/main/java/com/example/minibooklibrary/AddBookActivity.java deleted file mode 100644 index ada27c2..0000000 --- a/app/src/main/java/com/example/minibooklibrary/AddBookActivity.java +++ /dev/null @@ -1,206 +0,0 @@ -package com.example.minibooklibrary; - -import android.content.SharedPreferences; -import android.os.Bundle; -import android.text.TextUtils; -import android.view.View; -import android.widget.ArrayAdapter; -import android.widget.CheckBox; -import android.widget.ImageButton; -import android.widget.Spinner; -import android.widget.TextView; -import android.widget.Toast; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import com.google.android.material.button.MaterialButton; -import com.google.android.material.textfield.TextInputEditText; - -public class AddBookActivity extends AppCompatActivity { - - private TextInputEditText etTitle, etAuthor, etYear; - private Spinner spinnerCategory; - private CheckBox cbIsRead; - private MaterialButton btnSave, btnDelete; - private ImageButton btnBack; - private TextView tvTitle; - - private DatabaseHelper dbHelper; - private SharedPreferences sharedPreferences; - private int userId; - private boolean isEditMode = false; - private int bookId = -1; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_add_book); - - dbHelper = new DatabaseHelper(this); - sharedPreferences = getSharedPreferences("BookLibraryPrefs", MODE_PRIVATE); - userId = sharedPreferences.getInt("userId", -1); - - initViews(); - setupSpinner(); - checkEditMode(); - setupListeners(); - } - - private void initViews() { - etTitle = findViewById(R.id.etTitle); - etAuthor = findViewById(R.id.etAuthor); - etYear = findViewById(R.id.etYear); - spinnerCategory = findViewById(R.id.spinnerCategory); - cbIsRead = findViewById(R.id.cbIsRead); - btnSave = findViewById(R.id.btnSave); - btnDelete = findViewById(R.id.btnDelete); - btnBack = findViewById(R.id.btnBack); - tvTitle = findViewById(R.id.tvTitle); - } - - private void setupSpinner() { - ArrayAdapter adapter = ArrayAdapter.createFromResource( - this, - R.array.categories, - android.R.layout.simple_spinner_item - ); - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - spinnerCategory.setAdapter(adapter); - } - - private void checkEditMode() { - if (getIntent().hasExtra("isEdit") && getIntent().getBooleanExtra("isEdit", false)) { - isEditMode = true; - bookId = getIntent().getIntExtra("bookId", -1); - - tvTitle.setText("Edit Book"); - btnSave.setText("Update Book"); - btnDelete.setVisibility(View.VISIBLE); - - // Fill data - etTitle.setText(getIntent().getStringExtra("title")); - etAuthor.setText(getIntent().getStringExtra("author")); - etYear.setText(String.valueOf(getIntent().getIntExtra("year", 0))); - cbIsRead.setChecked(getIntent().getBooleanExtra("isRead", false)); - - String category = getIntent().getStringExtra("category"); - ArrayAdapter adapter = (ArrayAdapter) spinnerCategory.getAdapter(); - int position = adapter.getPosition(category); - spinnerCategory.setSelection(position); - } - } - - private void setupListeners() { - btnBack.setOnClickListener(v -> finish()); - - btnSave.setOnClickListener(v -> { - if (isEditMode) { - updateBook(); - } else { - saveBook(); - } - }); - - btnDelete.setOnClickListener(v -> showDeleteDialog()); - } - - private void saveBook() { - String title = etTitle.getText().toString().trim(); - String author = etAuthor.getText().toString().trim(); - String category = spinnerCategory.getSelectedItem().toString(); - String yearStr = etYear.getText().toString().trim(); - boolean isRead = cbIsRead.isChecked(); - - if (!validateInput(title, author, yearStr)) { - return; - } - int year = Integer.parseInt(yearStr); - - Book book = new Book(0, title, author, category, year, isRead); - boolean success = dbHelper.addBook(book, userId); - - if (success) { - Toast.makeText(this, "Book added successfully!", Toast.LENGTH_SHORT).show(); - finish(); - } else { - Toast.makeText(this, "Failed to add book", Toast.LENGTH_SHORT).show(); - } - } - - private void updateBook() { - String title = etTitle.getText().toString().trim(); - String author = etAuthor.getText().toString().trim(); - String category = spinnerCategory.getSelectedItem().toString(); - String yearStr = etYear.getText().toString().trim(); - boolean isRead = cbIsRead.isChecked(); - - if (!validateInput(title, author, yearStr)) { - return; - } - int year = Integer.parseInt(yearStr); - - Book book = new Book(bookId, title, author, category, year, isRead); - boolean success = dbHelper.updateBook(book); - - if (success) { - Toast.makeText(this, "Book updated successfully!", Toast.LENGTH_SHORT).show(); - finish(); - } else { - Toast.makeText(this, "Failed to update book", Toast.LENGTH_SHORT).show(); - } - } - - private boolean validateInput(String title, String author, String year) { - if (TextUtils.isEmpty(title)) { - etTitle.setError("Title required"); - return false; - } - - if (TextUtils.isEmpty(author)) { - etAuthor.setError("Author required"); - return false; - } - - if (TextUtils.isEmpty(year)) { - etYear.setError("Year required"); - return false; - } - - if (year.length() != 4) { - etYear.setError("Invalid year format"); - return false; - } - - try { - int yearInt = Integer.parseInt(year); - if (yearInt < 1000 || yearInt > 2100) { - etYear.setError("Year must be between 1000-2100"); - return false; - } - } catch (NumberFormatException e) { - etYear.setError("Invalid year"); - return false; - } - - return true; - } - - private void showDeleteDialog() { - new AlertDialog.Builder(this) - .setTitle("Delete Book") - .setMessage("Are you sure you want to delete this book?") - .setPositiveButton("Delete", (dialog, which) -> deleteBook()) - .setNegativeButton("Cancel", null) - .show(); - } - - private void deleteBook() { - boolean success = dbHelper.deleteBook(bookId); - - if (success) { - Toast.makeText(this, "Book deleted successfully", Toast.LENGTH_SHORT).show(); - finish(); - } else { - Toast.makeText(this, "Failed to delete book", Toast.LENGTH_SHORT).show(); - } - } -} diff --git a/app/src/main/java/com/example/minibooklibrary/Book.java b/app/src/main/java/com/example/minibooklibrary/Book.java deleted file mode 100644 index e48ad18..0000000 --- a/app/src/main/java/com/example/minibooklibrary/Book.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.example.minibooklibrary; - -public class Book { - private int id; - private String title; - private String author; - private String category; - private int year; - private boolean isRead; - - // βœ… NEW FIELDS - private float rating; // 0.0 - 5.0 - private String coverImagePath; // Path to cover image - private int currentPage; // Current reading page - private int totalPages; // Total pages in book - private String notes; // Personal notes - private long dateAdded; // Timestamp when added - private long lastModified; // Last update timestamp - - // Empty constructor - public Book() {} - - // Original constructor - public Book(int id, String title, String author, String category, int year, boolean isRead) { - this.id = id; - this.title = title; - this.author = author; - this.category = category; - this.year = year; - this.isRead = isRead; - this.rating = 0.0f; - this.coverImagePath = ""; - this.currentPage = 0; - this.totalPages = 0; - this.notes = ""; - this.dateAdded = System.currentTimeMillis(); - this.lastModified = System.currentTimeMillis(); - } - - // Full constructor with new fields - public Book(int id, String title, String author, String category, int year, boolean isRead, - float rating, String coverImagePath, int currentPage, int totalPages, - String notes, long dateAdded, long lastModified) { - this.id = id; - this.title = title; - this.author = author; - this.category = category; - this.year = year; - this.isRead = isRead; - this.rating = rating; - this.coverImagePath = coverImagePath; - this.currentPage = currentPage; - this.totalPages = totalPages; - this.notes = notes; - this.dateAdded = dateAdded; - this.lastModified = lastModified; - } - - // Getters and Setters - public int getId() { return id; } - public void setId(int id) { this.id = id; } - - public String getTitle() { return title; } - public void setTitle(String title) { this.title = title; } - - public String getAuthor() { return author; } - public void setAuthor(String author) { this.author = author; } - - public String getCategory() { return category; } - public void setCategory(String category) { this.category = category; } - - public int getYear() { return year; } - public void setYear(int year) { this.year = year; } - - public boolean isRead() { return isRead; } - public void setRead(boolean read) { isRead = read; } - - // βœ… NEW GETTERS & SETTERS - public float getRating() { return rating; } - public void setRating(float rating) { this.rating = rating; } - - public String getCoverImagePath() { return coverImagePath; } - public void setCoverImagePath(String coverImagePath) { this.coverImagePath = coverImagePath; } - - public int getCurrentPage() { return currentPage; } - public void setCurrentPage(int currentPage) { this.currentPage = currentPage; } - - public int getTotalPages() { return totalPages; } - public void setTotalPages(int totalPages) { this.totalPages = totalPages; } - - public String getNotes() { return notes; } - public void setNotes(String notes) { this.notes = notes; } - - public long getDateAdded() { return dateAdded; } - public void setDateAdded(long dateAdded) { this.dateAdded = dateAdded; } - - public long getLastModified() { return lastModified; } - public void setLastModified(long lastModified) { this.lastModified = lastModified; } - - // βœ… HELPER METHODS - public int getReadingProgress() { - if (totalPages == 0) return 0; - return (int) ((currentPage * 100.0) / totalPages); - } - - public boolean hasRating() { - return rating > 0; - } - - public boolean hasCoverImage() { - return coverImagePath != null && !coverImagePath.isEmpty(); - } -} diff --git a/app/src/main/java/com/example/minibooklibrary/BookAdapter.java b/app/src/main/java/com/example/minibooklibrary/BookAdapter.java deleted file mode 100644 index 29e375c..0000000 --- a/app/src/main/java/com/example/minibooklibrary/BookAdapter.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.example.minibooklibrary; - -import android.content.Context; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; // βœ… TAMBAHAN -import android.widget.TextView; -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; -import java.util.ArrayList; - -public class BookAdapter extends RecyclerView.Adapter { - - private Context context; - private ArrayList bookList; - private OnBookClickListener listener; - - public interface OnBookClickListener { - void onBookClick(Book book); - } - - public BookAdapter(Context context, ArrayList bookList, OnBookClickListener listener) { - this.context = context; - this.bookList = bookList; - this.listener = listener; - } - - @NonNull - @Override - public BookViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View view = LayoutInflater.from(context).inflate(R.layout.item_book, parent, false); - return new BookViewHolder(view); - } - - @Override - public void onBindViewHolder(@NonNull BookViewHolder holder, int position) { - Book book = bookList.get(position); - - holder.tvBookTitle.setText(book.getTitle()); - holder.tvAuthor.setText("by " + book.getAuthor()); - holder.tvCategory.setText(book.getCategory()); - holder.tvYear.setText(String.valueOf(book.getYear())); - - // Show read badge if book is read - if (book.isRead()) { - holder.tvReadBadge.setVisibility(View.VISIBLE); - } else { - holder.tvReadBadge.setVisibility(View.GONE); - } - - // Click listener - holder.itemView.setOnClickListener(v -> listener.onBookClick(book)); - } - - @Override - public int getItemCount() { - return bookList.size(); - } - - // βœ… UPDATED ViewHolder - public class BookViewHolder extends RecyclerView.ViewHolder { - TextView tvBookTitle, tvAuthor, tvCategory, tvYear; - ImageView ivBookIcon; // βœ… CHANGED: TextView -> ImageView - View tvReadBadge; // βœ… CHANGED: TextView -> View - - public BookViewHolder(@NonNull View itemView) { - super(itemView); - tvBookTitle = itemView.findViewById(R.id.tvBookTitle); - tvAuthor = itemView.findViewById(R.id.tvAuthor); - tvCategory = itemView.findViewById(R.id.tvCategory); - tvYear = itemView.findViewById(R.id.tvYear); - ivBookIcon = itemView.findViewById(R.id.ivBookIcon); // βœ… FIXED! - tvReadBadge = itemView.findViewById(R.id.tvReadBadge); - } - } -} diff --git a/app/src/main/java/com/example/minibooklibrary/DatabaseHelper.java b/app/src/main/java/com/example/minibooklibrary/DatabaseHelper.java deleted file mode 100644 index b915c5b..0000000 --- a/app/src/main/java/com/example/minibooklibrary/DatabaseHelper.java +++ /dev/null @@ -1,301 +0,0 @@ -package com.example.minibooklibrary; - -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteOpenHelper; -import java.util.ArrayList; - -public class DatabaseHelper extends SQLiteOpenHelper { - - private static final String DATABASE_NAME = "BookLibrary.db"; - private static final int DATABASE_VERSION = 2; - - // Users table - private static final String TABLE_USERS = "users"; - private static final String COL_USER_ID = "id"; - private static final String COL_USERNAME = "username"; - private static final String COL_PASSWORD = "password"; - - // Books table - private static final String TABLE_BOOKS = "books"; - private static final String COL_BOOK_ID = "id"; - private static final String COL_TITLE = "title"; - private static final String COL_AUTHOR = "author"; - private static final String COL_CATEGORY = "category"; - private static final String COL_YEAR = "year"; - private static final String COL_IS_READ = "is_read"; - private static final String COL_USER_ID_FK = "user_id"; - private static final String COL_RATING = "rating"; - private static final String COL_COVER_IMAGE = "cover_image_path"; - private static final String COL_CURRENT_PAGE = "current_page"; - private static final String COL_TOTAL_PAGES = "total_pages"; - private static final String COL_NOTES = "notes"; - private static final String COL_DATE_ADDED = "date_added"; - private static final String COL_LAST_MODIFIED = "last_modified"; - - public DatabaseHelper(Context context) { - super(context, DATABASE_NAME, null, DATABASE_VERSION); - } - - @Override - public void onCreate(SQLiteDatabase db) { - // Create users table - String createUsersTable = "CREATE TABLE " + TABLE_USERS + " (" + - COL_USER_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + - COL_USERNAME + " TEXT UNIQUE, " + - COL_PASSWORD + " TEXT)"; - db.execSQL(createUsersTable); - - // Create books table with new columns - String createBooksTable = "CREATE TABLE " + TABLE_BOOKS + " (" + - COL_BOOK_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + - COL_TITLE + " TEXT, " + - COL_AUTHOR + " TEXT, " + - COL_CATEGORY + " TEXT, " + - COL_YEAR + " INTEGER, " + // Seharusnya INTEGER untuk tahun - COL_IS_READ + " INTEGER DEFAULT 0, " + - COL_USER_ID_FK + " INTEGER, " + - COL_RATING + " REAL DEFAULT 0.0, " + - COL_COVER_IMAGE + " TEXT, " + - COL_CURRENT_PAGE + " INTEGER DEFAULT 0, " + - COL_TOTAL_PAGES + " INTEGER DEFAULT 0, " + - COL_NOTES + " TEXT, " + - COL_DATE_ADDED + " INTEGER, " + - COL_LAST_MODIFIED + " INTEGER, " + - "FOREIGN KEY(" + COL_USER_ID_FK + ") REFERENCES " + - TABLE_USERS + "(" + COL_USER_ID + "))"; - db.execSQL(createBooksTable); - } - - @Override - public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { - if (oldVersion < 2) { - db.execSQL("ALTER TABLE " + TABLE_BOOKS + " ADD COLUMN " + COL_RATING + " REAL DEFAULT 0.0"); - db.execSQL("ALTER TABLE " + TABLE_BOOKS + " ADD COLUMN " + COL_COVER_IMAGE + " TEXT"); - db.execSQL("ALTER TABLE " + TABLE_BOOKS + " ADD COLUMN " + COL_CURRENT_PAGE + " INTEGER DEFAULT 0"); - db.execSQL("ALTER TABLE " + TABLE_BOOKS + " ADD COLUMN " + COL_TOTAL_PAGES + " INTEGER DEFAULT 0"); - db.execSQL("ALTER TABLE " + TABLE_BOOKS + " ADD COLUMN " + COL_NOTES + " TEXT"); - db.execSQL("ALTER TABLE " + TABLE_BOOKS + " ADD COLUMN " + COL_DATE_ADDED + " INTEGER"); - db.execSQL("ALTER TABLE " + TABLE_BOOKS + " ADD COLUMN " + COL_LAST_MODIFIED + " INTEGER"); - } - } - - // ========== USER METHODS ========== - - public boolean registerUser(String username, String password) { - SQLiteDatabase db = this.getWritableDatabase(); - ContentValues values = new ContentValues(); - values.put(COL_USERNAME, username); - values.put(COL_PASSWORD, password); // Note: For a real app, hash the password! - long result = db.insert(TABLE_USERS, null, values); - db.close(); - return result != -1; - } - - public int loginUser(String username, String password) { - SQLiteDatabase db = this.getReadableDatabase(); - Cursor cursor = db.query(TABLE_USERS, - new String[]{COL_USER_ID}, - COL_USERNAME + "=? AND " + COL_PASSWORD + "=?", - new String[]{username, password}, - null, null, null); - - int userId = -1; - if (cursor != null && cursor.moveToFirst()) { - userId = cursor.getInt(cursor.getColumnIndexOrThrow(COL_USER_ID)); - cursor.close(); - } - db.close(); - return userId; - } - - // ========== BOOK METHODS ========== - - public boolean addBook(Book book, int userId) { - SQLiteDatabase db = this.getWritableDatabase(); - ContentValues values = new ContentValues(); - values.put(COL_TITLE, book.getTitle()); - values.put(COL_AUTHOR, book.getAuthor()); - values.put(COL_CATEGORY, book.getCategory()); - values.put(COL_YEAR, book.getYear()); - values.put(COL_IS_READ, book.isRead() ? 1 : 0); - values.put(COL_USER_ID_FK, userId); - values.put(COL_RATING, book.getRating()); - values.put(COL_COVER_IMAGE, book.getCoverImagePath()); - values.put(COL_CURRENT_PAGE, book.getCurrentPage()); - values.put(COL_TOTAL_PAGES, book.getTotalPages()); - values.put(COL_NOTES, book.getNotes()); - values.put(COL_DATE_ADDED, System.currentTimeMillis()); - values.put(COL_LAST_MODIFIED, System.currentTimeMillis()); - - long result = db.insert(TABLE_BOOKS, null, values); - db.close(); - return result != -1; - } - - public ArrayList getAllBooks(int userId) { - ArrayList bookList = new ArrayList<>(); - SQLiteDatabase db = this.getReadableDatabase(); - - Cursor cursor = db.query(TABLE_BOOKS, null, - COL_USER_ID_FK + "=?", - new String[]{String.valueOf(userId)}, - null, null, COL_LAST_MODIFIED + " DESC"); - - if (cursor.moveToFirst()) { - do { - Book book = new Book( - cursor.getInt(cursor.getColumnIndexOrThrow(COL_BOOK_ID)), - cursor.getString(cursor.getColumnIndexOrThrow(COL_TITLE)), - cursor.getString(cursor.getColumnIndexOrThrow(COL_AUTHOR)), - cursor.getString(cursor.getColumnIndexOrThrow(COL_CATEGORY)), - cursor.getInt(cursor.getColumnIndexOrThrow(COL_YEAR)), - cursor.getInt(cursor.getColumnIndexOrThrow(COL_IS_READ)) == 1, - cursor.getFloat(cursor.getColumnIndexOrThrow(COL_RATING)), - cursor.getString(cursor.getColumnIndexOrThrow(COL_COVER_IMAGE)), - cursor.getInt(cursor.getColumnIndexOrThrow(COL_CURRENT_PAGE)), - cursor.getInt(cursor.getColumnIndexOrThrow(COL_TOTAL_PAGES)), - cursor.getString(cursor.getColumnIndexOrThrow(COL_NOTES)), - cursor.getLong(cursor.getColumnIndexOrThrow(COL_DATE_ADDED)), - cursor.getLong(cursor.getColumnIndexOrThrow(COL_LAST_MODIFIED)) - ); - bookList.add(book); - } while (cursor.moveToNext()); - } - cursor.close(); - db.close(); - return bookList; - } - - public boolean updateBook(Book book) { - SQLiteDatabase db = this.getWritableDatabase(); - ContentValues values = new ContentValues(); - values.put(COL_TITLE, book.getTitle()); - values.put(COL_AUTHOR, book.getAuthor()); - values.put(COL_CATEGORY, book.getCategory()); - values.put(COL_YEAR, book.getYear()); - values.put(COL_IS_READ, book.isRead() ? 1 : 0); - values.put(COL_RATING, book.getRating()); - values.put(COL_COVER_IMAGE, book.getCoverImagePath()); - values.put(COL_CURRENT_PAGE, book.getCurrentPage()); - values.put(COL_TOTAL_PAGES, book.getTotalPages()); - values.put(COL_NOTES, book.getNotes()); - values.put(COL_LAST_MODIFIED, System.currentTimeMillis()); - - int result = db.update(TABLE_BOOKS, values, - COL_BOOK_ID + "=?", - new String[]{String.valueOf(book.getId())}); - db.close(); - return result > 0; - } - - public boolean deleteBook(int bookId) { - SQLiteDatabase db = this.getWritableDatabase(); - int result = db.delete(TABLE_BOOKS, - COL_BOOK_ID + "=?", - new String[]{String.valueOf(bookId)}); - db.close(); - return result > 0; - } - - // βœ… INI METODE BARU YANG ANDA BUTUHKAN - public ArrayList searchBooks(String query, int userId) { - ArrayList bookList = new ArrayList<>(); - SQLiteDatabase db = this.getReadableDatabase(); - - String selection = COL_USER_ID_FK + " = ? AND (" + - COL_TITLE + " LIKE ? OR " + - COL_AUTHOR + " LIKE ?)"; - String[] selectionArgs = { - String.valueOf(userId), - "%" + query + "%", - "%" + query + "%" - }; - - Cursor cursor = db.query( - TABLE_BOOKS, - null, - selection, - selectionArgs, - null, - null, - COL_LAST_MODIFIED + " DESC" - ); - - if (cursor.moveToFirst()) { - do { - Book book = new Book( - cursor.getInt(cursor.getColumnIndexOrThrow(COL_BOOK_ID)), - cursor.getString(cursor.getColumnIndexOrThrow(COL_TITLE)), - cursor.getString(cursor.getColumnIndexOrThrow(COL_AUTHOR)), - cursor.getString(cursor.getColumnIndexOrThrow(COL_CATEGORY)), - cursor.getInt(cursor.getColumnIndexOrThrow(COL_YEAR)), - cursor.getInt(cursor.getColumnIndexOrThrow(COL_IS_READ)) == 1, - cursor.getFloat(cursor.getColumnIndexOrThrow(COL_RATING)), - cursor.getString(cursor.getColumnIndexOrThrow(COL_COVER_IMAGE)), - cursor.getInt(cursor.getColumnIndexOrThrow(COL_CURRENT_PAGE)), - cursor.getInt(cursor.getColumnIndexOrThrow(COL_TOTAL_PAGES)), - cursor.getString(cursor.getColumnIndexOrThrow(COL_NOTES)), - cursor.getLong(cursor.getColumnIndexOrThrow(COL_DATE_ADDED)), - cursor.getLong(cursor.getColumnIndexOrThrow(COL_LAST_MODIFIED)) - ); - bookList.add(book); - } while (cursor.moveToNext()); - } - cursor.close(); - db.close(); - return bookList; - } - - // ========== NEW: Get statistics ========== - - public int getTotalBooks(int userId) { - SQLiteDatabase db = this.getReadableDatabase(); - Cursor cursor = db.rawQuery( - "SELECT COUNT(*) FROM " + TABLE_BOOKS + - " WHERE " + COL_USER_ID_FK + "=?", - new String[]{String.valueOf(userId)}); - - int count = 0; - if (cursor.moveToFirst()) { - count = cursor.getInt(0); - } - cursor.close(); - db.close(); - return count; - } - - public int getReadBooks(int userId) { - SQLiteDatabase db = this.getReadableDatabase(); - Cursor cursor = db.rawQuery( - "SELECT COUNT(*) FROM " + TABLE_BOOKS + - " WHERE " + COL_USER_ID_FK + "=? AND " + COL_IS_READ + "=1", - new String[]{String.valueOf(userId)}); - - int count = 0; - if (cursor.moveToFirst()) { - count = cursor.getInt(0); - } - cursor.close(); - db.close(); - return count; - } - - public float getAverageRating(int userId) { - SQLiteDatabase db = this.getReadableDatabase(); - Cursor cursor = db.rawQuery( - "SELECT AVG(" + COL_RATING + ") FROM " + TABLE_BOOKS + - " WHERE " + COL_USER_ID_FK + "=? AND " + COL_RATING + ">0", - new String[]{String.valueOf(userId)}); - - float avg = 0.0f; - if (cursor.moveToFirst()) { - avg = cursor.getFloat(0); - } - cursor.close(); - db.close(); - return avg; - } -} diff --git a/app/src/main/java/com/example/minibooklibrary/LoginActivity.java b/app/src/main/java/com/example/minibooklibrary/LoginActivity.java deleted file mode 100644 index cb1c546..0000000 --- a/app/src/main/java/com/example/minibooklibrary/LoginActivity.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.example.minibooklibrary; - -import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.text.TextUtils; -import android.widget.TextView; -import android.widget.Toast; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.app.AppCompatDelegate; -import com.google.android.material.button.MaterialButton; -import com.google.android.material.textfield.TextInputEditText; - -public class LoginActivity extends AppCompatActivity { - - private TextInputEditText etUsername, etPassword; - private MaterialButton btnLogin; - private TextView tvRegister; - private DatabaseHelper dbHelper; - private SharedPreferences sharedPreferences; - - @Override - protected void onCreate(Bundle savedInstanceState) { - // βœ… TAMBAHAN: Apply dark mode SEBELUM super.onCreate() - sharedPreferences = getSharedPreferences("BookLibraryPrefs", MODE_PRIVATE); - boolean isDarkMode = sharedPreferences.getBoolean("isDarkMode", false); - if (isDarkMode) { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); - } else { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); - } - - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_login); - - dbHelper = new DatabaseHelper(this); - - // Check if already logged in - if (sharedPreferences.getBoolean("isLoggedIn", false)) { - navigateToMain(); - return; - } - - initViews(); - setupListeners(); - } - - private void initViews() { - etUsername = findViewById(R.id.etUsername); - etPassword = findViewById(R.id.etPassword); - btnLogin = findViewById(R.id.btnLogin); - tvRegister = findViewById(R.id.tvRegister); - } - - private void setupListeners() { - btnLogin.setOnClickListener(v -> loginUser()); - - tvRegister.setOnClickListener(v -> { - startActivity(new Intent(LoginActivity.this, RegisterActivity.class)); - }); - } - - private void loginUser() { - String username = etUsername.getText().toString().trim(); - String password = etPassword.getText().toString().trim(); - - if (TextUtils.isEmpty(username)) { - etUsername.setError("Username required"); - return; - } - - if (TextUtils.isEmpty(password)) { - etPassword.setError("Password required"); - return; - } - - int userId = dbHelper.loginUser(username, password); - if (userId != -1) { - SharedPreferences.Editor editor = sharedPreferences.edit(); - editor.putBoolean("isLoggedIn", true); - editor.putString("username", username); - editor.putInt("userId", userId); - editor.apply(); - - Toast.makeText(this, "Login successful!", Toast.LENGTH_SHORT).show(); - navigateToMain(); - } else { - Toast.makeText(this, "Invalid credentials", Toast.LENGTH_SHORT).show(); - } - } - - private void navigateToMain() { - Intent intent = new Intent(LoginActivity.this, MainActivity.class); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - startActivity(intent); - finish(); - } -} diff --git a/app/src/main/java/com/example/minibooklibrary/MainActivity.java b/app/src/main/java/com/example/minibooklibrary/MainActivity.java deleted file mode 100644 index e23e1d2..0000000 --- a/app/src/main/java/com/example/minibooklibrary/MainActivity.java +++ /dev/null @@ -1,405 +0,0 @@ -package com.example.minibooklibrary; - -import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.text.Editable; -import android.text.TextWatcher; -import android.view.View; -import android.widget.EditText; -import android.widget.ImageButton; -import android.widget.LinearLayout; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.app.AppCompatDelegate; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.google.android.material.snackbar.Snackbar; - -import java.util.ArrayList; -import java.util.Collections; - -public class MainActivity extends AppCompatActivity implements BookAdapter.OnBookClickListener { - - // βœ… Views utama - private RecyclerView recyclerViewBooks; - private FloatingActionButton fabAddBook; - private EditText etSearch; - private TextView tvWelcome, tvBookCount; - private ImageButton btnLogout; - private LinearLayout emptyState; - - private DatabaseHelper dbHelper; - private BookAdapter bookAdapter; - private ArrayList bookList; - private SharedPreferences sharedPreferences; - private int userId; - private String username; - - // βœ… Button untuk fitur baru - private ImageButton btnSettings, btnStats, btnSort, btnFilter; - - // βœ… Variables untuk Sort & Filter - private String currentSortOrder = "newest"; - private String currentFilter = "all"; - - @Override - protected void onCreate(Bundle savedInstanceState) { - // βœ… Apply dark mode FIRST - sharedPreferences = getSharedPreferences("BookLibraryPrefs", MODE_PRIVATE); - applyDarkMode(); - - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - - dbHelper = new DatabaseHelper(this); - - // Get user data - userId = sharedPreferences.getInt("userId", -1); - username = sharedPreferences.getString("username", "User"); - - if (userId == -1) { - navigateToLogin(); - return; - } - - initViews(); - setupRecyclerView(); - loadBooks(); - setupListeners(); - } - - // βœ… Apply dark mode - private void applyDarkMode() { - boolean isDarkMode = sharedPreferences.getBoolean("isDarkMode", false); - if (isDarkMode) { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); - } else { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); - } - } - - private void initViews() { - // βœ… View utama - recyclerViewBooks = findViewById(R.id.recyclerViewBooks); - fabAddBook = findViewById(R.id.fabAddBook); - etSearch = findViewById(R.id.etSearch); - tvWelcome = findViewById(R.id.tvWelcome); - tvBookCount = findViewById(R.id.tvBookCount); - btnLogout = findViewById(R.id.btnLogout); - emptyState = findViewById(R.id.emptyState); - - // βœ… Init button baru (toolbar modern) - btnSettings = findViewById(R.id.btnSettings); - btnStats = findViewById(R.id.btnStats); - btnSort = findViewById(R.id.btnSort); - btnFilter = findViewById(R.id.btnFilter); - - // βœ… Header modern - TextView tvGreeting = findViewById(R.id.tvGreeting); // dari layout toolbar baru - // Kalau mau diubah dinamis bisa: tvGreeting.setText("Good evening πŸŒ™"); - - // βœ… UPDATE: Set username saja (tanpa "Welcome,") - tvWelcome.setText(username); - } - - private void setupRecyclerView() { - // βœ… Setup RecyclerView - bookList = new ArrayList<>(); - bookAdapter = new BookAdapter(this, bookList, this); - recyclerViewBooks.setLayoutManager(new LinearLayoutManager(this)); - recyclerViewBooks.setAdapter(bookAdapter); - - // βœ… SWIPE TO DELETE FEATURE - ItemTouchHelper itemTouchHelper = new ItemTouchHelper( - new ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT) { - @Override - public boolean onMove(@NonNull RecyclerView recyclerView, - @NonNull RecyclerView.ViewHolder viewHolder, - @NonNull RecyclerView.ViewHolder target) { - return false; - } - - @Override - public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { - int position = viewHolder.getAdapterPosition(); - Book deletedBook = bookList.get(position); - - // Remove from list - bookList.remove(position); - bookAdapter.notifyItemRemoved(position); - updateUI(); - - // Show Snackbar with UNDO option - Snackbar.make(recyclerViewBooks, - deletedBook.getTitle() + " deleted", - Snackbar.LENGTH_LONG) - .setAction("UNDO", v -> { - bookList.add(position, deletedBook); - bookAdapter.notifyItemInserted(position); - updateUI(); - }) - .addCallback(new Snackbar.Callback() { - @Override - public void onDismissed(Snackbar snackbar, int event) { - if (event != DISMISS_EVENT_ACTION) { - // Actually delete from database - dbHelper.deleteBook(deletedBook.getId()); - Toast.makeText(MainActivity.this, - "Book permanently deleted", - Toast.LENGTH_SHORT).show(); - } - } - }) - .show(); - } - }); - - itemTouchHelper.attachToRecyclerView(recyclerViewBooks); - } - - // βœ… loadBooks dengan Sort & Filter - private void loadBooks() { - bookList.clear(); - ArrayList allBooks = dbHelper.getAllBooks(userId); - - // βœ… APPLY FILTER - for (Book book : allBooks) { - if (currentFilter.equals("all")) { - bookList.add(book); - } else if (currentFilter.equals("read") && book.isRead()) { - bookList.add(book); - } else if (currentFilter.equals("unread") && !book.isRead()) { - bookList.add(book); - } - } - - // βœ… APPLY SORTING - switch (currentSortOrder) { - case "newest": - // Already sorted by ID DESC from database - break; - case "oldest": - Collections.reverse(bookList); - break; - case "a-z": - Collections.sort(bookList, - (b1, b2) -> b1.getTitle().compareToIgnoreCase(b2.getTitle())); - break; - case "z-a": - Collections.sort(bookList, - (b1, b2) -> b2.getTitle().compareToIgnoreCase(b1.getTitle())); - break; - } - - bookAdapter.notifyDataSetChanged(); - updateUI(); - } - - // βœ… Update UI (book count + empty state) - private void updateUI() { - tvBookCount.setText("Total Books: " + bookList.size()); - - if (bookList.isEmpty()) { - emptyState.setVisibility(View.VISIBLE); - recyclerViewBooks.setVisibility(View.GONE); - } else { - emptyState.setVisibility(View.GONE); - recyclerViewBooks.setVisibility(View.VISIBLE); - } - } - - // βœ… setupListeners dengan fitur baru - private void setupListeners() { - // Add book - fabAddBook.setOnClickListener(v -> { - Intent intent = new Intent(MainActivity.this, AddBookActivity.class); - startActivity(intent); - }); - - // Logout - btnLogout.setOnClickListener(v -> showLogoutDialog()); - - // Settings (Dark Mode) - btnSettings.setOnClickListener(v -> { - startActivity(new Intent(MainActivity.this, SettingsActivity.class)); - }); - - // Statistics - btnStats.setOnClickListener(v -> { - startActivity(new Intent(MainActivity.this, StatisticsActivity.class)); - }); - - // Sort - btnSort.setOnClickListener(v -> showSortMenu()); - - // Filter - btnFilter.setOnClickListener(v -> showFilterMenu()); - - // Search functionality - etSearch.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, - int count, int after) {} - - @Override - public void onTextChanged(CharSequence s, int start, - int before, int count) { - searchBooks(s.toString()); - } - - @Override - public void afterTextChanged(Editable s) {} - }); - } - - // βœ… Sort Menu - private void showSortMenu() { - String[] options = {"Newest First", "Oldest First", "A-Z", "Z-A"}; - int checkedItem = -1; - - switch (currentSortOrder) { - case "newest": - checkedItem = 0; - break; - case "oldest": - checkedItem = 1; - break; - case "a-z": - checkedItem = 2; - break; - case "z-a": - checkedItem = 3; - break; - } - - new AlertDialog.Builder(this) - .setTitle("Sort By") - .setSingleChoiceItems(options, checkedItem, (dialog, which) -> { - switch (which) { - case 0: - currentSortOrder = "newest"; - break; - case 1: - currentSortOrder = "oldest"; - break; - case 2: - currentSortOrder = "a-z"; - break; - case 3: - currentSortOrder = "z-a"; - break; - } - loadBooks(); - dialog.dismiss(); - }) - .setNegativeButton("Cancel", null) - .show(); - } - - // βœ… Filter Menu - private void showFilterMenu() { - String[] options = {"All Books", "Read Only", "Unread Only"}; - int checkedItem = -1; - - switch (currentFilter) { - case "all": - checkedItem = 0; - break; - case "read": - checkedItem = 1; - break; - case "unread": - checkedItem = 2; - break; - } - - new AlertDialog.Builder(this) - .setTitle("Filter By") - .setSingleChoiceItems(options, checkedItem, (dialog, which) -> { - switch (which) { - case 0: - currentFilter = "all"; - break; - case 1: - currentFilter = "read"; - break; - case 2: - currentFilter = "unread"; - break; - } - loadBooks(); - dialog.dismiss(); - }) - .setNegativeButton("Cancel", null) - .show(); - } - - // βœ… Search function - private void searchBooks(String query) { - if (query.trim().isEmpty()) { - loadBooks(); - } else { - bookList.clear(); - bookList.addAll(dbHelper.searchBooks(query, userId)); - bookAdapter.notifyDataSetChanged(); - updateUI(); - } - } - - // βœ… Logout Dialog - private void showLogoutDialog() { - new AlertDialog.Builder(this) - .setTitle("Logout") - .setMessage("Are you sure you want to logout?") - .setPositiveButton("Yes", (dialog, which) -> logout()) - .setNegativeButton("Cancel", null) - .show(); - } - - // βœ… Logout logic - private void logout() { - SharedPreferences.Editor editor = sharedPreferences.edit(); - editor.clear(); - editor.apply(); - - Toast.makeText(this, "Logged out successfully", Toast.LENGTH_SHORT).show(); - navigateToLogin(); - } - - // βœ… Navigate to Login - private void navigateToLogin() { - Intent intent = new Intent(MainActivity.this, LoginActivity.class); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - startActivity(intent); - finish(); - } - - // βœ… Klik item buku - @Override - public void onBookClick(Book book) { - Intent intent = new Intent(MainActivity.this, AddBookActivity.class); - intent.putExtra("bookId", book.getId()); - intent.putExtra("title", book.getTitle()); - intent.putExtra("author", book.getAuthor()); - intent.putExtra("category", book.getCategory()); - intent.putExtra("year", book.getYear()); - intent.putExtra("isRead", book.isRead()); - intent.putExtra("isEdit", true); - startActivity(intent); - } - - // βœ… Refresh data ketika kembali ke activity ini - @Override - protected void onResume() { - super.onResume(); - loadBooks(); // Refresh data when returning from AddBookActivity - } -} diff --git a/app/src/main/java/com/example/minibooklibrary/MiniBookLibraryApp.kt b/app/src/main/java/com/example/minibooklibrary/MiniBookLibraryApp.kt new file mode 100644 index 0000000..dd4e991 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/MiniBookLibraryApp.kt @@ -0,0 +1,22 @@ +package com.example.minibooklibrary + +import android.app.Application +import androidx.appcompat.app.AppCompatDelegate +import com.example.minibooklibrary.di.ServiceLocator + +/** + * Application entry point. Used to apply the persisted theme mode early so that the + * first activity doesn't flash the wrong palette. + */ +class MiniBookLibraryApp : Application() { + + override fun onCreate() { + super.onCreate() + applyPersistedTheme() + } + + private fun applyPersistedTheme() { + val mode = ServiceLocator.providePreferences(this).themeMode + AppCompatDelegate.setDefaultNightMode(mode) + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/RegisterActivity.java b/app/src/main/java/com/example/minibooklibrary/RegisterActivity.java deleted file mode 100644 index 711bfa3..0000000 --- a/app/src/main/java/com/example/minibooklibrary/RegisterActivity.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.example.minibooklibrary; - -import android.content.Intent; -import android.os.Bundle; -import android.text.TextUtils; -import android.util.Patterns; -import android.widget.CheckBox; -import android.widget.RadioGroup; -import android.widget.TextView; -import android.widget.Toast; -import androidx.appcompat.app.AppCompatActivity; -import com.google.android.material.button.MaterialButton; -import com.google.android.material.textfield.TextInputEditText; - -public class RegisterActivity extends AppCompatActivity { - - private TextInputEditText etUsername, etEmail, etPassword; - private RadioGroup rgGender; - private CheckBox cbNewsletter; - private MaterialButton btnRegister; - private TextView tvLogin; - private DatabaseHelper dbHelper; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_register); - - dbHelper = new DatabaseHelper(this); - initViews(); - setupListeners(); - } - - private void initViews() { - etUsername = findViewById(R.id.etRegUsername); - etEmail = findViewById(R.id.etEmail); - etPassword = findViewById(R.id.etRegPassword); - rgGender = findViewById(R.id.rgGender); - cbNewsletter = findViewById(R.id.cbNewsletter); - btnRegister = findViewById(R.id.btnRegister); - tvLogin = findViewById(R.id.tvLogin); - } - - private void setupListeners() { - btnRegister.setOnClickListener(v -> registerUser()); - - tvLogin.setOnClickListener(v -> { - finish(); - }); - } - - private void registerUser() { - String username = etUsername.getText().toString().trim(); - String email = etEmail.getText().toString().trim(); - String password = etPassword.getText().toString().trim(); - boolean newsletter = cbNewsletter.isChecked(); - - // Validation - if (TextUtils.isEmpty(username)) { - etUsername.setError("Username required"); - return; - } - - if (username.length() < 3) { - etUsername.setError("Username must be at least 3 characters"); - return; - } - - if (TextUtils.isEmpty(email)) { - etEmail.setError("Email required"); - return; - } - - if (!Patterns.EMAIL_ADDRESS.matcher(email).matches()) { - etEmail.setError("Invalid email format"); - return; - } - - if (TextUtils.isEmpty(password)) { - etPassword.setError("Password required"); - return; - } - - if (password.length() < 6) { - etPassword.setError("Password must be at least 6 characters"); - return; - } - - if (rgGender.getCheckedRadioButtonId() == -1) { - Toast.makeText(this, "Please select gender", Toast.LENGTH_SHORT).show(); - return; - } - - // Register user - boolean success = dbHelper.registerUser(username, password); - - if (success) { - Toast.makeText(this, "Registration successful! Please login", Toast.LENGTH_SHORT).show(); - finish(); - } else { - Toast.makeText(this, "Username already exists", Toast.LENGTH_SHORT).show(); - } - } -} diff --git a/app/src/main/java/com/example/minibooklibrary/SettingsActivity.java b/app/src/main/java/com/example/minibooklibrary/SettingsActivity.java deleted file mode 100644 index 92ecd0c..0000000 --- a/app/src/main/java/com/example/minibooklibrary/SettingsActivity.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.example.minibooklibrary; - -import android.content.SharedPreferences; -import android.os.Bundle; -import android.widget.ImageButton; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.app.AppCompatDelegate; -import com.google.android.material.switchmaterial.SwitchMaterial; - -public class SettingsActivity extends AppCompatActivity { - - private SwitchMaterial switchDarkMode; - private ImageButton btnBack; - private SharedPreferences sharedPreferences; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_settings); - - sharedPreferences = getSharedPreferences("BookLibraryPrefs", MODE_PRIVATE); - - switchDarkMode = findViewById(R.id.switchDarkMode); - btnBack = findViewById(R.id.btnBack); - - // Load saved dark mode state - boolean isDarkMode = sharedPreferences.getBoolean("isDarkMode", false); - switchDarkMode.setChecked(isDarkMode); - - switchDarkMode.setOnCheckedChangeListener((buttonView, isChecked) -> { - SharedPreferences.Editor editor = sharedPreferences.edit(); - editor.putBoolean("isDarkMode", isChecked); - editor.apply(); - - if (isChecked) { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); - } else { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); - } - }); - - btnBack.setOnClickListener(v -> finish()); - } -} diff --git a/app/src/main/java/com/example/minibooklibrary/StatisticsActivity.java b/app/src/main/java/com/example/minibooklibrary/StatisticsActivity.java deleted file mode 100644 index e72b133..0000000 --- a/app/src/main/java/com/example/minibooklibrary/StatisticsActivity.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.example.minibooklibrary; - -import android.content.SharedPreferences; -import android.os.Bundle; -import android.widget.ImageButton; -import android.widget.TextView; -import androidx.appcompat.app.AppCompatActivity; -import java.util.ArrayList; -import java.util.HashMap; - -public class StatisticsActivity extends AppCompatActivity { - - private DatabaseHelper dbHelper; - private SharedPreferences sharedPreferences; - private int userId; - - private TextView tvTotalBooks, tvReadBooks, tvUnreadBooks; - private TextView tvFictionCount, tvNonFictionCount, tvScienceCount; - private TextView tvTechnologyCount, tvHistoryCount, tvBiographyCount; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_statistics); - - dbHelper = new DatabaseHelper(this); - sharedPreferences = getSharedPreferences("BookLibraryPrefs", MODE_PRIVATE); - userId = sharedPreferences.getInt("userId", -1); - - initViews(); - loadStatistics(); - - ImageButton btnBack = findViewById(R.id.btnBack); - btnBack.setOnClickListener(v -> finish()); - } - - private void initViews() { - tvTotalBooks = findViewById(R.id.tvTotalBooks); - tvReadBooks = findViewById(R.id.tvReadBooks); - tvUnreadBooks = findViewById(R.id.tvUnreadBooks); - tvFictionCount = findViewById(R.id.tvFictionCount); - tvNonFictionCount = findViewById(R.id.tvNonFictionCount); - tvScienceCount = findViewById(R.id.tvScienceCount); - tvTechnologyCount = findViewById(R.id.tvTechnologyCount); - tvHistoryCount = findViewById(R.id.tvHistoryCount); - tvBiographyCount = findViewById(R.id.tvBiographyCount); - } - - private void loadStatistics() { - ArrayList allBooks = dbHelper.getAllBooks(userId); - - int total = allBooks.size(); - int read = 0; - HashMap categoryCount = new HashMap<>(); - - for (Book book : allBooks) { - if (book.isRead()) read++; - - String category = book.getCategory(); - categoryCount.put(category, categoryCount.getOrDefault(category, 0) + 1); - } - - int unread = total - read; - - // Update UI - tvTotalBooks.setText(String.valueOf(total)); - tvReadBooks.setText(String.valueOf(read)); - tvUnreadBooks.setText(String.valueOf(unread)); - - tvFictionCount.setText(String.valueOf(categoryCount.getOrDefault("Fiction", 0))); - tvNonFictionCount.setText(String.valueOf(categoryCount.getOrDefault("Non-Fiction", 0))); - tvScienceCount.setText(String.valueOf(categoryCount.getOrDefault("Science", 0))); - tvTechnologyCount.setText(String.valueOf(categoryCount.getOrDefault("Technology", 0))); - tvHistoryCount.setText(String.valueOf(categoryCount.getOrDefault("History", 0))); - tvBiographyCount.setText(String.valueOf(categoryCount.getOrDefault("Biography", 0))); - } -} diff --git a/app/src/main/java/com/example/minibooklibrary/data/local/BookDatabase.kt b/app/src/main/java/com/example/minibooklibrary/data/local/BookDatabase.kt new file mode 100644 index 0000000..00cca82 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/data/local/BookDatabase.kt @@ -0,0 +1,54 @@ +package com.example.minibooklibrary.data.local + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import com.example.minibooklibrary.data.local.dao.BookDao +import com.example.minibooklibrary.data.local.dao.UserDao +import com.example.minibooklibrary.data.local.entity.BookEntity +import com.example.minibooklibrary.data.local.entity.UserEntity + +/** + * Room database for the app. Singleton-managed via [getInstance]. + * + * Schema migrations: we ship as v1. The legacy SQLite DB ([DATABASE_LEGACY_NAME]) is + * abandoned rather than migrated β€” it lived in a different namespace, and the v2 schema + * has too many shape changes for an ALTER TABLE migration to be a clean win. Users + * upgrading will see an empty library; the Settings screen offers Backup/Restore for + * anyone who needs to bring data forward. + */ +@Database( + entities = [UserEntity::class, BookEntity::class], + version = 1, + exportSchema = true +) +abstract class BookDatabase : RoomDatabase() { + + abstract fun userDao(): UserDao + abstract fun bookDao(): BookDao + + companion object { + const val DATABASE_NAME = "mini_book_library.db" + + @Suppress("unused") + const val DATABASE_LEGACY_NAME = "BookLibrary.db" + + @Volatile + private var INSTANCE: BookDatabase? = null + + fun getInstance(context: Context): BookDatabase = + INSTANCE ?: synchronized(this) { + INSTANCE ?: build(context).also { INSTANCE = it } + } + + private fun build(context: Context): BookDatabase = + Room.databaseBuilder( + context.applicationContext, + BookDatabase::class.java, + DATABASE_NAME + ) + .fallbackToDestructiveMigration() + .build() + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/data/local/dao/BookDao.kt b/app/src/main/java/com/example/minibooklibrary/data/local/dao/BookDao.kt new file mode 100644 index 0000000..58f33b4 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/data/local/dao/BookDao.kt @@ -0,0 +1,50 @@ +package com.example.minibooklibrary.data.local.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.example.minibooklibrary.data.local.entity.BookEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface BookDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(book: BookEntity): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(books: List) + + @Update + suspend fun update(book: BookEntity): Int + + @Delete + suspend fun delete(book: BookEntity): Int + + @Query("DELETE FROM books WHERE id = :id") + suspend fun deleteById(id: Long): Int + + @Query("DELETE FROM books WHERE userId = :userId") + suspend fun deleteAllForUser(userId: Long): Int + + @Query("SELECT * FROM books WHERE id = :id LIMIT 1") + suspend fun findById(id: Long): BookEntity? + + @Query("SELECT * FROM books WHERE userId = :userId ORDER BY dateAdded DESC") + fun observeAllForUser(userId: Long): Flow> + + @Query("SELECT * FROM books WHERE userId = :userId ORDER BY dateAdded DESC") + suspend fun getAllForUser(userId: Long): List + + @Query("SELECT * FROM books WHERE userId = :userId ORDER BY dateAdded DESC LIMIT :limit") + fun observeRecentForUser(userId: Long, limit: Int): Flow> + + @Query("SELECT COUNT(*) FROM books WHERE userId = :userId") + fun observeBookCount(userId: Long): Flow + + @Query("SELECT COUNT(*) FROM books WHERE userId = :userId AND readingStatus = :status") + fun observeStatusCount(userId: Long, status: String): Flow +} diff --git a/app/src/main/java/com/example/minibooklibrary/data/local/dao/UserDao.kt b/app/src/main/java/com/example/minibooklibrary/data/local/dao/UserDao.kt new file mode 100644 index 0000000..2b77b07 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/data/local/dao/UserDao.kt @@ -0,0 +1,23 @@ +package com.example.minibooklibrary.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.example.minibooklibrary.data.local.entity.UserEntity + +@Dao +interface UserDao { + + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insert(user: UserEntity): Long + + @Query("SELECT * FROM users WHERE username = :username LIMIT 1") + suspend fun findByUsername(username: String): UserEntity? + + @Query("SELECT * FROM users WHERE id = :id LIMIT 1") + suspend fun findById(id: Long): UserEntity? + + @Query("SELECT COUNT(*) FROM users WHERE username = :username") + suspend fun countByUsername(username: String): Int +} diff --git a/app/src/main/java/com/example/minibooklibrary/data/local/entity/BookEntity.kt b/app/src/main/java/com/example/minibooklibrary/data/local/entity/BookEntity.kt new file mode 100644 index 0000000..e8953fa --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/data/local/entity/BookEntity.kt @@ -0,0 +1,46 @@ +package com.example.minibooklibrary.data.local.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import com.example.minibooklibrary.domain.ReadingStatus + +/** + * A single book in a user's personal library. + * + * [coverImageUri] is a string-encoded `content://` or `file://` URI managed by + * [com.example.minibooklibrary.util.ImageStorage] β€” the actual file lives in app-private + * internal storage so it survives backup/restore cleanly. + * + * [readingStatus] is stored as a string ([ReadingStatus.storageValue]) rather than the + * enum ordinal β€” see the docs on [ReadingStatus] for why. + */ +@Entity( + tableName = "books", + foreignKeys = [ + ForeignKey( + entity = UserEntity::class, + parentColumns = ["id"], + childColumns = ["userId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index("userId")] +) +data class BookEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0L, + val userId: Long, + val title: String, + val author: String, + val category: String, + val year: Int, + val readingStatus: String = ReadingStatus.WANT_TO_READ.storageValue, + val rating: Float = 0f, + val coverImageUri: String? = null, + val currentPage: Int = 0, + val totalPages: Int = 0, + val notes: String = "", + val dateAdded: Long = System.currentTimeMillis(), + val lastModified: Long = System.currentTimeMillis() +) diff --git a/app/src/main/java/com/example/minibooklibrary/data/local/entity/UserEntity.kt b/app/src/main/java/com/example/minibooklibrary/data/local/entity/UserEntity.kt new file mode 100644 index 0000000..5f7657c --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/data/local/entity/UserEntity.kt @@ -0,0 +1,26 @@ +package com.example.minibooklibrary.data.local.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Persisted user account. + * + * Passwords are stored as a SHA-256 hash with a per-record salt β€” see + * [com.example.minibooklibrary.util.PasswordHasher]. This is far from production-grade + * (a memory-hard KDF like Argon2 / bcrypt would be appropriate) but it removes the obvious + * footgun of storing plaintext while keeping the dependency footprint small. + */ +@Entity( + tableName = "users", + indices = [Index(value = ["username"], unique = true)] +) +data class UserEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0L, + val username: String, + val email: String, + val passwordHash: String, + val passwordSalt: String, + val createdAt: Long = System.currentTimeMillis() +) diff --git a/app/src/main/java/com/example/minibooklibrary/data/preferences/PreferencesManager.kt b/app/src/main/java/com/example/minibooklibrary/data/preferences/PreferencesManager.kt new file mode 100644 index 0000000..b0003f7 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/data/preferences/PreferencesManager.kt @@ -0,0 +1,83 @@ +package com.example.minibooklibrary.data.preferences + +import android.content.Context +import android.content.SharedPreferences +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.edit +import com.example.minibooklibrary.domain.SortOrder +import com.example.minibooklibrary.domain.StatusFilter + +/** + * Strongly-typed wrapper around [SharedPreferences]. + * + * Reasons to keep the wrapper instead of touching SharedPreferences directly: + * - all key strings live in one place (so a typo is a compile error, not a silent miss), + * - migrations across versions can happen here without leaking into UI code, + * - app-wide settings like theme and last sort order share the same backing file. + */ +class PreferencesManager(context: Context) { + + private val prefs: SharedPreferences = + context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + /** Whether the user finished onboarding (false = first launch). */ + var onboardingCompleted: Boolean + get() = prefs.getBoolean(KEY_ONBOARDING_DONE, false) + set(value) = prefs.edit { putBoolean(KEY_ONBOARDING_DONE, value) } + + /** Whether a user is currently signed in. */ + val isLoggedIn: Boolean + get() = currentUserId > 0 + + /** Currently signed-in user's id, or 0 when nobody is signed in. */ + var currentUserId: Long + get() = prefs.getLong(KEY_USER_ID, 0L) + set(value) = prefs.edit { putLong(KEY_USER_ID, value) } + + var currentUsername: String? + get() = prefs.getString(KEY_USERNAME, null) + set(value) = prefs.edit { putString(KEY_USERNAME, value) } + + /** + * App theme β€” one of [AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM], + * [AppCompatDelegate.MODE_NIGHT_NO], [AppCompatDelegate.MODE_NIGHT_YES]. + */ + var themeMode: Int + get() = prefs.getInt(KEY_THEME_MODE, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + set(value) = prefs.edit { putInt(KEY_THEME_MODE, value) } + + var lastSortOrder: SortOrder + get() = SortOrder.fromStorageValue(prefs.getString(KEY_SORT_ORDER, null)) + set(value) = prefs.edit { putString(KEY_SORT_ORDER, value.storageValue) } + + var lastStatusFilter: StatusFilter + get() = StatusFilter.fromStorageValue(prefs.getString(KEY_STATUS_FILTER, null)) + set(value) = prefs.edit { putString(KEY_STATUS_FILTER, value.storageValue) } + + var lastCategoryFilter: String? + get() = prefs.getString(KEY_CATEGORY_FILTER, null) + set(value) = prefs.edit { putString(KEY_CATEGORY_FILTER, value) } + + /** + * Clear session-scoped state on logout but preserve onboarding/theme β€” the user + * shouldn't have to re-do those after signing out. + */ + fun clearSession() { + prefs.edit { + remove(KEY_USER_ID) + remove(KEY_USERNAME) + } + } + + companion object { + private const val PREFS_NAME = "mini_book_library_prefs" + + private const val KEY_ONBOARDING_DONE = "onboarding_completed" + private const val KEY_USER_ID = "current_user_id" + private const val KEY_USERNAME = "current_username" + private const val KEY_THEME_MODE = "theme_mode" + private const val KEY_SORT_ORDER = "sort_order" + private const val KEY_STATUS_FILTER = "status_filter" + private const val KEY_CATEGORY_FILTER = "category_filter" + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/data/remote/GoogleBooksService.kt b/app/src/main/java/com/example/minibooklibrary/data/remote/GoogleBooksService.kt new file mode 100644 index 0000000..6a44b92 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/data/remote/GoogleBooksService.kt @@ -0,0 +1,122 @@ +package com.example.minibooklibrary.data.remote + +import android.util.Log +import com.example.minibooklibrary.domain.BookLookup +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder + +/** + * Tiny client for the public Google Books volumes endpoint. + * + * Why we hand-roll HTTP instead of pulling OkHttp: + * - One endpoint, one JSON response β€” adding a 800KB networking lib is overkill. + * - `HttpURLConnection` ships with the platform, so the APK stays the same size. + * - Parsing uses [org.json] which we already pull in for backups. + * + * The endpoint is unauthenticated for low-volume reads, which is what a personal + * library does. Add an API key via the `key=` param if you start seeing 429s. + */ +class GoogleBooksService( + private val httpClient: HttpClient = DefaultHttpClient, + private val parser: GoogleBooksParser = GoogleBooksParser +) { + + /** + * Look up a book by ISBN-10 or ISBN-13. Returns null on any network/parse failure β€” + * this feature is best-effort and the user can always type fields manually. + */ + suspend fun lookupByIsbn(isbn: String): BookLookup? = withContext(Dispatchers.IO) { + val cleaned = isbn.filter { it.isDigit() || it == 'X' || it == 'x' } + if (cleaned.length !in 10..13) return@withContext null + + val url = "https://www.googleapis.com/books/v1/volumes?q=isbn:" + + URLEncoder.encode(cleaned, "UTF-8") + + "&maxResults=1&printType=books" + + // Cap at 8s so a flaky connection doesn't hang the UI thread that's awaiting us. + val body = withTimeoutOrNull(8_000L) { httpClient.get(url) } ?: return@withContext null + runCatching { parser.parseFirstVolume(body) } + .onFailure { Log.w(TAG, "Failed to parse Google Books response", it) } + .getOrNull() + } + + companion object { + private const val TAG = "GoogleBooksService" + } +} + +/** Abstraction so tests can stub HTTP without hitting the network. */ +fun interface HttpClient { + fun get(url: String): String? +} + +private object DefaultHttpClient : HttpClient { + override fun get(url: String): String? { + var connection: HttpURLConnection? = null + return try { + connection = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + connectTimeout = 5_000 + readTimeout = 5_000 + setRequestProperty("Accept", "application/json") + } + if (connection.responseCode !in 200..299) return null + connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + } catch (t: Throwable) { + null + } finally { + connection?.disconnect() + } + } +} + +/** + * Pure JSON β†’ [BookLookup] parser, separated from networking so it can be exhaustively + * unit-tested with sample fixtures. + */ +object GoogleBooksParser { + + fun parseFirstVolume(rawJson: String): BookLookup? { + val root = JSONObject(rawJson) + val items = root.optJSONArray("items") ?: return null + if (items.length() == 0) return null + + val volumeInfo = items.getJSONObject(0).optJSONObject("volumeInfo") ?: return null + + val title = volumeInfo.optString("title").takeIf { it.isNotBlank() } ?: return null + val authors = volumeInfo.optJSONArray("authors") + val firstAuthor = if (authors != null && authors.length() > 0) authors.getString(0) else null + val category = volumeInfo.optJSONArray("categories") + ?.takeIf { it.length() > 0 } + ?.getString(0) + ?.substringBefore(" / ") // Google returns nested taxonomy like "Fiction / Romance" + + val publishedDate = volumeInfo.optString("publishedDate") + val year = parsePublishedYear(publishedDate) + + val pageCount = volumeInfo.optInt("pageCount", 0).takeIf { it > 0 } + + return BookLookup( + title = title, + author = firstAuthor.orEmpty(), + category = category.orEmpty(), + year = year, + totalPages = pageCount ?: 0 + ) + } + + /** + * Google returns dates in any of `YYYY`, `YYYY-MM`, or `YYYY-MM-DD`. We only care + * about the year β€” extract first 4 digits and accept it if it's plausible. + */ + internal fun parsePublishedYear(input: String?): Int { + if (input.isNullOrBlank()) return 0 + val candidate = input.take(4).toIntOrNull() ?: return 0 + return if (candidate in 1000..2100) candidate else 0 + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/data/repository/AuthResult.kt b/app/src/main/java/com/example/minibooklibrary/data/repository/AuthResult.kt new file mode 100644 index 0000000..2ed13fe --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/data/repository/AuthResult.kt @@ -0,0 +1,17 @@ +package com.example.minibooklibrary.data.repository + +/** + * Result type for authentication operations. Modeled as a sealed class so the UI can + * branch on outcome without depending on exception types. + */ +sealed class AuthResult { + data class Success(val userId: Long, val username: String) : AuthResult() + data class Failure(val reason: FailureReason, val message: String) : AuthResult() + + enum class FailureReason { + USERNAME_TAKEN, + INVALID_CREDENTIALS, + VALIDATION, + UNKNOWN + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/data/repository/BookRepository.kt b/app/src/main/java/com/example/minibooklibrary/data/repository/BookRepository.kt new file mode 100644 index 0000000..1cd4029 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/data/repository/BookRepository.kt @@ -0,0 +1,67 @@ +package com.example.minibooklibrary.data.repository + +import com.example.minibooklibrary.data.local.dao.BookDao +import com.example.minibooklibrary.data.local.entity.BookEntity +import com.example.minibooklibrary.domain.ReadingStatus +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +/** + * The single point of entry for book persistence operations used by the ViewModels. + * + * The repository keeps reactive reads as cold [Flow]s emitted from Room and pushes + * blocking writes onto [Dispatchers.IO]. Filtering, searching, and sorting happens in + * the ViewModel layer so the SQL stays simple and Flow combinators stay testable. + */ +class BookRepository(private val bookDao: BookDao) { + + /** Reactive list of all books for [userId], sorted newest-first by `dateAdded`. */ + fun observeBooks(userId: Long): Flow> = + bookDao.observeAllForUser(userId) + + /** Reactive total count, used by the dashboard tile. */ + fun observeBookCount(userId: Long): Flow = bookDao.observeBookCount(userId) + + /** Reactive count of books in [status], used for dashboard progress bars. */ + fun observeStatusCount(userId: Long, status: ReadingStatus): Flow = + bookDao.observeStatusCount(userId, status.storageValue) + + /** A small slice of recent books, used by the dashboard's "Recently added" rail. */ + fun observeRecentBooks(userId: Long, limit: Int = 5): Flow> = + bookDao.observeRecentForUser(userId, limit) + + /** One-shot list, useful for PDF/JSON export which doesn't need live updates. */ + suspend fun getBooks(userId: Long): List = withContext(Dispatchers.IO) { + bookDao.getAllForUser(userId) + } + + suspend fun findById(id: Long): BookEntity? = withContext(Dispatchers.IO) { + bookDao.findById(id) + } + + suspend fun add(book: BookEntity): Long = withContext(Dispatchers.IO) { + val now = System.currentTimeMillis() + bookDao.insert(book.copy(dateAdded = now, lastModified = now)) + } + + suspend fun addAll(books: List) = withContext(Dispatchers.IO) { + bookDao.insertAll(books) + } + + suspend fun update(book: BookEntity): Int = withContext(Dispatchers.IO) { + bookDao.update(book.copy(lastModified = System.currentTimeMillis())) + } + + suspend fun delete(book: BookEntity): Int = withContext(Dispatchers.IO) { + bookDao.delete(book) + } + + suspend fun deleteById(id: Long): Int = withContext(Dispatchers.IO) { + bookDao.deleteById(id) + } + + suspend fun deleteAllForUser(userId: Long): Int = withContext(Dispatchers.IO) { + bookDao.deleteAllForUser(userId) + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/data/repository/UserRepository.kt b/app/src/main/java/com/example/minibooklibrary/data/repository/UserRepository.kt new file mode 100644 index 0000000..7db4b18 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/data/repository/UserRepository.kt @@ -0,0 +1,107 @@ +package com.example.minibooklibrary.data.repository + +import com.example.minibooklibrary.data.local.dao.UserDao +import com.example.minibooklibrary.data.local.entity.UserEntity +import com.example.minibooklibrary.data.preferences.PreferencesManager +import com.example.minibooklibrary.util.PasswordHasher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Coordinates user account creation and sign-in. + * + * The repository owns the password-hashing detail so that ViewModels never see plaintext + * passwords flow through them β€” they pass strings in and get an [AuthResult] back. + */ +class UserRepository( + private val userDao: UserDao, + private val preferences: PreferencesManager +) { + + /** Register a new user. Returns [AuthResult.Success] on success, otherwise a typed failure. */ + suspend fun register(username: String, email: String, password: String): AuthResult = + withContext(Dispatchers.IO) { + val cleanedUsername = username.trim() + val cleanedEmail = email.trim() + + when (val validation = validate(cleanedUsername, cleanedEmail, password)) { + is ValidationResult.Invalid -> return@withContext AuthResult.Failure( + AuthResult.FailureReason.VALIDATION, + validation.message + ) + ValidationResult.Valid -> Unit + } + + if (userDao.countByUsername(cleanedUsername) > 0) { + return@withContext AuthResult.Failure( + AuthResult.FailureReason.USERNAME_TAKEN, + "That username is already taken" + ) + } + + val salt = PasswordHasher.newSalt() + val hash = PasswordHasher.hash(password, salt) + val newId = userDao.insert( + UserEntity( + username = cleanedUsername, + email = cleanedEmail, + passwordHash = hash, + passwordSalt = salt + ) + ) + AuthResult.Success(newId, cleanedUsername) + } + + /** Verify credentials and (on success) persist the session via [PreferencesManager]. */ + suspend fun login(username: String, password: String): AuthResult = + withContext(Dispatchers.IO) { + val cleaned = username.trim() + if (cleaned.isEmpty() || password.isEmpty()) { + return@withContext AuthResult.Failure( + AuthResult.FailureReason.VALIDATION, + "Username and password are required" + ) + } + val user = userDao.findByUsername(cleaned) + ?: return@withContext AuthResult.Failure( + AuthResult.FailureReason.INVALID_CREDENTIALS, + "Invalid username or password" + ) + val ok = PasswordHasher.verify(password, user.passwordSalt, user.passwordHash) + if (!ok) { + AuthResult.Failure( + AuthResult.FailureReason.INVALID_CREDENTIALS, + "Invalid username or password" + ) + } else { + preferences.currentUserId = user.id + preferences.currentUsername = user.username + AuthResult.Success(user.id, user.username) + } + } + + /** Drop session info β€” does not delete the account, just signs out. */ + fun logout() { + preferences.clearSession() + } + + /** Convenience used by the dashboard to greet the user. */ + suspend fun currentUser(): UserEntity? = withContext(Dispatchers.IO) { + val id = preferences.currentUserId + if (id <= 0L) null else userDao.findById(id) + } + + private sealed class ValidationResult { + data object Valid : ValidationResult() + data class Invalid(val message: String) : ValidationResult() + } + + private fun validate(username: String, email: String, password: String): ValidationResult { + if (username.length < 3) return ValidationResult.Invalid("Username must be at least 3 characters") + if (email.isEmpty() || !android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()) { + return ValidationResult.Invalid("Please enter a valid email address") + } + if (password.length < 6) return ValidationResult.Invalid("Password must be at least 6 characters") + return ValidationResult.Valid + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/di/ServiceLocator.kt b/app/src/main/java/com/example/minibooklibrary/di/ServiceLocator.kt new file mode 100644 index 0000000..dec9735 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/di/ServiceLocator.kt @@ -0,0 +1,78 @@ +package com.example.minibooklibrary.di + +import android.content.Context +import com.example.minibooklibrary.data.local.BookDatabase +import com.example.minibooklibrary.data.preferences.PreferencesManager +import com.example.minibooklibrary.data.remote.GoogleBooksService +import com.example.minibooklibrary.data.repository.BookRepository +import com.example.minibooklibrary.data.repository.UserRepository + +/** + * Tiny hand-rolled DI container. + * + * Hilt would be a more conventional choice but is overkill for a single-module app β€” and + * showing a clear, pluggable container in source is more interview-friendly than KAPT + * generating it for us. All instances are lazy and process-singletons. + */ +object ServiceLocator { + + @Volatile + private var preferences: PreferencesManager? = null + + @Volatile + private var database: BookDatabase? = null + + @Volatile + private var userRepository: UserRepository? = null + + @Volatile + private var bookRepository: BookRepository? = null + + @Volatile + private var googleBooksService: GoogleBooksService? = null + + fun providePreferences(context: Context): PreferencesManager = + preferences ?: synchronized(this) { + preferences ?: PreferencesManager(context.applicationContext).also { preferences = it } + } + + fun provideDatabase(context: Context): BookDatabase = + database ?: synchronized(this) { + database ?: BookDatabase.getInstance(context.applicationContext).also { database = it } + } + + fun provideUserRepository(context: Context): UserRepository = + userRepository ?: synchronized(this) { + userRepository ?: UserRepository( + userDao = provideDatabase(context).userDao(), + preferences = providePreferences(context) + ).also { userRepository = it } + } + + fun provideBookRepository(context: Context): BookRepository = + bookRepository ?: synchronized(this) { + bookRepository ?: BookRepository(provideDatabase(context).bookDao()) + .also { bookRepository = it } + } + + fun provideGoogleBooksService(): GoogleBooksService = + googleBooksService ?: synchronized(this) { + googleBooksService ?: GoogleBooksService().also { googleBooksService = it } + } + + /** Test-only escape hatch: replace dependencies with fakes/mocks then reset between cases. */ + fun resetForTests( + preferences: PreferencesManager? = null, + userRepository: UserRepository? = null, + bookRepository: BookRepository? = null, + googleBooksService: GoogleBooksService? = null + ) { + synchronized(this) { + this.preferences = preferences + this.userRepository = userRepository + this.bookRepository = bookRepository + this.googleBooksService = googleBooksService + this.database = null + } + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/domain/BookLookup.kt b/app/src/main/java/com/example/minibooklibrary/domain/BookLookup.kt new file mode 100644 index 0000000..71a434e --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/domain/BookLookup.kt @@ -0,0 +1,14 @@ +package com.example.minibooklibrary.domain + +/** + * Result of a successful ISBN lookup. Field-for-field matches the editable parts of the + * Add/Edit form so the ViewModel can splat it onto [com.example.minibooklibrary.ui.books.edit.BookForm] + * with a single `copy(...)`. + */ +data class BookLookup( + val title: String, + val author: String, + val category: String, + val year: Int, + val totalPages: Int +) diff --git a/app/src/main/java/com/example/minibooklibrary/domain/ReadingStatus.kt b/app/src/main/java/com/example/minibooklibrary/domain/ReadingStatus.kt new file mode 100644 index 0000000..3cb8a04 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/domain/ReadingStatus.kt @@ -0,0 +1,19 @@ +package com.example.minibooklibrary.domain + +/** + * Reading status of a book in the user's library. + * + * The [storageValue] is what's persisted in Room β€” kept as a stable string so that + * a future enum reordering doesn't break existing databases or JSON backups. + */ +enum class ReadingStatus(val storageValue: String, val displayLabel: String) { + WANT_TO_READ("want_to_read", "Want to Read"), + CURRENTLY_READING("currently_reading", "Currently Reading"), + FINISHED("finished", "Finished"); + + companion object { + /** Resolve a status from its persisted [storageValue], defaulting to [WANT_TO_READ]. */ + fun fromStorageValue(value: String?): ReadingStatus = + entries.firstOrNull { it.storageValue == value } ?: WANT_TO_READ + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/domain/SortOrder.kt b/app/src/main/java/com/example/minibooklibrary/domain/SortOrder.kt new file mode 100644 index 0000000..236dc7c --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/domain/SortOrder.kt @@ -0,0 +1,16 @@ +package com.example.minibooklibrary.domain + +/** Sort orders the user can apply to the book list. */ +enum class SortOrder(val storageValue: String, val displayLabel: String) { + DATE_ADDED_DESC("date_desc", "Newest First"), + DATE_ADDED_ASC("date_asc", "Oldest First"), + TITLE_ASC("title_asc", "Title (A-Z)"), + TITLE_DESC("title_desc", "Title (Z-A)"), + RATING_DESC("rating_desc", "Rating (High-Low)"), + RATING_ASC("rating_asc", "Rating (Low-High)"); + + companion object { + fun fromStorageValue(value: String?): SortOrder = + entries.firstOrNull { it.storageValue == value } ?: DATE_ADDED_DESC + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/domain/StatusFilter.kt b/app/src/main/java/com/example/minibooklibrary/domain/StatusFilter.kt new file mode 100644 index 0000000..f18e75c --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/domain/StatusFilter.kt @@ -0,0 +1,18 @@ +package com.example.minibooklibrary.domain + +/** + * Filter options applied on top of search/sort to scope the book list by reading status. + * + * [ALL] is the no-op identity filter β€” it returns every book regardless of status. + */ +enum class StatusFilter(val storageValue: String, val displayLabel: String) { + ALL("all", "All Books"), + WANT_TO_READ("want_to_read", "Want to Read"), + CURRENTLY_READING("currently_reading", "Currently Reading"), + FINISHED("finished", "Finished"); + + companion object { + fun fromStorageValue(value: String?): StatusFilter = + entries.firstOrNull { it.storageValue == value } ?: ALL + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/ui/RouterActivity.kt b/app/src/main/java/com/example/minibooklibrary/ui/RouterActivity.kt new file mode 100644 index 0000000..1c9ed09 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/ui/RouterActivity.kt @@ -0,0 +1,32 @@ +package com.example.minibooklibrary.ui + +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.example.minibooklibrary.di.ServiceLocator +import com.example.minibooklibrary.ui.auth.AuthActivity +import com.example.minibooklibrary.ui.main.MainActivity +import com.example.minibooklibrary.ui.onboarding.OnboardingActivity + +/** + * Front-door activity that decides whether the user should land on Onboarding, Auth, or + * straight into the Main app shell based on persisted state. + * + * Kept separate from the splash screen so that the launcher icon flow is one + * discoverable place β€” much easier to reason about than branching in `onCreate` of a + * conditional first screen. + */ +class RouterActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val prefs = ServiceLocator.providePreferences(this) + val target = when { + !prefs.onboardingCompleted -> OnboardingActivity::class.java + !prefs.isLoggedIn -> AuthActivity::class.java + else -> MainActivity::class.java + } + startActivity(Intent(this, target)) + finish() + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/ui/auth/AuthActivity.kt b/app/src/main/java/com/example/minibooklibrary/ui/auth/AuthActivity.kt new file mode 100644 index 0000000..896cfe7 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/ui/auth/AuthActivity.kt @@ -0,0 +1,43 @@ +package com.example.minibooklibrary.ui.auth + +import android.content.Intent +import android.os.Bundle +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.example.minibooklibrary.databinding.ActivityAuthBinding +import com.example.minibooklibrary.ui.common.ViewModelFactory +import com.example.minibooklibrary.ui.main.MainActivity +import kotlinx.coroutines.launch + +/** + * Hosts the login + register Navigation graph. Owns the [AuthViewModel] so both + * fragments share its state β€” the form is a single workflow split across two destinations. + */ +class AuthActivity : AppCompatActivity() { + + val viewModel: AuthViewModel by viewModels { ViewModelFactory(this) } + + private lateinit var binding: ActivityAuthBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityAuthBinding.inflate(layoutInflater) + setContentView(binding.root) + + // Listen for session-level events: a successful login navigates to MainActivity + // and tears down the auth back-stack so Back doesn't return here. + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.events.collect { event -> + if (event is AuthEvent.LoggedIn) { + startActivity(Intent(this@AuthActivity, MainActivity::class.java)) + finish() + } + } + } + } + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/ui/auth/AuthViewModel.kt b/app/src/main/java/com/example/minibooklibrary/ui/auth/AuthViewModel.kt new file mode 100644 index 0000000..778b351 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/ui/auth/AuthViewModel.kt @@ -0,0 +1,91 @@ +package com.example.minibooklibrary.ui.auth + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.minibooklibrary.data.repository.AuthResult +import com.example.minibooklibrary.data.repository.UserRepository +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * Backs both the login and register screens. Keeping them in one ViewModel is + * intentional β€” they share validation rules and a single "authenticating" loading + * indicator, and switching between the two is a navigation tab on a single screen + * rather than two graphs. + */ +class AuthViewModel(private val userRepository: UserRepository) : ViewModel() { + + private val _uiState = MutableStateFlow(AuthUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + val events: SharedFlow = _events.asSharedFlow() + + fun login(username: String, password: String) { + if (_uiState.value.isLoading) return + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + viewModelScope.launch { + when (val result = userRepository.login(username, password)) { + is AuthResult.Success -> { + _uiState.value = _uiState.value.copy(isLoading = false) + _events.emit(AuthEvent.LoggedIn(result.userId, result.username)) + } + is AuthResult.Failure -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = result.message + ) + } + } + } + } + + fun register(username: String, email: String, password: String, confirmPassword: String) { + if (_uiState.value.isLoading) return + if (password != confirmPassword) { + _uiState.value = _uiState.value.copy(errorMessage = "Passwords don't match") + return + } + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + viewModelScope.launch { + when (val result = userRepository.register(username, email, password)) { + is AuthResult.Success -> { + _uiState.value = _uiState.value.copy(isLoading = false) + _events.emit(AuthEvent.Registered(result.username)) + } + is AuthResult.Failure -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = result.message + ) + } + } + } + } + + fun consumeError() { + _uiState.value = _uiState.value.copy(errorMessage = null) + } + + /** Sign-out β€” clears persisted session and emits a navigation event. */ + fun logout() { + userRepository.logout() + viewModelScope.launch { _events.emit(AuthEvent.LoggedOut) } + } +} + +data class AuthUiState( + val isLoading: Boolean = false, + val errorMessage: String? = null +) + +sealed class AuthEvent { + data class LoggedIn(val userId: Long, val username: String) : AuthEvent() + data class Registered(val username: String) : AuthEvent() + data object LoggedOut : AuthEvent() +} diff --git a/app/src/main/java/com/example/minibooklibrary/ui/auth/LoginFragment.kt b/app/src/main/java/com/example/minibooklibrary/ui/auth/LoginFragment.kt new file mode 100644 index 0000000..ea4328c --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/ui/auth/LoginFragment.kt @@ -0,0 +1,79 @@ +package com.example.minibooklibrary.ui.auth + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import com.example.minibooklibrary.R +import com.example.minibooklibrary.databinding.FragmentLoginBinding +import com.example.minibooklibrary.ui.common.ViewModelFactory +import kotlinx.coroutines.launch + +class LoginFragment : Fragment() { + + private var _binding: FragmentLoginBinding? = null + private val binding get() = _binding!! + + private val viewModel: AuthViewModel by activityViewModels { + ViewModelFactory(requireContext()) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentLoginBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.btnLogin.setOnClickListener { + val username = binding.inputUsername.text?.toString().orEmpty() + val password = binding.inputPassword.text?.toString().orEmpty() + // Clear inline error visuals before re-validating. + binding.tilUsername.error = null + binding.tilPassword.error = null + + if (username.isBlank()) { + binding.tilUsername.error = "Username is required" + return@setOnClickListener + } + if (password.isBlank()) { + binding.tilPassword.error = "Password is required" + return@setOnClickListener + } + viewModel.login(username, password) + } + + binding.btnGoRegister.setOnClickListener { + findNavController().navigate(R.id.action_login_to_register) + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect { state -> + binding.btnLogin.isEnabled = !state.isLoading + binding.progress.visibility = if (state.isLoading) View.VISIBLE else View.GONE + state.errorMessage?.let { error -> + binding.tilPassword.error = error + viewModel.consumeError() + } + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/ui/auth/RegisterFragment.kt b/app/src/main/java/com/example/minibooklibrary/ui/auth/RegisterFragment.kt new file mode 100644 index 0000000..2ff8bf1 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/ui/auth/RegisterFragment.kt @@ -0,0 +1,92 @@ +package com.example.minibooklibrary.ui.auth + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import com.example.minibooklibrary.databinding.FragmentRegisterBinding +import com.example.minibooklibrary.ui.common.ViewModelFactory +import com.example.minibooklibrary.util.toast +import kotlinx.coroutines.launch + +class RegisterFragment : Fragment() { + + private var _binding: FragmentRegisterBinding? = null + private val binding get() = _binding!! + + private val viewModel: AuthViewModel by activityViewModels { + ViewModelFactory(requireContext()) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentRegisterBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.btnRegister.setOnClickListener { + val username = binding.inputUsername.text?.toString().orEmpty() + val email = binding.inputEmail.text?.toString().orEmpty() + val password = binding.inputPassword.text?.toString().orEmpty() + val confirm = binding.inputConfirmPassword.text?.toString().orEmpty() + clearErrors() + + if (username.isBlank()) { binding.tilUsername.error = "Username is required"; return@setOnClickListener } + if (email.isBlank()) { binding.tilEmail.error = "Email is required"; return@setOnClickListener } + if (password.isBlank()) { binding.tilPassword.error = "Password is required"; return@setOnClickListener } + if (confirm.isBlank()) { binding.tilConfirmPassword.error = "Confirm your password"; return@setOnClickListener } + + viewModel.register(username, email, password, confirm) + } + + binding.btnBackToLogin.setOnClickListener { findNavController().popBackStack() } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect { state -> + binding.btnRegister.isEnabled = !state.isLoading + binding.progress.visibility = if (state.isLoading) View.VISIBLE else View.GONE + state.errorMessage?.let { error -> + binding.tilPassword.error = error + viewModel.consumeError() + } + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.events.collect { event -> + if (event is AuthEvent.Registered) { + toast("Welcome, ${event.username} β€” please sign in.") + findNavController().popBackStack() + } + } + } + } + } + + private fun clearErrors() { + binding.tilUsername.error = null + binding.tilEmail.error = null + binding.tilPassword.error = null + binding.tilConfirmPassword.error = null + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/ui/books/detail/BookDetailFragment.kt b/app/src/main/java/com/example/minibooklibrary/ui/books/detail/BookDetailFragment.kt new file mode 100644 index 0000000..59f3559 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/ui/books/detail/BookDetailFragment.kt @@ -0,0 +1,165 @@ +package com.example.minibooklibrary.ui.books.detail + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import coil.load +import com.example.minibooklibrary.R +import com.example.minibooklibrary.data.local.entity.BookEntity +import com.example.minibooklibrary.databinding.FragmentBookDetailBinding +import com.example.minibooklibrary.domain.ReadingStatus +import com.example.minibooklibrary.ui.common.ViewModelFactory +import com.example.minibooklibrary.util.formatDate +import com.example.minibooklibrary.util.show +import com.example.minibooklibrary.util.showIf +import com.example.minibooklibrary.util.toast +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.launch +import java.io.File + +class BookDetailFragment : Fragment() { + + private var _binding: FragmentBookDetailBinding? = null + private val binding get() = _binding!! + + private val viewModel: BookDetailViewModel by viewModels { + ViewModelFactory(requireContext()) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentBookDetailBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val bookId = arguments?.getLong("bookId", 0L) ?: 0L + viewModel.load(bookId) + + binding.btnBack.setOnClickListener { findNavController().popBackStack() } + binding.btnEdit.setOnClickListener { + findNavController().navigate( + R.id.action_detail_to_addEdit, + Bundle().apply { putLong("bookId", bookId) } + ) + } + binding.btnDelete.setOnClickListener { confirmDelete() } + binding.btnShare.setOnClickListener { share() } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.state.collect { state -> + if (state.notFound) { + toast("Book not found") + findNavController().popBackStack() + } else state.book?.let(::render) + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.events.collect { event -> + when (event) { + DetailEvent.Deleted -> { + toast("Book deleted") + findNavController().popBackStack() + } + } + } + } + } + } + + private fun render(book: BookEntity) { + binding.title.text = book.title + binding.author.text = "by ${book.author}" + binding.category.text = book.category + binding.year.text = if (book.year > 0) book.year.toString() else "β€”" + + val status = ReadingStatus.fromStorageValue(book.readingStatus) + binding.statusChip.text = status.displayLabel + + binding.ratingValue.text = if (book.rating > 0f) + String.format("%.1f / 5", book.rating) + else getString(R.string.rating_unrated) + binding.ratingBar.rating = book.rating + + val showProgress = status == ReadingStatus.CURRENTLY_READING && book.totalPages > 0 + binding.progressGroup.showIf(showProgress) + if (showProgress) { + val pct = ((book.currentPage * 100f) / book.totalPages).coerceIn(0f, 100f).toInt() + binding.progressBar.progress = pct + binding.progressLabel.text = getString( + R.string.detail_pages_format, + book.currentPage, + book.totalPages + ) + } + + binding.notes.text = book.notes.ifBlank { getString(R.string.detail_no_notes) } + binding.dateAdded.text = getString(R.string.detail_added_on, book.dateAdded.formatDate()) + + val cover = book.coverImageUri + if (!cover.isNullOrBlank()) { + binding.cover.load(File(cover)) { + crossfade(true) + placeholder(R.drawable.bg_cover_placeholder) + error(R.drawable.bg_cover_placeholder) + } + binding.cover.background = null + binding.coverIcon.visibility = View.GONE + } else { + binding.cover.setImageDrawable(null) + binding.cover.setBackgroundResource(R.drawable.bg_cover_placeholder) + binding.coverIcon.show() + } + + binding.scrollContent.show() + } + + private fun confirmDelete() { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.confirm_delete_title) + .setMessage(R.string.confirm_delete_body) + .setPositiveButton(R.string.action_delete) { _, _ -> viewModel.delete() } + .setNegativeButton(R.string.action_cancel, null) + .show() + } + + private fun share() { + val book = viewModel.state.value.book ?: return + val text = buildString { + append("πŸ“– ${book.title}\n") + append("by ${book.author}\n") + if (book.year > 0) append("Published: ${book.year}\n") + append("Category: ${book.category}\n") + val status = ReadingStatus.fromStorageValue(book.readingStatus) + append("Status: ${status.displayLabel}\n") + if (book.rating > 0f) append("Rating: ${"%.1f".format(book.rating)}/5\n") + if (book.notes.isNotBlank()) append("\nNotes: ${book.notes}") + } + val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(android.content.Intent.EXTRA_TEXT, text) + } + startActivity(android.content.Intent.createChooser(intent, getString(R.string.action_share))) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/ui/books/detail/BookDetailViewModel.kt b/app/src/main/java/com/example/minibooklibrary/ui/books/detail/BookDetailViewModel.kt new file mode 100644 index 0000000..82977e5 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/ui/books/detail/BookDetailViewModel.kt @@ -0,0 +1,58 @@ +package com.example.minibooklibrary.ui.books.detail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.minibooklibrary.data.local.entity.BookEntity +import com.example.minibooklibrary.data.repository.BookRepository +import com.example.minibooklibrary.util.ImageStorage +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class BookDetailViewModel( + private val bookRepository: BookRepository +) : ViewModel() { + + private val _state = MutableStateFlow(BookDetailUiState()) + val state: StateFlow = _state.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + val events: SharedFlow = _events.asSharedFlow() + + fun load(bookId: Long) { + viewModelScope.launch { + val book = bookRepository.findById(bookId) + _state.value = if (book != null) { + BookDetailUiState(book = book) + } else { + BookDetailUiState(notFound = true) + } + } + } + + /** + * Confirm-and-delete handler used by the detail screen. Cleans up the cover image + * file too so we don't leak storage when a book is removed. + */ + fun delete() { + val current = _state.value.book ?: return + viewModelScope.launch { + ImageStorage.deleteCoverImage(current.coverImageUri) + bookRepository.delete(current) + _events.emit(DetailEvent.Deleted) + } + } +} + +data class BookDetailUiState( + val book: BookEntity? = null, + val notFound: Boolean = false +) + +sealed class DetailEvent { + data object Deleted : DetailEvent() +} diff --git a/app/src/main/java/com/example/minibooklibrary/ui/books/edit/AddEditBookFragment.kt b/app/src/main/java/com/example/minibooklibrary/ui/books/edit/AddEditBookFragment.kt new file mode 100644 index 0000000..6f173fd --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/ui/books/edit/AddEditBookFragment.kt @@ -0,0 +1,273 @@ +package com.example.minibooklibrary.ui.books.edit + +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import coil.load +import com.example.minibooklibrary.R +import com.example.minibooklibrary.databinding.FragmentAddEditBookBinding +import com.example.minibooklibrary.domain.ReadingStatus +import com.example.minibooklibrary.ui.common.ViewModelFactory +import com.example.minibooklibrary.util.ImageStorage +import com.example.minibooklibrary.util.show +import com.example.minibooklibrary.util.showIf +import com.example.minibooklibrary.util.toast +import kotlinx.coroutines.launch +import java.io.File + +/** + * Add a new book or edit an existing one. The form's stored state lives in + * [AddEditBookViewModel] so config changes don't blow away half-typed input. + */ +class AddEditBookFragment : Fragment() { + + private var _binding: FragmentAddEditBookBinding? = null + private val binding get() = _binding!! + + private val viewModel: AddEditBookViewModel by viewModels { + ViewModelFactory(requireContext()) + } + + /** + * Photo Picker β€” preferred over `ACTION_GET_CONTENT` because it doesn't require a + * runtime permission on Android 13+ and falls back to the system gallery on older + * versions. We copy the resulting URI's bytes into private storage in [onCoverPicked]. + */ + private val pickCoverLauncher = registerForActivityResult( + ActivityResultContracts.PickVisualMedia() + ) { uri -> + if (uri != null) onCoverPicked(uri) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentAddEditBookBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val bookId = arguments?.getLong("bookId", 0L) ?: 0L + if (bookId > 0L) viewModel.loadForEdit(bookId) + + setupCategoryDropdown() + setupStatusDropdown() + wireForm() + wireButtons() + observeForm() + observeValidation() + observeEvents() + } + + private fun setupCategoryDropdown() { + val categories = resources.getStringArray(R.array.book_categories) + val adapter = ArrayAdapter(requireContext(), R.layout.item_dropdown, categories) + binding.inputCategory.setAdapter(adapter) + binding.inputCategory.setOnItemClickListener { parent, _, position, _ -> + val value = parent.getItemAtPosition(position) as String + viewModel.updateField { copy(category = value) } + } + } + + private fun setupStatusDropdown() { + val statuses = ReadingStatus.entries + val labels = statuses.map { it.displayLabel }.toTypedArray() + val adapter = ArrayAdapter(requireContext(), R.layout.item_dropdown, labels) + binding.inputStatus.setAdapter(adapter) + binding.inputStatus.setOnItemClickListener { _, _, position, _ -> + viewModel.updateField { copy(readingStatus = statuses[position]) } + } + } + + private fun wireForm() { + binding.inputTitle.doAfterChange { viewModel.updateField { copy(title = it) } } + binding.inputAuthor.doAfterChange { viewModel.updateField { copy(author = it) } } + binding.inputYear.doAfterChange { viewModel.updateField { copy(year = it) } } + binding.inputCurrentPage.doAfterChange { viewModel.updateField { copy(currentPage = it) } } + binding.inputTotalPages.doAfterChange { viewModel.updateField { copy(totalPages = it) } } + binding.inputNotes.doAfterChange { viewModel.updateField { copy(notes = it) } } + + binding.ratingBar.setOnRatingBarChangeListener { _, value, fromUser -> + if (fromUser) viewModel.updateField { copy(rating = value) } + } + } + + private fun wireButtons() { + binding.btnBack.setOnClickListener { findNavController().popBackStack() } + binding.btnSave.setOnClickListener { viewModel.save() } + binding.btnPickCover.setOnClickListener { + pickCoverLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + binding.btnRemoveCover.setOnClickListener { + // Delete the on-disk file too so we don't leak storage. + ImageStorage.deleteCoverImage(viewModel.form.value.coverImageUri) + viewModel.updateField { copy(coverImageUri = null) } + } + binding.btnLookupIsbn.setOnClickListener { + val isbn = binding.inputIsbn.text?.toString().orEmpty() + viewModel.lookupIsbn(isbn) + } + } + + private fun observeForm() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.form.collect { state -> + binding.toolbar.title = getString( + if (state.isEditing) R.string.addedit_edit_title + else R.string.addedit_add_title + ) + binding.btnSave.text = getString( + if (state.isEditing) R.string.addedit_update else R.string.addedit_save + ) + + setIfDifferent(binding.inputTitle, state.title) + setIfDifferent(binding.inputAuthor, state.author) + setIfDifferent(binding.inputYear, state.year) + setIfDifferent(binding.inputCategory, state.category, false) + setIfDifferent(binding.inputStatus, state.readingStatus.displayLabel, false) + setIfDifferent(binding.inputCurrentPage, state.currentPage) + setIfDifferent(binding.inputTotalPages, state.totalPages) + setIfDifferent(binding.inputNotes, state.notes) + + if (binding.ratingBar.rating != state.rating) { + binding.ratingBar.rating = state.rating + } + + val showProgressFields = state.readingStatus == ReadingStatus.CURRENTLY_READING + binding.tilCurrentPage.showIf(showProgressFields) + binding.tilTotalPages.showIf(showProgressFields) + + binding.lookupProgress.showIf(state.isLooking) + binding.btnLookupIsbn.isEnabled = !state.isLooking + + val cover = state.coverImageUri + if (!cover.isNullOrBlank()) { + binding.cover.load(File(cover)) { + crossfade(true) + placeholder(R.drawable.bg_cover_placeholder) + } + binding.cover.background = null + binding.coverIcon.visibility = View.GONE + binding.btnRemoveCover.show() + } else { + binding.cover.setImageDrawable(null) + binding.cover.setBackgroundResource(R.drawable.bg_cover_placeholder) + binding.coverIcon.show() + binding.btnRemoveCover.visibility = View.GONE + } + } + } + } + } + + private fun observeValidation() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.validation.collect { errors -> + binding.tilTitle.error = errors.titleError + binding.tilAuthor.error = errors.authorError + binding.tilYear.error = errors.yearError + binding.tilTotalPages.error = errors.pagesError + } + } + } + } + + private fun observeEvents() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.events.collect { event -> + when (event) { + is EditEvent.Saved -> { + toast( + getString( + if (event.wasEdit) R.string.snackbar_book_updated + else R.string.snackbar_book_added + ) + ) + findNavController().popBackStack() + } + is EditEvent.LookupSucceeded -> { + toast(getString(R.string.addedit_isbn_lookup_success, event.title)) + } + is EditEvent.LookupFailed -> toast(event.message) + } + } + } + } + } + + private fun onCoverPicked(uri: Uri) { + val saved = ImageStorage.saveCoverImage(requireContext(), uri) + if (saved == null) { + toast("Couldn't load that image") + return + } + // Replace previous cover file (if any) so we don't leak storage on repeated picks. + val previous = viewModel.form.value.coverImageUri + if (!previous.isNullOrBlank() && previous != saved) { + ImageStorage.deleteCoverImage(previous) + } + viewModel.updateField { copy(coverImageUri = saved) } + } + + /** Avoid feedback loops when programmatically setting EditText values. */ + private fun setIfDifferent( + view: com.google.android.material.textfield.TextInputEditText, + value: String, + isEditable: Boolean = true + ) { + if (view.text?.toString() != value) { + view.setText(value) + if (isEditable) view.setSelection(value.length) + } + } + + private fun setIfDifferent( + view: com.google.android.material.textfield.MaterialAutoCompleteTextView, + value: String, + isEditable: Boolean = false + ) { + if (view.text?.toString() != value) view.setText(value, false) + } + + private fun setIfDifferent( + view: com.google.android.material.textfield.TextInputEditText, + value: Int + ) = setIfDifferent(view, if (value > 0) value.toString() else "") + + private fun com.google.android.material.textfield.TextInputEditText.doAfterChange( + callback: (String) -> Unit + ) { + addTextChangedListener(object : android.text.TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun afterTextChanged(s: android.text.Editable?) { + callback(s?.toString().orEmpty()) + } + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit + }) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/ui/books/edit/AddEditBookViewModel.kt b/app/src/main/java/com/example/minibooklibrary/ui/books/edit/AddEditBookViewModel.kt new file mode 100644 index 0000000..0fe4bfa --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/ui/books/edit/AddEditBookViewModel.kt @@ -0,0 +1,202 @@ +package com.example.minibooklibrary.ui.books.edit + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.minibooklibrary.data.local.entity.BookEntity +import com.example.minibooklibrary.data.preferences.PreferencesManager +import com.example.minibooklibrary.data.remote.GoogleBooksService +import com.example.minibooklibrary.data.repository.BookRepository +import com.example.minibooklibrary.domain.ReadingStatus +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.Calendar + +/** + * Drives the Add/Edit Book screen. Holds the in-progress form state and runs validation + * before round-tripping to [BookRepository]. Also brokers ISBN lookups against the + * Google Books service so the user can auto-populate fields. + */ +class AddEditBookViewModel( + private val bookRepository: BookRepository, + private val preferences: PreferencesManager, + private val googleBooksService: GoogleBooksService +) : ViewModel() { + + private val _form = MutableStateFlow(BookForm()) + val form: StateFlow = _form.asStateFlow() + + private val _validation = MutableStateFlow(ValidationErrors()) + val validation: StateFlow = _validation.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + val events: SharedFlow = _events.asSharedFlow() + + private var editingId: Long = 0L + private var existingDateAdded: Long = System.currentTimeMillis() + + /** Hydrate the form for an "edit existing book" flow. No-op if [bookId] is invalid. */ + fun loadForEdit(bookId: Long) { + if (bookId <= 0L) return + viewModelScope.launch { + val existing = bookRepository.findById(bookId) ?: return@launch + editingId = existing.id + existingDateAdded = existing.dateAdded + _form.value = BookForm( + title = existing.title, + author = existing.author, + category = existing.category, + year = existing.year.takeIf { it != 0 }?.toString().orEmpty(), + readingStatus = ReadingStatus.fromStorageValue(existing.readingStatus), + rating = existing.rating, + currentPage = existing.currentPage.takeIf { it != 0 }?.toString().orEmpty(), + totalPages = existing.totalPages.takeIf { it != 0 }?.toString().orEmpty(), + notes = existing.notes, + coverImageUri = existing.coverImageUri, + isEditing = true + ) + } + } + + fun updateField(transform: BookForm.() -> BookForm) { + _form.value = _form.value.transform() + } + + /** + * Look up [isbn] via Google Books and merge the result into the current form. We + * prefer remote-supplied values for fields the user hasn't typed yet β€” never + * overwrite a non-empty local value, since the user's typed input is intentional. + */ + fun lookupIsbn(isbn: String) { + if (_form.value.isLooking) return + val cleaned = isbn.trim() + if (cleaned.length < 10) { + _events.tryEmit(EditEvent.LookupFailed("Enter a 10 or 13 digit ISBN")) + return + } + _form.value = _form.value.copy(isLooking = true) + viewModelScope.launch { + val result = googleBooksService.lookupByIsbn(cleaned) + if (result == null) { + _form.value = _form.value.copy(isLooking = false) + _events.tryEmit(EditEvent.LookupFailed("No book found for that ISBN")) + return@launch + } + val current = _form.value + _form.value = current.copy( + isLooking = false, + title = current.title.ifBlank { result.title }, + author = current.author.ifBlank { result.author }, + category = if (current.category == DEFAULT_CATEGORY && result.category.isNotBlank()) + result.category + else current.category, + year = current.year.ifBlank { result.year.takeIf { it > 0 }?.toString().orEmpty() }, + totalPages = current.totalPages.ifBlank { + result.totalPages.takeIf { it > 0 }?.toString().orEmpty() + } + ) + _events.tryEmit(EditEvent.LookupSucceeded(result.title)) + } + } + + /** Validate and persist. Emits [EditEvent.Saved] on success. */ + fun save() { + val form = _form.value + val errors = validate(form) + _validation.value = errors + if (!errors.isValid) return + + viewModelScope.launch { + val userId = preferences.currentUserId + val entity = BookEntity( + id = editingId, + userId = userId, + title = form.title.trim(), + author = form.author.trim(), + category = form.category, + year = form.year.toIntOrNull() ?: 0, + readingStatus = form.readingStatus.storageValue, + rating = form.rating, + coverImageUri = form.coverImageUri, + currentPage = form.currentPage.toIntOrNull() ?: 0, + totalPages = form.totalPages.toIntOrNull() ?: 0, + notes = form.notes.trim(), + dateAdded = if (form.isEditing) existingDateAdded else System.currentTimeMillis(), + lastModified = System.currentTimeMillis() + ) + if (form.isEditing) bookRepository.update(entity) else bookRepository.add(entity) + _events.emit(EditEvent.Saved(form.isEditing)) + } + } + + private fun validate(form: BookForm): ValidationErrors { + val titleError = if (form.title.isBlank()) "Title is required" else null + val authorError = if (form.author.isBlank()) "Author is required" else null + val yearError = when { + form.year.isBlank() -> "Year is required" + form.year.toIntOrNull() == null -> "Year must be a number" + (form.year.toInt() !in 1000..(Calendar.getInstance().get(Calendar.YEAR) + 1)) -> + "Enter a realistic year" + else -> null + } + val pagesError = run { + val current = form.currentPage.toIntOrNull() ?: 0 + val total = form.totalPages.toIntOrNull() ?: 0 + when { + form.readingStatus == ReadingStatus.CURRENTLY_READING && total == 0 -> + "Set a total page count to track progress" + current > total && total > 0 -> "Current page can't exceed total pages" + else -> null + } + } + return ValidationErrors( + titleError = titleError, + authorError = authorError, + yearError = yearError, + pagesError = pagesError + ) + } +} + +/** + * The Add/Edit form state. Strings are kept raw so the UI can show partial input + * (e.g. "20" while the user is still typing a year). + */ +/** Default category used when the user hasn't picked one β€” matches strings.xml order. */ +internal const val DEFAULT_CATEGORY = "Fiction" + +data class BookForm( + val title: String = "", + val author: String = "", + val category: String = DEFAULT_CATEGORY, + val year: String = "", + val readingStatus: ReadingStatus = ReadingStatus.WANT_TO_READ, + val rating: Float = 0f, + val currentPage: String = "", + val totalPages: String = "", + val notes: String = "", + val coverImageUri: String? = null, + val isEditing: Boolean = false, + val isLooking: Boolean = false +) + +data class ValidationErrors( + val titleError: String? = null, + val authorError: String? = null, + val yearError: String? = null, + val pagesError: String? = null +) { + val isValid: Boolean get() = listOfNotNull( + titleError, authorError, yearError, pagesError + ).isEmpty() +} + +sealed class EditEvent { + data class Saved(val wasEdit: Boolean) : EditEvent() + data class LookupSucceeded(val title: String) : EditEvent() + data class LookupFailed(val message: String) : EditEvent() +} diff --git a/app/src/main/java/com/example/minibooklibrary/ui/books/list/BookAdapter.kt b/app/src/main/java/com/example/minibooklibrary/ui/books/list/BookAdapter.kt new file mode 100644 index 0000000..3fe2e06 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/ui/books/list/BookAdapter.kt @@ -0,0 +1,128 @@ +package com.example.minibooklibrary.ui.books.list + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import coil.load +import com.example.minibooklibrary.R +import com.example.minibooklibrary.data.local.entity.BookEntity +import com.example.minibooklibrary.databinding.ItemBookHorizontalBinding +import com.example.minibooklibrary.databinding.ItemBookVerticalBinding +import com.example.minibooklibrary.domain.ReadingStatus +import com.example.minibooklibrary.util.show +import com.example.minibooklibrary.util.showIf +import java.io.File + +/** + * Generic book adapter that renders either a vertical card (library list) or horizontal + * tile (dashboard rail). Layout choice is fixed at construction; we don't switch via + * `getItemViewType` because the consumer always knows which variant they want. + */ +class BookAdapter( + private val variant: LayoutVariant, + private val onClick: (BookEntity) -> Unit +) : ListAdapter(DIFF) { + + enum class LayoutVariant { VERTICAL, HORIZONTAL } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (variant) { + LayoutVariant.VERTICAL -> VerticalHolder( + ItemBookVerticalBinding.inflate(inflater, parent, false) + ) + LayoutVariant.HORIZONTAL -> HorizontalHolder( + ItemBookHorizontalBinding.inflate(inflater, parent, false) + ) + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val item = getItem(position) + when (holder) { + is VerticalHolder -> holder.bind(item, onClick) + is HorizontalHolder -> holder.bind(item, onClick) + } + } + + /** Vertical card used in the main library list. */ + class VerticalHolder( + private val binding: ItemBookVerticalBinding + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(book: BookEntity, onClick: (BookEntity) -> Unit) { + binding.title.text = book.title + binding.author.text = "by ${book.author}" + binding.category.text = book.category + binding.year.text = if (book.year > 0) book.year.toString() else "β€”" + + val status = ReadingStatus.fromStorageValue(book.readingStatus) + binding.statusChip.text = status.displayLabel + binding.statusChip.chipBackgroundColor = androidx.core.content.ContextCompat + .getColorStateList(itemView.context, statusBackgroundColor(status)) + + binding.rating.showIf(book.rating > 0f) + binding.ratingText.text = String.format("%.1f", book.rating) + + // Cover: file URI when present, gradient placeholder otherwise. + val cover = book.coverImageUri + if (!cover.isNullOrBlank()) { + binding.cover.load(File(cover)) { + crossfade(true) + placeholder(R.drawable.bg_cover_placeholder) + error(R.drawable.bg_cover_placeholder) + } + binding.cover.background = null + binding.coverIcon.visibility = android.view.View.GONE + } else { + binding.cover.setImageDrawable(null) + binding.cover.setBackgroundResource(R.drawable.bg_cover_placeholder) + binding.coverIcon.show() + } + + binding.root.setOnClickListener { onClick(book) } + } + } + + /** Compact tile used in the dashboard "recently added" rail. */ + class HorizontalHolder( + private val binding: ItemBookHorizontalBinding + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(book: BookEntity, onClick: (BookEntity) -> Unit) { + binding.title.text = book.title + binding.author.text = book.author + val cover = book.coverImageUri + if (!cover.isNullOrBlank()) { + binding.cover.load(File(cover)) { + crossfade(true) + placeholder(R.drawable.bg_cover_placeholder) + error(R.drawable.bg_cover_placeholder) + } + binding.cover.background = null + binding.coverIcon.visibility = android.view.View.GONE + } else { + binding.cover.setImageDrawable(null) + binding.cover.setBackgroundResource(R.drawable.bg_cover_placeholder) + binding.coverIcon.show() + } + binding.root.setOnClickListener { onClick(book) } + } + } + + companion object { + private val DIFF = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: BookEntity, newItem: BookEntity) = + oldItem.id == newItem.id + override fun areContentsTheSame(oldItem: BookEntity, newItem: BookEntity) = + oldItem == newItem + } + + @androidx.annotation.ColorRes + fun statusBackgroundColor(status: ReadingStatus): Int = when (status) { + ReadingStatus.WANT_TO_READ -> R.color.status_want_bg + ReadingStatus.CURRENTLY_READING -> R.color.status_reading_bg + ReadingStatus.FINISHED -> R.color.status_finished_bg + } + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/ui/books/list/BookListFragment.kt b/app/src/main/java/com/example/minibooklibrary/ui/books/list/BookListFragment.kt new file mode 100644 index 0000000..a1f74be --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/ui/books/list/BookListFragment.kt @@ -0,0 +1,178 @@ +package com.example.minibooklibrary.ui.books.list + +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import com.example.minibooklibrary.R +import com.example.minibooklibrary.databinding.FragmentBookListBinding +import com.example.minibooklibrary.domain.SortOrder +import com.example.minibooklibrary.domain.StatusFilter +import com.example.minibooklibrary.ui.common.ViewModelFactory +import com.example.minibooklibrary.util.showIf +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch + +/** + * Library tab. Houses search, sort, filter, swipe-to-delete with undo, and tap-to-view. + */ +class BookListFragment : Fragment() { + + private var _binding: FragmentBookListBinding? = null + private val binding get() = _binding!! + + private val viewModel: BookListViewModel by viewModels { + ViewModelFactory(requireContext()) + } + + private lateinit var adapter: BookAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentBookListBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + adapter = BookAdapter(BookAdapter.LayoutVariant.VERTICAL) { book -> + findNavController().navigate( + R.id.action_library_to_detail, + Bundle().apply { putLong("bookId", book.id) } + ) + } + binding.recycler.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = this@BookListFragment.adapter + } + + // Swipe-to-delete with undo via Snackbar β€” repository delete is fire-and-forget + // because Room's reactive flow re-emits the post-delete state. + val touchHelper = androidx.recyclerview.widget.ItemTouchHelper( + object : androidx.recyclerview.widget.ItemTouchHelper.SimpleCallback( + 0, + androidx.recyclerview.widget.ItemTouchHelper.LEFT or androidx.recyclerview.widget.ItemTouchHelper.RIGHT + ) { + override fun onMove( + recyclerView: androidx.recyclerview.widget.RecyclerView, + viewHolder: androidx.recyclerview.widget.RecyclerView.ViewHolder, + target: androidx.recyclerview.widget.RecyclerView.ViewHolder + ) = false + + override fun onSwiped( + viewHolder: androidx.recyclerview.widget.RecyclerView.ViewHolder, + direction: Int + ) { + val pos = viewHolder.bindingAdapterPosition + val book = adapter.currentList[pos] + viewModel.delete(book) + Snackbar.make( + binding.root, + getString(R.string.snackbar_book_deleted, book.title), + Snackbar.LENGTH_LONG + ) + .setAction(R.string.action_undo) { viewModel.restore(book) } + .show() + } + } + ) + touchHelper.attachToRecyclerView(binding.recycler) + + binding.fabAdd.setOnClickListener { + findNavController().navigate( + R.id.action_library_to_addEdit, + Bundle().apply { putLong("bookId", 0L) } + ) + } + + binding.btnSort.setOnClickListener { showSortMenu() } + binding.btnFilter.setOnClickListener { showFilterMenu() } + + // Search box: avoid emitting on every keystroke during config changes by using a + // simple TextWatcher and trusting the flow's combineLatest semantics in the VM. + binding.searchInput.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun afterTextChanged(s: Editable?) = Unit + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + viewModel.setQuery(s?.toString().orEmpty()) + } + }) + + binding.btnEmptyAdd.setOnClickListener { + findNavController().navigate( + R.id.action_library_to_addEdit, + Bundle().apply { putLong("bookId", 0L) } + ) + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect(::render) + } + } + } + + private fun render(state: BookListUiState) { + adapter.submitList(state.books) + + // Header counter β€” show "5 of 12" when filtered, else "12 books". + binding.tvCount.text = if (state.totalUnfiltered != state.books.size) { + getString(R.string.library_count_filtered, state.books.size, state.totalUnfiltered) + } else { + getString(R.string.library_count, state.totalUnfiltered) + } + + // Empty states: differentiate "no books at all" from "no matches". + binding.emptyNoBooks.showIf(state.hasNoBooksAtAll) + binding.emptyNoMatches.showIf(state.hasNoMatchingResults) + binding.recycler.showIf(state.books.isNotEmpty()) + binding.progress.showIf(state.isLoading) + } + + private fun showSortMenu() { + val options = SortOrder.entries.toTypedArray() + val current = viewModel.sortOrder.value + val labels = options.map { it.displayLabel }.toTypedArray() + MaterialAlertDialogBuilder(requireContext()) + .setTitle("Sort by") + .setSingleChoiceItems(labels, options.indexOf(current)) { dialog, which -> + viewModel.setSortOrder(options[which]) + dialog.dismiss() + } + .setNegativeButton(R.string.action_cancel, null) + .show() + } + + private fun showFilterMenu() { + val options = StatusFilter.entries.toTypedArray() + val current = viewModel.statusFilter.value + val labels = options.map { it.displayLabel }.toTypedArray() + MaterialAlertDialogBuilder(requireContext()) + .setTitle("Filter by status") + .setSingleChoiceItems(labels, options.indexOf(current)) { dialog, which -> + viewModel.setStatusFilter(options[which]) + dialog.dismiss() + } + .setNegativeButton(R.string.action_cancel, null) + .show() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/ui/books/list/BookListViewModel.kt b/app/src/main/java/com/example/minibooklibrary/ui/books/list/BookListViewModel.kt new file mode 100644 index 0000000..dc3f81e --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/ui/books/list/BookListViewModel.kt @@ -0,0 +1,142 @@ +package com.example.minibooklibrary.ui.books.list + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.minibooklibrary.data.local.entity.BookEntity +import com.example.minibooklibrary.data.preferences.PreferencesManager +import com.example.minibooklibrary.data.repository.BookRepository +import com.example.minibooklibrary.domain.ReadingStatus +import com.example.minibooklibrary.domain.SortOrder +import com.example.minibooklibrary.domain.StatusFilter +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * The library list ViewModel. Combines: + * + * 1. The reactive list of books from Room. + * 2. The user's current search query (held in-memory; not persisted). + * 3. The user's last-used sort order and status filter (persisted in [PreferencesManager]). + * + * Filtering and sorting are done in-memory rather than in SQL β€” the dataset is small + * (personal library, expected < 1000 rows), and this keeps DAO surface area minimal + * and the logic trivially unit-testable. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class BookListViewModel( + private val bookRepository: BookRepository, + private val preferences: PreferencesManager +) : ViewModel() { + + private val _query = MutableStateFlow("") + val query: StateFlow = _query + + private val _sortOrder = MutableStateFlow(preferences.lastSortOrder) + val sortOrder: StateFlow = _sortOrder + + private val _statusFilter = MutableStateFlow(preferences.lastStatusFilter) + val statusFilter: StateFlow = _statusFilter + + private val userIdFlow = flowOf(preferences.currentUserId) + + val uiState: StateFlow = userIdFlow + .flatMapLatest { userId -> + if (userId <= 0L) flowOf(BookListUiState()) + else combine( + bookRepository.observeBooks(userId), + _query, + _sortOrder, + _statusFilter + ) { allBooks, q, sort, filter -> + val filtered = applyFilter(allBooks, filter) + val searched = applySearch(filtered, q) + val sorted = applySort(searched, sort) + BookListUiState( + isLoading = false, + books = sorted, + totalUnfiltered = allBooks.size, + query = q, + sortOrder = sort, + statusFilter = filter + ) + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = BookListUiState(isLoading = true) + ) + + fun setQuery(text: String) { _query.value = text } + + fun setSortOrder(order: SortOrder) { + _sortOrder.value = order + preferences.lastSortOrder = order + } + + fun setStatusFilter(filter: StatusFilter) { + _statusFilter.value = filter + preferences.lastStatusFilter = filter + } + + /** + * Soft delete used by swipe-to-dismiss in the list. We delete immediately and let the + * Snackbar UNDO call back into [restore] β€” Room's reactive flow re-emits both ways. + */ + fun delete(book: BookEntity) { + viewModelScope.launch { bookRepository.delete(book) } + } + + fun restore(book: BookEntity) { + viewModelScope.launch { bookRepository.add(book) } + } + + private fun applyFilter( + books: List, + filter: StatusFilter + ): List = when (filter) { + StatusFilter.ALL -> books + else -> books.filter { it.readingStatus == filter.storageValue } + } + + private fun applySearch(books: List, q: String): List { + if (q.isBlank()) return books + val needle = q.trim().lowercase() + return books.filter { + it.title.lowercase().contains(needle) || + it.author.lowercase().contains(needle) || + it.category.lowercase().contains(needle) + } + } + + private fun applySort(books: List, sort: SortOrder): List = when (sort) { + SortOrder.DATE_ADDED_DESC -> books.sortedByDescending { it.dateAdded } + SortOrder.DATE_ADDED_ASC -> books.sortedBy { it.dateAdded } + SortOrder.TITLE_ASC -> books.sortedBy { it.title.lowercase() } + SortOrder.TITLE_DESC -> books.sortedByDescending { it.title.lowercase() } + SortOrder.RATING_DESC -> books.sortedByDescending { it.rating } + SortOrder.RATING_ASC -> books.sortedBy { it.rating } + } +} + +data class BookListUiState( + val isLoading: Boolean = false, + val books: List = emptyList(), + val totalUnfiltered: Int = 0, + val query: String = "", + val sortOrder: SortOrder = SortOrder.DATE_ADDED_DESC, + val statusFilter: StatusFilter = StatusFilter.ALL +) { + val isEmpty: Boolean get() = !isLoading && books.isEmpty() + val hasNoBooksAtAll: Boolean get() = isEmpty && totalUnfiltered == 0 + val hasNoMatchingResults: Boolean get() = isEmpty && totalUnfiltered > 0 + val statusForReading: ReadingStatus + get() = ReadingStatus.fromStorageValue(statusFilter.storageValue) +} diff --git a/app/src/main/java/com/example/minibooklibrary/ui/common/ViewModelFactory.kt b/app/src/main/java/com/example/minibooklibrary/ui/common/ViewModelFactory.kt new file mode 100644 index 0000000..ab79549 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/ui/common/ViewModelFactory.kt @@ -0,0 +1,43 @@ +package com.example.minibooklibrary.ui.common + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.example.minibooklibrary.di.ServiceLocator +import com.example.minibooklibrary.ui.auth.AuthViewModel +import com.example.minibooklibrary.ui.books.detail.BookDetailViewModel +import com.example.minibooklibrary.ui.books.edit.AddEditBookViewModel +import com.example.minibooklibrary.ui.books.list.BookListViewModel +import com.example.minibooklibrary.ui.dashboard.DashboardViewModel +import com.example.minibooklibrary.ui.settings.SettingsViewModel + +/** + * Hand-rolled [ViewModelProvider.Factory] backed by [ServiceLocator]. Lets every + * ViewModel keep its constructor honest about its real dependencies, instead of pulling + * a SavedStateHandle "blob". + */ +class ViewModelFactory(context: Context) : ViewModelProvider.Factory { + + private val app = context.applicationContext + private val userRepo = ServiceLocator.provideUserRepository(app) + private val bookRepo = ServiceLocator.provideBookRepository(app) + private val prefs = ServiceLocator.providePreferences(app) + private val googleBooks = ServiceLocator.provideGoogleBooksService() + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = when { + modelClass.isAssignableFrom(AuthViewModel::class.java) -> + AuthViewModel(userRepo) as T + modelClass.isAssignableFrom(DashboardViewModel::class.java) -> + DashboardViewModel(bookRepo, prefs) as T + modelClass.isAssignableFrom(BookListViewModel::class.java) -> + BookListViewModel(bookRepo, prefs) as T + modelClass.isAssignableFrom(BookDetailViewModel::class.java) -> + BookDetailViewModel(bookRepo) as T + modelClass.isAssignableFrom(AddEditBookViewModel::class.java) -> + AddEditBookViewModel(bookRepo, prefs, googleBooks) as T + modelClass.isAssignableFrom(SettingsViewModel::class.java) -> + SettingsViewModel(bookRepo, userRepo, prefs) as T + else -> error("Unknown ViewModel class: ${modelClass.name}") + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/ui/dashboard/DashboardFragment.kt b/app/src/main/java/com/example/minibooklibrary/ui/dashboard/DashboardFragment.kt new file mode 100644 index 0000000..50901ca --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/ui/dashboard/DashboardFragment.kt @@ -0,0 +1,102 @@ +package com.example.minibooklibrary.ui.dashboard + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import com.example.minibooklibrary.R +import com.example.minibooklibrary.databinding.FragmentDashboardBinding +import com.example.minibooklibrary.ui.books.list.BookAdapter +import com.example.minibooklibrary.ui.common.ViewModelFactory +import com.example.minibooklibrary.util.show +import com.example.minibooklibrary.util.showIf +import kotlinx.coroutines.launch + +/** + * Home tab. Shows greeting, totals card, status breakdown, recently added books, and a + * quick "Add book" CTA. + */ +class DashboardFragment : Fragment() { + + private var _binding: FragmentDashboardBinding? = null + private val binding get() = _binding!! + + private val viewModel: DashboardViewModel by viewModels { + ViewModelFactory(requireContext()) + } + + private lateinit var recentAdapter: BookAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentDashboardBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + recentAdapter = BookAdapter(BookAdapter.LayoutVariant.HORIZONTAL) { book -> + findNavController().navigate( + R.id.action_dashboard_to_detail, + Bundle().apply { putLong("bookId", book.id) } + ) + } + binding.recyclerRecent.apply { + layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false) + adapter = recentAdapter + } + + binding.btnAddBook.setOnClickListener { + findNavController().navigate( + R.id.action_dashboard_to_addEdit, + Bundle().apply { putLong("bookId", 0L) } + ) + } + binding.btnViewAll.setOnClickListener { + findNavController().navigate(R.id.action_dashboard_to_library) + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect(::render) + } + } + } + + private fun render(state: DashboardUiState) { + binding.tvGreeting.text = "${viewModel.timeOfDayGreeting}," + binding.tvUserName.text = state.username.ifEmpty { "reader" } + + binding.tvTotalBooks.text = state.totalBooks.toString() + binding.tvAvgRating.text = if (state.averageRating > 0f) + String.format("%.1f", state.averageRating) + else "β€”" + + binding.progressWantToRead.progress = state.wantToReadPercent + binding.progressReading.progress = state.currentlyReadingPercent + binding.progressFinished.progress = state.finishedPercent + binding.tvWantToReadCount.text = state.wantToReadCount.toString() + binding.tvCurrentlyReadingCount.text = state.currentlyReadingCount.toString() + binding.tvFinishedCount.text = state.finishedCount.toString() + + recentAdapter.submitList(state.recentBooks) + binding.emptyRecent.showIf(state.recentBooks.isEmpty()) + binding.recyclerRecent.showIf(state.recentBooks.isNotEmpty()) + binding.dashboardContent.show() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/example/minibooklibrary/ui/dashboard/DashboardViewModel.kt new file mode 100644 index 0000000..177a792 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/ui/dashboard/DashboardViewModel.kt @@ -0,0 +1,98 @@ +package com.example.minibooklibrary.ui.dashboard + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.minibooklibrary.data.local.entity.BookEntity +import com.example.minibooklibrary.data.preferences.PreferencesManager +import com.example.minibooklibrary.data.repository.BookRepository +import com.example.minibooklibrary.domain.ReadingStatus +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** + * Surfaces the data shown on the Dashboard tab: total count, status breakdown, recent + * book strip, and the highlight rating average. All values are derived from a single + * [BookRepository] [kotlinx.coroutines.flow.Flow] and updated reactively. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DashboardViewModel( + private val bookRepository: BookRepository, + private val preferences: PreferencesManager +) : ViewModel() { + + private val userIdFlow = flowOf(preferences.currentUserId) + + val uiState: StateFlow = userIdFlow + .flatMapLatest { userId -> + if (userId <= 0L) flowOf(DashboardUiState()) + else combineDashboard(userId) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = DashboardUiState(isLoading = true) + ) + + private fun combineDashboard(userId: Long) = combine( + bookRepository.observeBooks(userId), + bookRepository.observeRecentBooks(userId, limit = 5), + bookRepository.observeStatusCount(userId, ReadingStatus.WANT_TO_READ), + bookRepository.observeStatusCount(userId, ReadingStatus.CURRENTLY_READING), + bookRepository.observeStatusCount(userId, ReadingStatus.FINISHED) + ) { books, recent, wantToRead, reading, finished -> + DashboardUiState( + isLoading = false, + totalBooks = books.size, + wantToReadCount = wantToRead, + currentlyReadingCount = reading, + finishedCount = finished, + averageRating = books.filter { it.rating > 0f } + .map { it.rating } + .average() + .takeIf { !it.isNaN() }?.toFloat() ?: 0f, + recentBooks = recent, + username = preferences.currentUsername.orEmpty() + ) + } + + /** Helper used by the welcome card to greet by time of day. */ + val timeOfDayGreeting: String + get() { + val hour = java.util.Calendar.getInstance() + .get(java.util.Calendar.HOUR_OF_DAY) + return when (hour) { + in 5..11 -> "Good morning" + in 12..16 -> "Good afternoon" + in 17..20 -> "Good evening" + else -> "Good night" + } + } +} + +data class DashboardUiState( + val isLoading: Boolean = false, + val username: String = "", + val totalBooks: Int = 0, + val wantToReadCount: Int = 0, + val currentlyReadingCount: Int = 0, + val finishedCount: Int = 0, + val averageRating: Float = 0f, + val recentBooks: List = emptyList() +) { + /** Percentages for the progress bars; sum may be < 100 when there are no books. */ + val wantToReadPercent: Int + get() = if (totalBooks == 0) 0 else (wantToReadCount * 100 / totalBooks) + val currentlyReadingPercent: Int + get() = if (totalBooks == 0) 0 else (currentlyReadingCount * 100 / totalBooks) + val finishedPercent: Int + get() = if (totalBooks == 0) 0 else (finishedCount * 100 / totalBooks) +} + +private fun List.average(): Double = if (isEmpty()) Double.NaN +else sum() / size.toDouble() diff --git a/app/src/main/java/com/example/minibooklibrary/ui/main/MainActivity.kt b/app/src/main/java/com/example/minibooklibrary/ui/main/MainActivity.kt new file mode 100644 index 0000000..62f8e62 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/ui/main/MainActivity.kt @@ -0,0 +1,57 @@ +package com.example.minibooklibrary.ui.main + +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.navigation.NavController +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.setupWithNavController +import com.example.minibooklibrary.R +import com.example.minibooklibrary.databinding.ActivityMainBinding +import com.example.minibooklibrary.ui.RouterActivity + +/** + * Single-Activity host for the post-login app. Holds three top-level destinations + * (Dashboard, Library, Settings) wired through a [com.google.android.material.bottomnavigation.BottomNavigationView] + * and a NavController. Detail / Add-Edit screens push onto the same stack but hide + * the bottom nav so the user feels like they descended to a sub-screen. + */ +class MainActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding + private lateinit var navController: NavController + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + val navHost = supportFragmentManager + .findFragmentById(R.id.main_nav_host) as NavHostFragment + navController = navHost.navController + binding.bottomNav.setupWithNavController(navController) + + // Hide bottom nav on inner destinations so detail / edit screens get full height. + navController.addOnDestinationChangedListener { _, destination, _ -> + val showBottomNav = destination.id in TOP_LEVEL_DESTINATIONS + binding.bottomNav.visibility = if (showBottomNav) android.view.View.VISIBLE + else android.view.View.GONE + } + } + + /** Convenience used by the Settings screen after sign-out. */ + fun routeBackToAuth() { + startActivity(Intent(this, RouterActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + }) + finish() + } + + companion object { + private val TOP_LEVEL_DESTINATIONS = setOf( + R.id.dashboardFragment, + R.id.bookListFragment, + R.id.settingsFragment + ) + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/ui/onboarding/OnboardingActivity.kt b/app/src/main/java/com/example/minibooklibrary/ui/onboarding/OnboardingActivity.kt new file mode 100644 index 0000000..6838acf --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/ui/onboarding/OnboardingActivity.kt @@ -0,0 +1,97 @@ +package com.example.minibooklibrary.ui.onboarding + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowCompat +import androidx.viewpager2.widget.ViewPager2 +import com.example.minibooklibrary.R +import com.example.minibooklibrary.databinding.ActivityOnboardingBinding +import com.example.minibooklibrary.di.ServiceLocator +import com.example.minibooklibrary.ui.auth.AuthActivity + +/** + * 3-slide onboarding shown only on the first launch. The "completed" flag is flipped + * the moment the user moves past this screen β€” see [finishOnboarding]. + */ +class OnboardingActivity : AppCompatActivity() { + + private lateinit var binding: ActivityOnboardingBinding + + private val pages = listOf( + OnboardingPage( + titleRes = R.string.onboarding_title_1, + bodyRes = R.string.onboarding_body_1, + illustrationRes = R.drawable.illustration_onboarding_1 + ), + OnboardingPage( + titleRes = R.string.onboarding_title_2, + bodyRes = R.string.onboarding_body_2, + illustrationRes = R.drawable.illustration_onboarding_2 + ), + OnboardingPage( + titleRes = R.string.onboarding_title_3, + bodyRes = R.string.onboarding_body_3, + illustrationRes = R.drawable.illustration_onboarding_3 + ) + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, true) + binding = ActivityOnboardingBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.viewPager.adapter = OnboardingPagerAdapter(pages) + binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) = renderState(position) + }) + + binding.btnSkip.setOnClickListener { finishOnboarding() } + binding.btnNext.setOnClickListener { + val current = binding.viewPager.currentItem + if (current >= pages.lastIndex) finishOnboarding() + else binding.viewPager.currentItem = current + 1 + } + + renderState(0) + } + + private fun renderState(position: Int) { + renderDots(position) + val isLast = position == pages.lastIndex + binding.btnSkip.visibility = if (isLast) View.INVISIBLE else View.VISIBLE + binding.btnNext.text = getString( + if (isLast) R.string.onboarding_get_started else R.string.onboarding_next + ) + } + + private fun renderDots(activePosition: Int) { + binding.dotIndicator.removeAllViews() + val inflater = LayoutInflater.from(this) + repeat(pages.size) { index -> + val dot = inflater.inflate(R.layout.view_indicator_dot, binding.dotIndicator, false) as ImageView + dot.setImageResource( + if (index == activePosition) R.drawable.bg_dot_indicator_active + else R.drawable.bg_dot_indicator_inactive + ) + (dot.layoutParams as LinearLayout.LayoutParams).apply { + marginStart = if (index == 0) 0 else dpToPx(6) + } + binding.dotIndicator.addView(dot) + } + } + + private fun finishOnboarding() { + ServiceLocator.providePreferences(this).onboardingCompleted = true + startActivity(Intent(this, AuthActivity::class.java)) + finish() + } + + private fun dpToPx(dp: Int): Int = + (dp * resources.displayMetrics.density).toInt() +} diff --git a/app/src/main/java/com/example/minibooklibrary/ui/onboarding/OnboardingPage.kt b/app/src/main/java/com/example/minibooklibrary/ui/onboarding/OnboardingPage.kt new file mode 100644 index 0000000..d37fd63 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/ui/onboarding/OnboardingPage.kt @@ -0,0 +1,11 @@ +package com.example.minibooklibrary.ui.onboarding + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes + +/** A single onboarding card. Three of these compose the carousel. */ +data class OnboardingPage( + @StringRes val titleRes: Int, + @StringRes val bodyRes: Int, + @DrawableRes val illustrationRes: Int +) diff --git a/app/src/main/java/com/example/minibooklibrary/ui/onboarding/OnboardingPagerAdapter.kt b/app/src/main/java/com/example/minibooklibrary/ui/onboarding/OnboardingPagerAdapter.kt new file mode 100644 index 0000000..b979353 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/ui/onboarding/OnboardingPagerAdapter.kt @@ -0,0 +1,40 @@ +package com.example.minibooklibrary.ui.onboarding + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.example.minibooklibrary.databinding.ItemOnboardingPageBinding + +/** + * Tiny RecyclerView adapter wired into a ViewPager2. Three static pages β€” no diffing, no + * payloads, just bind once and render. + */ +class OnboardingPagerAdapter( + private val pages: List +) : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PageViewHolder { + val binding = ItemOnboardingPageBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return PageViewHolder(binding) + } + + override fun onBindViewHolder(holder: PageViewHolder, position: Int) { + holder.bind(pages[position]) + } + + override fun getItemCount(): Int = pages.size + + class PageViewHolder( + private val binding: ItemOnboardingPageBinding + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(page: OnboardingPage) { + binding.illustration.setImageResource(page.illustrationRes) + binding.title.setText(page.titleRes) + binding.body.setText(page.bodyRes) + } + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/ui/settings/SettingsFragment.kt b/app/src/main/java/com/example/minibooklibrary/ui/settings/SettingsFragment.kt new file mode 100644 index 0000000..cf14029 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/ui/settings/SettingsFragment.kt @@ -0,0 +1,158 @@ +package com.example.minibooklibrary.ui.settings + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatDelegate +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.example.minibooklibrary.R +import com.example.minibooklibrary.databinding.FragmentSettingsBinding +import com.example.minibooklibrary.ui.common.ViewModelFactory +import com.example.minibooklibrary.ui.main.MainActivity +import com.example.minibooklibrary.util.toast +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch + +class SettingsFragment : Fragment() { + + private var _binding: FragmentSettingsBinding? = null + private val binding get() = _binding!! + + private val viewModel: SettingsViewModel by viewModels { + ViewModelFactory(requireContext()) + } + + /** Lets the user pick any JSON file from storage for the Restore action. */ + private val pickRestoreLauncher = registerForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + if (uri != null) viewModel.importBackup(requireContext(), uri) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSettingsBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Theme toggle group: maps Material's chip ids to AppCompatDelegate constants. + binding.themeToggle.addOnButtonCheckedListener { _, checkedId, isChecked -> + if (!isChecked) return@addOnButtonCheckedListener + val mode = when (checkedId) { + R.id.themeLight -> AppCompatDelegate.MODE_NIGHT_NO + R.id.themeDark -> AppCompatDelegate.MODE_NIGHT_YES + else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + viewModel.setThemeMode(mode) + } + + // Populate each settings row template (title, summary, icon) β€” they share one layout. + with(binding.rowExportPdf) { + title.setText(R.string.settings_export_pdf) + summary.setText(R.string.settings_export_pdf_summary) + icon.setImageResource(R.drawable.ic_pdf) + root.setOnClickListener { viewModel.exportPdf(requireContext()) } + } + with(binding.rowBackup) { + title.setText(R.string.settings_backup) + summary.setText(R.string.settings_backup_summary) + icon.setImageResource(R.drawable.ic_backup) + root.setOnClickListener { viewModel.exportBackup(requireContext()) } + } + with(binding.rowRestore) { + title.setText(R.string.settings_restore) + summary.setText(R.string.settings_restore_summary) + icon.setImageResource(R.drawable.ic_restore) + root.setOnClickListener { pickRestoreLauncher.launch(arrayOf("application/json")) } + } + binding.btnLogout.setOnClickListener { confirmLogout() } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect(::render) + } + } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.events.collect(::handleEvent) + } + } + } + + private fun render(state: SettingsUiState) { + val themeId = when (state.themeMode) { + AppCompatDelegate.MODE_NIGHT_NO -> R.id.themeLight + AppCompatDelegate.MODE_NIGHT_YES -> R.id.themeDark + else -> R.id.themeSystem + } + if (binding.themeToggle.checkedButtonId != themeId) { + binding.themeToggle.check(themeId) + } + binding.signedInAs.text = getString( + R.string.settings_signed_in_as, + state.username.ifEmpty { "you" } + ) + binding.progress.visibility = if (state.isWorking) View.VISIBLE else View.GONE + } + + private fun handleEvent(event: SettingsEvent) { + when (event) { + is SettingsEvent.PdfReady -> { + Snackbar.make(binding.root, R.string.snackbar_pdf_exported, Snackbar.LENGTH_LONG) + .setAction(R.string.action_open) { openFile(event.uri, "application/pdf") } + .show() + } + is SettingsEvent.BackupReady -> { + Snackbar.make(binding.root, R.string.snackbar_backup_exported, Snackbar.LENGTH_LONG) + .setAction(R.string.action_open) { openFile(event.uri, "application/json") } + .show() + } + is SettingsEvent.RestoreCompleted -> { + toast(getString(R.string.snackbar_restore_done, event.importedCount)) + } + is SettingsEvent.Error -> { + toast(event.message) + } + SettingsEvent.LoggedOut -> { + (activity as? MainActivity)?.routeBackToAuth() + } + } + } + + private fun openFile(uri: Uri, mime: String) { + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, mime) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + runCatching { startActivity(intent) }.onFailure { toast("No app can open this file") } + } + + private fun confirmLogout() { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.confirm_logout_title) + .setMessage(R.string.confirm_logout_body) + .setPositiveButton(R.string.settings_logout) { _, _ -> viewModel.logout() } + .setNegativeButton(R.string.action_cancel, null) + .show() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/example/minibooklibrary/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..d540096 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/ui/settings/SettingsViewModel.kt @@ -0,0 +1,118 @@ +package com.example.minibooklibrary.ui.settings + +import android.content.Context +import android.net.Uri +import androidx.appcompat.app.AppCompatDelegate +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.minibooklibrary.data.preferences.PreferencesManager +import com.example.minibooklibrary.data.repository.BookRepository +import com.example.minibooklibrary.data.repository.UserRepository +import com.example.minibooklibrary.util.BackupManager +import com.example.minibooklibrary.util.PdfExporter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Settings tab ViewModel. Owns theme switching, PDF export, JSON backup/restore, and + * account sign-out. Long-running disk work runs on [Dispatchers.IO]. + */ +class SettingsViewModel( + private val bookRepository: BookRepository, + private val userRepository: UserRepository, + private val preferences: PreferencesManager +) : ViewModel() { + + private val _uiState = MutableStateFlow( + SettingsUiState( + themeMode = preferences.themeMode, + username = preferences.currentUsername.orEmpty() + ) + ) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + val events: SharedFlow = _events.asSharedFlow() + + fun setThemeMode(mode: Int) { + preferences.themeMode = mode + _uiState.value = _uiState.value.copy(themeMode = mode) + AppCompatDelegate.setDefaultNightMode(mode) + } + + fun exportPdf(context: Context) { + if (_uiState.value.isWorking) return + _uiState.value = _uiState.value.copy(isWorking = true) + viewModelScope.launch { + val books = bookRepository.getBooks(preferences.currentUserId) + val uri = withContext(Dispatchers.IO) { + PdfExporter(context.applicationContext).export(books) + } + _uiState.value = _uiState.value.copy(isWorking = false) + _events.emit( + if (uri != null) SettingsEvent.PdfReady(uri) + else SettingsEvent.Error("Couldn't export the PDF β€” please try again") + ) + } + } + + fun exportBackup(context: Context) { + if (_uiState.value.isWorking) return + _uiState.value = _uiState.value.copy(isWorking = true) + viewModelScope.launch { + val books = bookRepository.getBooks(preferences.currentUserId) + val uri = withContext(Dispatchers.IO) { + BackupManager(context.applicationContext).exportToJson(books) + } + _uiState.value = _uiState.value.copy(isWorking = false) + _events.emit( + if (uri != null) SettingsEvent.BackupReady(uri) + else SettingsEvent.Error("Couldn't write backup file") + ) + } + } + + fun importBackup(context: Context, source: Uri) { + if (_uiState.value.isWorking) return + _uiState.value = _uiState.value.copy(isWorking = true) + viewModelScope.launch { + val userId = preferences.currentUserId + val result = withContext(Dispatchers.IO) { + BackupManager(context.applicationContext).importFromJson(source, userId) + } + _uiState.value = _uiState.value.copy(isWorking = false) + if (result == null) { + _events.emit(SettingsEvent.Error("Backup file looks invalid")) + return@launch + } + bookRepository.addAll(result) + _events.emit(SettingsEvent.RestoreCompleted(result.size)) + } + } + + fun logout() { + userRepository.logout() + viewModelScope.launch { _events.emit(SettingsEvent.LoggedOut) } + } +} + +data class SettingsUiState( + val themeMode: Int = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, + val username: String = "", + val isWorking: Boolean = false +) + +sealed class SettingsEvent { + data class PdfReady(val uri: Uri) : SettingsEvent() + data class BackupReady(val uri: Uri) : SettingsEvent() + data class RestoreCompleted(val importedCount: Int) : SettingsEvent() + data class Error(val message: String) : SettingsEvent() + data object LoggedOut : SettingsEvent() +} diff --git a/app/src/main/java/com/example/minibooklibrary/util/BackupManager.kt b/app/src/main/java/com/example/minibooklibrary/util/BackupManager.kt new file mode 100644 index 0000000..71dacb0 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/util/BackupManager.kt @@ -0,0 +1,126 @@ +package com.example.minibooklibrary.util + +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import com.example.minibooklibrary.data.local.entity.BookEntity +import org.json.JSONArray +import org.json.JSONObject +import java.io.BufferedReader +import java.io.File +import java.io.FileOutputStream +import java.io.InputStreamReader +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Manual JSON serializer for backup/restore. + * + * Why hand-rolled JSON instead of kotlinx-serialization or Moshi: keeps the dependency + * graph small for a portfolio app, and the schema is tiny. The file format is human + * readable so users can inspect/edit it if they really need to. + */ +class BackupManager(private val context: Context) { + + /** Serialize [books] to a JSON file in Downloads and return its URI. */ + fun exportToJson(books: List): Uri? { + val payload = JSONObject().apply { + put("schema", SCHEMA_VERSION) + put("exportedAt", System.currentTimeMillis()) + put( + "books", + JSONArray().apply { books.forEach { put(it.toJson()) } } + ) + } + val fileName = "MiniBookLibrary_backup_${SimpleDateFormat("yyyyMMdd_HHmm", Locale.US).format(Date())}.json" + return writeToDownloads(fileName, payload.toString(2)) + } + + /** + * Read [uri] as JSON and convert it back into a list of books for [userId]. + * The IDs are reset to zero so that Room treats them as fresh inserts (avoids + * collisions with the user's existing library). + */ + fun importFromJson(uri: Uri, userId: Long): List? { + return try { + val text = context.contentResolver.openInputStream(uri)?.use { stream -> + BufferedReader(InputStreamReader(stream)).readText() + } ?: return null + + val root = JSONObject(text) + val booksArray = root.getJSONArray("books") + (0 until booksArray.length()).map { idx -> + booksArray.getJSONObject(idx).toBook(userId) + } + } catch (t: Throwable) { + null + } + } + + private fun BookEntity.toJson(): JSONObject = JSONObject().apply { + put("title", title) + put("author", author) + put("category", category) + put("year", year) + put("readingStatus", readingStatus) + put("rating", rating.toDouble()) + put("currentPage", currentPage) + put("totalPages", totalPages) + put("notes", notes) + put("dateAdded", dateAdded) + put("lastModified", lastModified) + // We deliberately skip coverImageUri β€” local file paths don't survive a transfer. + } + + private fun JSONObject.toBook(userId: Long): BookEntity = BookEntity( + id = 0L, + userId = userId, + title = optString("title", "Untitled"), + author = optString("author", "Unknown"), + category = optString("category", "Fiction"), + year = optInt("year", 0), + readingStatus = optString("readingStatus", "want_to_read"), + rating = optDouble("rating", 0.0).toFloat(), + coverImageUri = null, + currentPage = optInt("currentPage", 0), + totalPages = optInt("totalPages", 0), + notes = optString("notes", ""), + dateAdded = optLong("dateAdded", System.currentTimeMillis()), + lastModified = optLong("lastModified", System.currentTimeMillis()) + ) + + private fun writeToDownloads(fileName: String, content: String): Uri? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val resolver = context.contentResolver + val values = ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, fileName) + put(MediaStore.Downloads.MIME_TYPE, "application/json") + put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + } + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) ?: return null + resolver.openOutputStream(uri)?.use { stream -> + stream.write(content.toByteArray(Charsets.UTF_8)) + } + uri + } else { + @Suppress("DEPRECATION") + val downloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + if (!downloadDir.exists()) downloadDir.mkdirs() + val file = File(downloadDir, fileName) + FileOutputStream(file).use { it.write(content.toByteArray(Charsets.UTF_8)) } + androidx.core.content.FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file + ) + } + } + + companion object { + private const val SCHEMA_VERSION = 1 + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/util/Extensions.kt b/app/src/main/java/com/example/minibooklibrary/util/Extensions.kt new file mode 100644 index 0000000..82ad54d --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/util/Extensions.kt @@ -0,0 +1,42 @@ +package com.example.minibooklibrary.util + +import android.content.Context +import android.text.format.DateUtils +import android.view.View +import android.widget.Toast +import androidx.fragment.app.Fragment +import com.example.minibooklibrary.domain.ReadingStatus +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** Show a short toast β€” the most common shape across the app. */ +fun Context.toast(message: CharSequence) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() +} + +fun Fragment.toast(message: CharSequence) { + requireContext().toast(message) +} + +fun View.show() { visibility = View.VISIBLE } +fun View.hide() { visibility = View.GONE } +fun View.invisible() { visibility = View.INVISIBLE } +fun View.showIf(condition: Boolean) { + visibility = if (condition) View.VISIBLE else View.GONE +} + +/** Format epoch milliseconds as a friendly absolute date β€” used in book detail / PDF. */ +fun Long.formatDate(pattern: String = "MMM d, yyyy"): String = + SimpleDateFormat(pattern, Locale.getDefault()).format(Date(this)) + +/** Format epoch milliseconds as a relative string ("3 hours ago") for activity rails. */ +fun Long.formatRelative(): String = + DateUtils.getRelativeTimeSpanString( + this, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS + ).toString() + +/** UI-friendly label for a reading status. */ +fun ReadingStatus.label(): String = displayLabel diff --git a/app/src/main/java/com/example/minibooklibrary/util/ImageStorage.kt b/app/src/main/java/com/example/minibooklibrary/util/ImageStorage.kt new file mode 100644 index 0000000..68017e1 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/util/ImageStorage.kt @@ -0,0 +1,50 @@ +package com.example.minibooklibrary.util + +import android.content.Context +import android.net.Uri +import android.util.Log +import java.io.File +import java.io.FileOutputStream +import java.util.UUID + +/** + * Copies user-picked cover images into our app-private internal storage. + * + * Why we copy instead of saving the original `content://` URI: the gallery URI may grant + * us only short-lived read permission (especially via Photo Picker). By copying the + * bytes locally we get a stable, persistent path the RecyclerView can reload across + * cold starts. + */ +object ImageStorage { + + private const val TAG = "ImageStorage" + private const val DIRECTORY = "covers" + + /** + * Copy [sourceUri] into internal storage and return the absolute file path. Returns + * null on any I/O failure β€” callers should fall back to the placeholder cover. + */ + fun saveCoverImage(context: Context, sourceUri: Uri): String? { + return try { + val coversDir = File(context.filesDir, DIRECTORY).apply { mkdirs() } + val target = File(coversDir, "cover_${UUID.randomUUID()}.jpg") + + context.contentResolver.openInputStream(sourceUri)?.use { input -> + FileOutputStream(target).use { output -> + input.copyTo(output) + } + } ?: return null + + target.absolutePath + } catch (t: Throwable) { + Log.w(TAG, "Failed to copy cover image from $sourceUri", t) + null + } + } + + /** Best-effort delete; ignored if the file is already gone. */ + fun deleteCoverImage(path: String?) { + if (path.isNullOrBlank()) return + runCatching { File(path).delete() } + } +} diff --git a/app/src/main/java/com/example/minibooklibrary/util/PasswordHasher.kt b/app/src/main/java/com/example/minibooklibrary/util/PasswordHasher.kt new file mode 100644 index 0000000..87ae8f0 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/util/PasswordHasher.kt @@ -0,0 +1,48 @@ +package com.example.minibooklibrary.util + +import java.security.MessageDigest +import java.security.SecureRandom + +/** + * Lightweight salted SHA-256 password hashing. + * + * Why not bcrypt/Argon2: those would add a dependency and aren't free on cold start of an + * offline-first portfolio app. SHA-256 with a per-user salt is enough to defeat trivial + * rainbow-table attacks against a leaked SQLite file, which is the realistic threat + * model here. Real production code should use a memory-hard KDF. + */ +object PasswordHasher { + + private const val SALT_BYTES = 16 + + /** Generate a fresh salt (Base64-ish hex string, safe to store in SQLite). */ + fun newSalt(): String { + val bytes = ByteArray(SALT_BYTES) + SecureRandom().nextBytes(bytes) + return bytes.toHex() + } + + /** Hash [password] with [salt]. Both inputs are required and must not be blank. */ + fun hash(password: String, salt: String): String { + require(password.isNotEmpty()) { "Password must not be empty" } + require(salt.isNotBlank()) { "Salt must not be blank" } + val digest = MessageDigest.getInstance("SHA-256") + digest.update(salt.toByteArray(Charsets.UTF_8)) + digest.update(password.toByteArray(Charsets.UTF_8)) + return digest.digest().toHex() + } + + /** Constant-time-ish comparison so callers don't need to remember to do it themselves. */ + fun verify(password: String, salt: String, expectedHash: String): Boolean { + val computed = hash(password, salt) + if (computed.length != expectedHash.length) return false + var result = 0 + for (i in computed.indices) { + result = result or (computed[i].code xor expectedHash[i].code) + } + return result == 0 + } + + private fun ByteArray.toHex(): String = + joinToString(separator = "") { "%02x".format(it) } +} diff --git a/app/src/main/java/com/example/minibooklibrary/util/PdfExporter.kt b/app/src/main/java/com/example/minibooklibrary/util/PdfExporter.kt new file mode 100644 index 0000000..1213892 --- /dev/null +++ b/app/src/main/java/com/example/minibooklibrary/util/PdfExporter.kt @@ -0,0 +1,150 @@ +package com.example.minibooklibrary.util + +import android.content.ContentValues +import android.content.Context +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Typeface +import android.graphics.pdf.PdfDocument +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import com.example.minibooklibrary.data.local.entity.BookEntity +import com.example.minibooklibrary.domain.ReadingStatus +import java.io.File +import java.io.FileOutputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Renders the user's library to a PDF using the platform [PdfDocument] API β€” no + * third-party PDF library required. + * + * Layout: a single-column table per page with a header strip, padded rows, and a footer + * showing page numbers. Pages are sized to US Letter at 72 DPI for predictable rendering. + * + * Output goes through MediaStore on Q+ (no permission needed) and falls back to the + * legacy public Downloads dir on older devices. + */ +class PdfExporter(private val context: Context) { + + /** Render [books] to a PDF and return the public URI of the resulting file. */ + fun export(books: List): Uri? { + val document = PdfDocument() + try { + renderPages(document, books) + val fileName = "MiniBookLibrary_${SimpleDateFormat("yyyyMMdd_HHmm", Locale.US).format(Date())}.pdf" + return writeToDownloads(fileName, document) + } finally { + document.close() + } + } + + private fun renderPages(document: PdfDocument, books: List) { + val titlePaint = Paint().apply { + color = Color.parseColor("#1F2937") + textSize = 22f + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + isAntiAlias = true + } + val subtitlePaint = Paint().apply { + color = Color.parseColor("#6B7280") + textSize = 12f + typeface = Typeface.DEFAULT + isAntiAlias = true + } + val rowTitlePaint = Paint().apply { + color = Color.parseColor("#1F2937") + textSize = 13f + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + isAntiAlias = true + } + val rowMetaPaint = Paint().apply { + color = Color.parseColor("#374151") + textSize = 11f + typeface = Typeface.DEFAULT + isAntiAlias = true + } + val dividerPaint = Paint().apply { + color = Color.parseColor("#E5E7EB") + strokeWidth = 0.5f + style = Paint.Style.STROKE + isAntiAlias = true + } + + val pageWidth = 612 // 8.5in @ 72dpi + val pageHeight = 792 // 11in @ 72dpi + val margin = 40f + val rowHeight = 64f + val maxRowsPerPage = ((pageHeight - margin * 2 - 100f) / rowHeight).toInt() + + val totalPages = if (books.isEmpty()) 1 else (books.size + maxRowsPerPage - 1) / maxRowsPerPage + + for (pageIndex in 0 until totalPages) { + val pageInfo = PdfDocument.PageInfo.Builder(pageWidth, pageHeight, pageIndex + 1).create() + val page = document.startPage(pageInfo) + val canvas = page.canvas + + // Header + canvas.drawText("Mini Book Library", margin, margin + 10f, titlePaint) + val timestamp = SimpleDateFormat("MMM d, yyyy 'at' HH:mm", Locale.getDefault()).format(Date()) + canvas.drawText("Exported on $timestamp Β· ${books.size} books", margin, margin + 30f, subtitlePaint) + canvas.drawLine(margin, margin + 50f, pageWidth - margin, margin + 50f, dividerPaint) + + if (books.isEmpty()) { + canvas.drawText("No books to export.", margin, margin + 90f, rowMetaPaint) + } else { + val start = pageIndex * maxRowsPerPage + val end = minOf(start + maxRowsPerPage, books.size) + var y = margin + 80f + for (i in start until end) { + val book = books[i] + val statusLabel = ReadingStatus.fromStorageValue(book.readingStatus).displayLabel + val ratingLabel = if (book.rating > 0f) "β˜… %.1f".format(book.rating) else "Unrated" + val dateLabel = book.dateAdded.formatDate("MMM d, yyyy") + + canvas.drawText("${i + 1}. ${book.title}", margin, y, rowTitlePaint) + canvas.drawText("by ${book.author}", margin, y + 16f, rowMetaPaint) + val tail = "${book.category} Β· $statusLabel Β· $ratingLabel Β· Added $dateLabel" + canvas.drawText(tail, margin, y + 32f, rowMetaPaint) + canvas.drawLine(margin, y + 44f, pageWidth - margin, y + 44f, dividerPaint) + y += rowHeight + } + } + + // Footer + val footer = "Page ${pageIndex + 1} of $totalPages" + canvas.drawText(footer, pageWidth - margin - 80f, pageHeight - margin / 2f, subtitlePaint) + document.finishPage(page) + } + } + + private fun writeToDownloads(fileName: String, document: PdfDocument): Uri? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val resolver = context.contentResolver + val values = ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, fileName) + put(MediaStore.Downloads.MIME_TYPE, "application/pdf") + put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + } + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) ?: return null + resolver.openOutputStream(uri)?.use { stream -> + document.writeTo(stream) + } + uri + } else { + @Suppress("DEPRECATION") + val downloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + if (!downloadDir.exists()) downloadDir.mkdirs() + val file = File(downloadDir, fileName) + FileOutputStream(file).use { stream -> document.writeTo(stream) } + androidx.core.content.FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file + ) + } + } +} diff --git a/app/src/main/res/color/bottom_nav_item_color.xml b/app/src/main/res/color/bottom_nav_item_color.xml new file mode 100644 index 0000000..2daeb70 --- /dev/null +++ b/app/src/main/res/color/bottom_nav_item_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/accent_bar.xml b/app/src/main/res/drawable/accent_bar.xml deleted file mode 100644 index 3187483..0000000 --- a/app/src/main/res/drawable/accent_bar.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/dot_indicator.xml b/app/src/main/res/drawable/bg_circle_glass.xml similarity index 77% rename from app/src/main/res/drawable/dot_indicator.xml rename to app/src/main/res/drawable/bg_circle_glass.xml index 8c40656..bc4f056 100644 --- a/app/src/main/res/drawable/dot_indicator.xml +++ b/app/src/main/res/drawable/bg_circle_glass.xml @@ -1,5 +1,5 @@ - + diff --git a/app/src/main/res/drawable/bg_cover_placeholder.xml b/app/src/main/res/drawable/bg_cover_placeholder.xml new file mode 100644 index 0000000..1324ad4 --- /dev/null +++ b/app/src/main/res/drawable/bg_cover_placeholder.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_dot_indicator_active.xml b/app/src/main/res/drawable/bg_dot_indicator_active.xml new file mode 100644 index 0000000..8c59bf8 --- /dev/null +++ b/app/src/main/res/drawable/bg_dot_indicator_active.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_dot_indicator_inactive.xml b/app/src/main/res/drawable/bg_dot_indicator_inactive.xml new file mode 100644 index 0000000..f032615 --- /dev/null +++ b/app/src/main/res/drawable/bg_dot_indicator_inactive.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_gradient_header.xml b/app/src/main/res/drawable/bg_gradient_header.xml new file mode 100644 index 0000000..3fe2bd9 --- /dev/null +++ b/app/src/main/res/drawable/bg_gradient_header.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_progress_track.xml b/app/src/main/res/drawable/bg_progress_track.xml new file mode 100644 index 0000000..68c41dc --- /dev/null +++ b/app/src/main/res/drawable/bg_progress_track.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_splash.xml b/app/src/main/res/drawable/bg_splash.xml new file mode 100644 index 0000000..9ba378a --- /dev/null +++ b/app/src/main/res/drawable/bg_splash.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/bg_status_chip.xml b/app/src/main/res/drawable/bg_status_chip.xml new file mode 100644 index 0000000..68c41dc --- /dev/null +++ b/app/src/main/res/drawable/bg_status_chip.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/book_icon_gradient.xml b/app/src/main/res/drawable/book_icon_gradient.xml deleted file mode 100644 index 9f3ea83..0000000 --- a/app/src/main/res/drawable/book_icon_gradient.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/card_background.xml b/app/src/main/res/drawable/card_background.xml deleted file mode 100644 index d6d2f4e..0000000 --- a/app/src/main/res/drawable/card_background.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/fab_gradient.xml b/app/src/main/res/drawable/fab_gradient.xml deleted file mode 100644 index a5b1038..0000000 --- a/app/src/main/res/drawable/fab_gradient.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/gradient_circle.xml b/app/src/main/res/drawable/gradient_circle.xml deleted file mode 100644 index a6abd52..0000000 --- a/app/src/main/res/drawable/gradient_circle.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/gradient_header.xml b/app/src/main/res/drawable/gradient_header.xml deleted file mode 100644 index 6164796..0000000 --- a/app/src/main/res/drawable/gradient_header.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/gradient_header_rounded.xml b/app/src/main/res/drawable/gradient_header_rounded.xml deleted file mode 100644 index 8c55f16..0000000 --- a/app/src/main/res/drawable/gradient_header_rounded.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml index 55010e4..5303dc6 100644 --- a/app/src/main/res/drawable/ic_add.xml +++ b/app/src/main/res/drawable/ic_add.xml @@ -1,10 +1,10 @@ - + android:viewportHeight="24" + android:tint="?attr/colorOnPrimary"> + android:fillColor="@android:color/white" + android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6V13z" /> diff --git a/app/src/main/res/drawable/ic_back.xml b/app/src/main/res/drawable/ic_back.xml new file mode 100644 index 0000000..91e4fc1 --- /dev/null +++ b/app/src/main/res/drawable/ic_back.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_backup.xml b/app/src/main/res/drawable/ic_backup.xml new file mode 100644 index 0000000..a7b2198 --- /dev/null +++ b/app/src/main/res/drawable/ic_backup.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_book.xml b/app/src/main/res/drawable/ic_book.xml index e265297..48830e2 100644 --- a/app/src/main/res/drawable/ic_book.xml +++ b/app/src/main/res/drawable/ic_book.xml @@ -1,10 +1,10 @@ - + android:viewportHeight="24" + android:tint="?attr/colorOnSurface"> + android:fillColor="@android:color/white" + android:pathData="M18,2H6C4.9,2 4,2.9 4,4v16c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4C20,2.9 19.1,2 18,2zM18,20H6V4h12V20zM8,7h8v2H8V7zM8,11h8v2H8V11zM8,15h5v2H8V15z" /> diff --git a/app/src/main/res/drawable/ic_camera.xml b/app/src/main/res/drawable/ic_camera.xml new file mode 100644 index 0000000..9db2cd3 --- /dev/null +++ b/app/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000..2c527ce --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_chevron_right.xml b/app/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 0000000..bf7d54e --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_dark_mode.xml b/app/src/main/res/drawable/ic_dark_mode.xml index 8e166cd..f47f695 100644 --- a/app/src/main/res/drawable/ic_dark_mode.xml +++ b/app/src/main/res/drawable/ic_dark_mode.xml @@ -1,10 +1,10 @@ - + android:viewportHeight="24" + android:tint="?attr/colorOnSurface"> + android:fillColor="@android:color/white" + android:pathData="M9.37,5.51c-0.18,0.64 -0.27,1.31 -0.27,1.99c0,4.08 3.32,7.4 7.4,7.4c0.68,0 1.35,-0.09 1.99,-0.27C17.45,17.19 14.93,19 12,19c-3.86,0 -7,-3.14 -7,-7c0,-2.93 1.81,-5.45 4.37,-6.49zM12,3c-4.97,0 -9,4.03 -9,9c0,4.97 4.03,9 9,9c4.97,0 9,-4.03 9,-9c0,-0.46 -0.04,-0.92 -0.1,-1.36c-0.98,1.37 -2.58,2.26 -4.4,2.26c-2.98,0 -5.4,-2.42 -5.4,-5.4c0,-1.81 0.89,-3.42 2.26,-4.4C12.92,3.04 12.46,3 12,3z" /> diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..ad1eea8 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 0000000..81b70f6 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_filter.xml b/app/src/main/res/drawable/ic_filter.xml index abf319c..12a9d04 100644 --- a/app/src/main/res/drawable/ic_filter.xml +++ b/app/src/main/res/drawable/ic_filter.xml @@ -1,10 +1,10 @@ - + android:viewportHeight="24" + android:tint="?attr/colorOnSurfaceVariant"> + android:fillColor="@android:color/white" + android:pathData="M10,18h4v-2h-4V18zM3,6v2h18V6H3zM6,13h12v-2H6V13z" /> diff --git a/app/src/main/res/drawable/ic_home.xml b/app/src/main/res/drawable/ic_home.xml new file mode 100644 index 0000000..1eb9884 --- /dev/null +++ b/app/src/main/res/drawable/ic_home.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 07d5da9..43117cd 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,170 +1,23 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 2b068d1..396cbb1 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,30 +1,20 @@ - - - - - - - - + - \ No newline at end of file + android:pathData="M70,32H38c-3.3,0 -6,2.7 -6,6v32c0,3.3 2.7,6 6,6h32c3.3,0 6,-2.7 6,-6V38C76,34.7 73.3,32 70,32zM70,70H38V38h32V70z" /> + + + + diff --git a/app/src/main/res/drawable/ic_library.xml b/app/src/main/res/drawable/ic_library.xml new file mode 100644 index 0000000..2bdba0e --- /dev/null +++ b/app/src/main/res/drawable/ic_library.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_light_mode.xml b/app/src/main/res/drawable/ic_light_mode.xml new file mode 100644 index 0000000..297146d --- /dev/null +++ b/app/src/main/res/drawable/ic_light_mode.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_logo_book.xml b/app/src/main/res/drawable/ic_logo_book.xml new file mode 100644 index 0000000..d9a237e --- /dev/null +++ b/app/src/main/res/drawable/ic_logo_book.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_logout.xml b/app/src/main/res/drawable/ic_logout.xml new file mode 100644 index 0000000..6fa5dd0 --- /dev/null +++ b/app/src/main/res/drawable/ic_logout.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_pdf.xml b/app/src/main/res/drawable/ic_pdf.xml new file mode 100644 index 0000000..fabc0e9 --- /dev/null +++ b/app/src/main/res/drawable/ic_pdf.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_restore.xml b/app/src/main/res/drawable/ic_restore.xml new file mode 100644 index 0000000..83f95ac --- /dev/null +++ b/app/src/main/res/drawable/ic_restore.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_save.xml b/app/src/main/res/drawable/ic_save.xml new file mode 100644 index 0000000..4eb5289 --- /dev/null +++ b/app/src/main/res/drawable/ic_save.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000..e2f12b4 --- /dev/null +++ b/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml index 2601780..269fb7c 100644 --- a/app/src/main/res/drawable/ic_settings.xml +++ b/app/src/main/res/drawable/ic_settings.xml @@ -1,10 +1,10 @@ - + android:viewportHeight="24" + android:tint="?attr/colorOnSurfaceVariant"> + android:fillColor="@android:color/white" + android:pathData="M19.43,12.98c0.04,-0.32 0.07,-0.64 0.07,-0.98c0,-0.34 -0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.12,-0.22 -0.39,-0.3 -0.61,-0.22l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.23,-0.09 -0.49,0 -0.61,0.22l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98c0,0.33 0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.12,0.22 0.39,0.3 0.61,0.22l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65C9.54,21.82 9.75,22 10,22h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.23,0.09 0.49,0 0.61,-0.22l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64L19.43,12.98zM12,15.5c-1.93,0 -3.5,-1.57 -3.5,-3.5s1.57,-3.5 3.5,-3.5s3.5,1.57 3.5,3.5S13.93,15.5 12,15.5z" /> diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 0000000..29fb505 --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_sort.xml b/app/src/main/res/drawable/ic_sort.xml index 1d26281..411f236 100644 --- a/app/src/main/res/drawable/ic_sort.xml +++ b/app/src/main/res/drawable/ic_sort.xml @@ -1,10 +1,10 @@ - + android:viewportHeight="24" + android:tint="?attr/colorOnSurfaceVariant"> + android:fillColor="@android:color/white" + android:pathData="M3,18h6v-2H3V18zM3,6v2h18V6H3zM3,13h12v-2H3V13z" /> diff --git a/app/src/main/res/drawable/ic_star.xml b/app/src/main/res/drawable/ic_star.xml new file mode 100644 index 0000000..c64d3ff --- /dev/null +++ b/app/src/main/res/drawable/ic_star.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_star_outline.xml b/app/src/main/res/drawable/ic_star_outline.xml new file mode 100644 index 0000000..6e15479 --- /dev/null +++ b/app/src/main/res/drawable/ic_star_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_stats.xml b/app/src/main/res/drawable/ic_stats.xml deleted file mode 100644 index 1b8c255..0000000 --- a/app/src/main/res/drawable/ic_stats.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/icon_button_bg.xml b/app/src/main/res/drawable/icon_button_bg.xml deleted file mode 100644 index fe8cd99..0000000 --- a/app/src/main/res/drawable/icon_button_bg.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/drawable/icon_button_glass.xml b/app/src/main/res/drawable/icon_button_glass.xml deleted file mode 100644 index f561721..0000000 --- a/app/src/main/res/drawable/icon_button_glass.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/illustration_onboarding_1.xml b/app/src/main/res/drawable/illustration_onboarding_1.xml new file mode 100644 index 0000000..64b4351 --- /dev/null +++ b/app/src/main/res/drawable/illustration_onboarding_1.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/illustration_onboarding_2.xml b/app/src/main/res/drawable/illustration_onboarding_2.xml new file mode 100644 index 0000000..ac70764 --- /dev/null +++ b/app/src/main/res/drawable/illustration_onboarding_2.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/illustration_onboarding_3.xml b/app/src/main/res/drawable/illustration_onboarding_3.xml new file mode 100644 index 0000000..4845144 --- /dev/null +++ b/app/src/main/res/drawable/illustration_onboarding_3.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/progress_reading.xml b/app/src/main/res/drawable/progress_reading.xml new file mode 100644 index 0000000..76b4b76 --- /dev/null +++ b/app/src/main/res/drawable/progress_reading.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/progress_status_finished.xml b/app/src/main/res/drawable/progress_status_finished.xml new file mode 100644 index 0000000..7ee16ea --- /dev/null +++ b/app/src/main/res/drawable/progress_status_finished.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/progress_status_reading.xml b/app/src/main/res/drawable/progress_status_reading.xml new file mode 100644 index 0000000..ad30563 --- /dev/null +++ b/app/src/main/res/drawable/progress_status_reading.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/progress_status_want.xml b/app/src/main/res/drawable/progress_status_want.xml new file mode 100644 index 0000000..af77b4f --- /dev/null +++ b/app/src/main/res/drawable/progress_status_want.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/search_background.xml b/app/src/main/res/drawable/search_background.xml deleted file mode 100644 index 2126224..0000000 --- a/app/src/main/res/drawable/search_background.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/tag_background.xml b/app/src/main/res/drawable/tag_background.xml deleted file mode 100644 index a67e25f..0000000 --- a/app/src/main/res/drawable/tag_background.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/tag_background_secondary.xml b/app/src/main/res/drawable/tag_background_secondary.xml deleted file mode 100644 index 4f09465..0000000 --- a/app/src/main/res/drawable/tag_background_secondary.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/tag_modern.xml b/app/src/main/res/drawable/tag_modern.xml deleted file mode 100644 index bd6c6f7..0000000 --- a/app/src/main/res/drawable/tag_modern.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/tag_modern_secondary.xml b/app/src/main/res/drawable/tag_modern_secondary.xml deleted file mode 100644 index 2d6f759..0000000 --- a/app/src/main/res/drawable/tag_modern_secondary.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout/activity_add_book.xml b/app/src/main/res/layout/activity_add_book.xml deleted file mode 100644 index e1bb16f..0000000 --- a/app/src/main/res/layout/activity_add_book.xml +++ /dev/null @@ -1,183 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_auth.xml b/app/src/main/res/layout/activity_auth.xml new file mode 100644 index 0000000..4da4164 --- /dev/null +++ b/app/src/main/res/layout/activity_auth.xml @@ -0,0 +1,11 @@ + + diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml deleted file mode 100644 index 1df41fb..0000000 --- a/app/src/main/res/layout/activity_login.xml +++ /dev/null @@ -1,146 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index b03923f..f81a825 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -4,279 +4,31 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="#F8F9FA"> + android:background="?android:colorBackground"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + app:layout_constraintTop_toTopOf="parent" + app:navGraph="@navigation/main_nav_graph" /> - - - + app:layout_constraintStart_toStartOf="parent" + app:menu="@menu/bottom_nav_menu" /> diff --git a/app/src/main/res/layout/activity_onboarding.xml b/app/src/main/res/layout/activity_onboarding.xml new file mode 100644 index 0000000..a3ce36f --- /dev/null +++ b/app/src/main/res/layout/activity_onboarding.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_register.xml b/app/src/main/res/layout/activity_register.xml deleted file mode 100644 index f068047..0000000 --- a/app/src/main/res/layout/activity_register.xml +++ /dev/null @@ -1,177 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml deleted file mode 100644 index 39f53d1..0000000 --- a/app/src/main/res/layout/activity_settings.xml +++ /dev/null @@ -1,134 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_statistics.xml b/app/src/main/res/layout/activity_statistics.xml deleted file mode 100644 index 958c638..0000000 --- a/app/src/main/res/layout/activity_statistics.xml +++ /dev/null @@ -1,438 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_add_edit_book.xml b/app/src/main/res/layout/fragment_add_edit_book.xml new file mode 100644 index 0000000..0d26930 --- /dev/null +++ b/app/src/main/res/layout/fragment_add_edit_book.xml @@ -0,0 +1,371 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_book_detail.xml b/app/src/main/res/layout/fragment_book_detail.xml new file mode 100644 index 0000000..afa9681 --- /dev/null +++ b/app/src/main/res/layout/fragment_book_detail.xml @@ -0,0 +1,325 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_book_list.xml b/app/src/main/res/layout/fragment_book_list.xml new file mode 100644 index 0000000..b5bdf22 --- /dev/null +++ b/app/src/main/res/layout/fragment_book_list.xml @@ -0,0 +1,233 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_dashboard.xml b/app/src/main/res/layout/fragment_dashboard.xml new file mode 100644 index 0000000..d6ae90f --- /dev/null +++ b/app/src/main/res/layout/fragment_dashboard.xml @@ -0,0 +1,371 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_login.xml b/app/src/main/res/layout/fragment_login.xml new file mode 100644 index 0000000..d4046f0 --- /dev/null +++ b/app/src/main/res/layout/fragment_login.xml @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_register.xml b/app/src/main/res/layout/fragment_register.xml new file mode 100644 index 0000000..b8f42d0 --- /dev/null +++ b/app/src/main/res/layout/fragment_register.xml @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml new file mode 100644 index 0000000..6a34cd1 --- /dev/null +++ b/app/src/main/res/layout/fragment_settings.xml @@ -0,0 +1,233 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_book.xml b/app/src/main/res/layout/item_book.xml deleted file mode 100644 index 684f3f0..0000000 --- a/app/src/main/res/layout/item_book.xml +++ /dev/null @@ -1,142 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_book_horizontal.xml b/app/src/main/res/layout/item_book_horizontal.xml new file mode 100644 index 0000000..7589593 --- /dev/null +++ b/app/src/main/res/layout/item_book_horizontal.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_book_vertical.xml b/app/src/main/res/layout/item_book_vertical.xml new file mode 100644 index 0000000..5895c0d --- /dev/null +++ b/app/src/main/res/layout/item_book_vertical.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_dropdown.xml b/app/src/main/res/layout/item_dropdown.xml new file mode 100644 index 0000000..281c1b4 --- /dev/null +++ b/app/src/main/res/layout/item_dropdown.xml @@ -0,0 +1,8 @@ + + diff --git a/app/src/main/res/layout/item_onboarding_page.xml b/app/src/main/res/layout/item_onboarding_page.xml new file mode 100644 index 0000000..c22419e --- /dev/null +++ b/app/src/main/res/layout/item_onboarding_page.xml @@ -0,0 +1,44 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/view_indicator_dot.xml b/app/src/main/res/layout/view_indicator_dot.xml new file mode 100644 index 0000000..aaa2240 --- /dev/null +++ b/app/src/main/res/layout/view_indicator_dot.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/main/res/layout/view_settings_row.xml b/app/src/main/res/layout/view_settings_row.xml new file mode 100644 index 0000000..1ef3ab2 --- /dev/null +++ b/app/src/main/res/layout/view_settings_row.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/bottom_nav_menu.xml b/app/src/main/res/menu/bottom_nav_menu.xml new file mode 100644 index 0000000..620468c --- /dev/null +++ b/app/src/main/res/menu/bottom_nav_menu.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/app/src/main/res/navigation/auth_nav_graph.xml b/app/src/main/res/navigation/auth_nav_graph.xml new file mode 100644 index 0000000..93ca2f8 --- /dev/null +++ b/app/src/main/res/navigation/auth_nav_graph.xml @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/app/src/main/res/navigation/main_nav_graph.xml b/app/src/main/res/navigation/main_nav_graph.xml new file mode 100644 index 0000000..3a7b459 --- /dev/null +++ b/app/src/main/res/navigation/main_nav_graph.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..a816880 --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,7 @@ + + + + #3B2A0E + #0B2C4D + #0B3D2C + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 02470b7..467c76a 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,13 +1,38 @@ - - - diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 825b5c2..2e03540 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,23 +1,50 @@ - - #667EEA - #5568D3 - #F093FB - #F8F9FA - #FFFFFF - #FFFFFF - - - #1F2937 - #6B7280 - - - #10B981 - #EF4444 - #F59E0B - - - #667EEA - #E0E7FF + + #5B5FE9 + #3F44B8 + #7E82F0 + #B86DEC + #EC4FA0 + + + #FFFFFF + #F5F6FB + #EEF0F7 + + #101218 + #171A22 + #1F2330 + + + #0F172A + #475569 + #94A3B8 + + #F8FAFC + #CBD5E1 + #94A3B8 + + + #F59E0B + #3B82F6 + #10B981 + #EF4444 + + + #FEF3C7 + #DBEAFE + #D1FAE5 + + + #E2E8F0 + #2A2F3D + #FFFFFF + #000000 + + + #5B5FE9 + #8651DF + #EC4FA0 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ec1f364..018b79d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,20 +1,152 @@ + - Book Library - Login - Register - Username - Password - Email - Book Title - Author - Add Book - - + Mini Book Library + + + Track every book + Add titles, authors, covers, and ratings β€” your library, beautifully organized. + Watch your progress + Mark books as want-to-read, currently reading, or finished. Track pages as you go. + Stats & backups + See reading insights, export your collection to PDF, or back it up to JSON anytime. + Skip + Next + Get started + + + Welcome back + Sign in to keep track of your reading. + Sign in + Don\'t have an account? + Create one + + Create your account + It only takes a moment. + Create account + Already a member? + Sign in + + Username + Email address + Password + Confirm password + + + Home + Library + Settings + + Total books + Avg rating + Reading status + Recently added + See all + Add your first book to see it here. + + + My Library + Search title, author, or category + No books yet + Tap the button below to add your first book. + No matches + Try a different search term, or change your filters. + Add a book + %1$d books + %1$d of %2$d + + + Add new book + Edit book + ISBN (10 or 13 digits) + Auto-fill + Quick add by ISBN + Type an ISBN and we\'ll fetch the title, author, year, and page count from Google Books. + Found β€œ%1$s”. Double-check the details before saving. + Title + Author + Publication year + Category + Reading status + Your rating + Current page + Total pages + Personal notes + Choose cover + Remove cover + Save book + Save changes + Delete book + + + Book details + Share + Edit + Delete + Progress + Added on %1$s + No personal notes yet. + %1$d / %2$d pages + + + Settings + Appearance + Theme + Your data + Export library as PDF + Save a printable summary to Downloads. + Backup to JSON + Export your library to a JSON file in Downloads. + Restore from JSON + Pick a backup file to import. + Account + Signed in as %1$s + Sign out + About Mini Book Library + A modern Material 3 portfolio app showcasing MVVM, Room, and Coroutines. + + + Cancel + Delete + Save + Share + Undo + Open + + Delete book? + This will remove the book from your library. You can\'t undo this once you tap delete. + + Sign out? + You can sign back in any time. Your library will still be here. + + Book added to your library + Changes saved + %1$s deleted + PDF saved to Downloads + Backup saved to Downloads + Restored %1$d books + + Want to Read + Currently Reading + Finished + + Follow system + Light + Dark + + Unrated + %1$.1f stars + + + Fiction Non-Fiction Science Technology History Biography + Self-Help + Mystery + Fantasy + Romance diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index e7a5660..6b51ba5 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,8 +1,79 @@ - - + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 4df9255..c17ecbc 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -1,13 +1,8 @@ - + + - - \ No newline at end of file + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 9ee9997..7941d57 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,19 +1,13 @@ - + - + + + - - \ No newline at end of file + diff --git a/app/src/main/res/xml/file_provider_paths.xml b/app/src/main/res/xml/file_provider_paths.xml new file mode 100644 index 0000000..75786a6 --- /dev/null +++ b/app/src/main/res/xml/file_provider_paths.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/test/java/com/example/minibooklibrary/ExampleUnitTest.java b/app/src/test/java/com/example/minibooklibrary/ExampleUnitTest.java deleted file mode 100644 index e64a471..0000000 --- a/app/src/test/java/com/example/minibooklibrary/ExampleUnitTest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.example.minibooklibrary; - -import org.junit.Test; - -import static org.junit.Assert.*; - -/** - * Example local unit test, which will execute on the development machine (host). - * - * @see Testing documentation - */ -public class ExampleUnitTest { - @Test - public void addition_isCorrect() { - assertEquals(4, 2 + 2); - } -} \ No newline at end of file diff --git a/app/src/test/java/com/example/minibooklibrary/data/remote/GoogleBooksParserTest.kt b/app/src/test/java/com/example/minibooklibrary/data/remote/GoogleBooksParserTest.kt new file mode 100644 index 0000000..3ac1a76 --- /dev/null +++ b/app/src/test/java/com/example/minibooklibrary/data/remote/GoogleBooksParserTest.kt @@ -0,0 +1,126 @@ +package com.example.minibooklibrary.data.remote + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Pure JSON-parsing tests. No HTTP, no Android dependencies β€” runs as a fast JVM test. + * + * Fixtures are inline rather than under `resources/` because we want the test to be + * self-contained and easy to scan when reviewing. + */ +class GoogleBooksParserTest { + + @Test + fun `parses a complete volume entry`() { + val json = """ + { + "totalItems": 1, + "items": [ + { + "volumeInfo": { + "title": "The Pragmatic Programmer", + "authors": ["Andrew Hunt", "David Thomas"], + "publishedDate": "1999-10-30", + "categories": ["Computers / Programming"], + "pageCount": 320 + } + } + ] + } + """.trimIndent() + + val result = GoogleBooksParser.parseFirstVolume(json) + requireNotNull(result) + assertEquals("The Pragmatic Programmer", result.title) + assertEquals("Andrew Hunt", result.author) + assertEquals("Computers", result.category) // taxonomy split on " / " + assertEquals(1999, result.year) + assertEquals(320, result.totalPages) + } + + @Test + fun `returns null when items array is empty`() { + val json = """{ "totalItems": 0, "items": [] }""" + assertNull(GoogleBooksParser.parseFirstVolume(json)) + } + + @Test + fun `returns null when items array is missing`() { + val json = """{ "totalItems": 0 }""" + assertNull(GoogleBooksParser.parseFirstVolume(json)) + } + + @Test + fun `tolerates missing optional fields`() { + val json = """ + { + "items": [ + { "volumeInfo": { "title": "Bare Title" } } + ] + } + """.trimIndent() + val result = GoogleBooksParser.parseFirstVolume(json) + requireNotNull(result) + assertEquals("Bare Title", result.title) + assertEquals("", result.author) + assertEquals("", result.category) + assertEquals(0, result.year) + assertEquals(0, result.totalPages) + } + + @Test + fun `picks first author when multiple are listed`() { + val json = """ + { + "items": [ + { + "volumeInfo": { + "title": "T", + "authors": ["First", "Second", "Third"] + } + } + ] + } + """.trimIndent() + assertEquals("First", GoogleBooksParser.parseFirstVolume(json)?.author) + } + + @Test + fun `parses year-only and year-month dates`() { + assertEquals(2010, GoogleBooksParser.parsePublishedYear("2010")) + assertEquals(2010, GoogleBooksParser.parsePublishedYear("2010-08")) + assertEquals(2010, GoogleBooksParser.parsePublishedYear("2010-08-15")) + assertEquals(0, GoogleBooksParser.parsePublishedYear("")) + assertEquals(0, GoogleBooksParser.parsePublishedYear(null)) + assertEquals(0, GoogleBooksParser.parsePublishedYear("nope")) + assertEquals(0, GoogleBooksParser.parsePublishedYear("9999")) // out of plausible range + } + + @Test + fun `service returns null when http client returns null`() = kotlinx.coroutines.test.runTest { + val service = GoogleBooksService(httpClient = { _ -> null }) + assertNull(service.lookupByIsbn("9780201616224")) + } + + @Test + fun `service returns null for malformed ISBN`() = kotlinx.coroutines.test.runTest { + val service = GoogleBooksService(httpClient = { _ -> error("should not be called") }) + assertNull(service.lookupByIsbn("123")) + assertNull(service.lookupByIsbn("")) + assertNull(service.lookupByIsbn("not-numeric-at-all")) + } + + @Test + fun `service returns parsed lookup for happy path`() = kotlinx.coroutines.test.runTest { + val canned = """ + { "items": [{ "volumeInfo": { "title": "Hello", "authors": ["A"] } }] } + """.trimIndent() + val service = GoogleBooksService(httpClient = { _ -> canned }) + val result = service.lookupByIsbn("9780201616224") + requireNotNull(result) + assertEquals("Hello", result.title) + assertEquals("A", result.author) + } +} diff --git a/app/src/test/java/com/example/minibooklibrary/data/repository/BookRepositoryTest.kt b/app/src/test/java/com/example/minibooklibrary/data/repository/BookRepositoryTest.kt new file mode 100644 index 0000000..2a7b9fd --- /dev/null +++ b/app/src/test/java/com/example/minibooklibrary/data/repository/BookRepositoryTest.kt @@ -0,0 +1,79 @@ +package com.example.minibooklibrary.data.repository + +import com.example.minibooklibrary.data.local.dao.BookDao +import com.example.minibooklibrary.data.local.entity.BookEntity +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class BookRepositoryTest { + + private val dao: BookDao = mockk(relaxed = true) + private val repo = BookRepository(dao) + + @Test + fun `add stamps fresh dateAdded and lastModified`() = runTest { + val captured = slot() + coEvery { dao.insert(capture(captured)) } returns 1L + val before = System.currentTimeMillis() + repo.add( + BookEntity( + userId = 1L, + title = "Test", + author = "Me", + category = "Fiction", + year = 2024, + dateAdded = 0L, + lastModified = 0L + ) + ) + val saved = captured.captured + assertNotNull(saved) + // The repo refreshes both timestamps to "now". Allow a small delta. + assertTrue(saved.dateAdded >= before) + assertTrue(saved.lastModified >= before) + assertEquals(saved.dateAdded, saved.lastModified) + } + + @Test + fun `update refreshes lastModified but keeps dateAdded`() = runTest { + val original = BookEntity( + id = 5L, + userId = 1L, + title = "Test", + author = "Me", + category = "Fiction", + year = 2024, + dateAdded = 1_000L, + lastModified = 1_000L + ) + val captured = slot() + coEvery { dao.update(capture(captured)) } returns 1 + repo.update(original) + assertEquals(1_000L, captured.captured.dateAdded) + assertTrue(captured.captured.lastModified >= 1_000L) + } + + @Test + fun `delete forwards to dao`() = runTest { + val book = BookEntity( + id = 1L, + userId = 1L, + title = "T", + author = "A", + category = "Fiction", + year = 2024 + ) + coEvery { dao.delete(book) } returns 1 + repo.delete(book) + coVerify { dao.delete(book) } + } +} diff --git a/app/src/test/java/com/example/minibooklibrary/data/repository/UserRepositoryTest.kt b/app/src/test/java/com/example/minibooklibrary/data/repository/UserRepositoryTest.kt new file mode 100644 index 0000000..f71a57f --- /dev/null +++ b/app/src/test/java/com/example/minibooklibrary/data/repository/UserRepositoryTest.kt @@ -0,0 +1,135 @@ +package com.example.minibooklibrary.data.repository + +import com.example.minibooklibrary.data.local.dao.UserDao +import com.example.minibooklibrary.data.local.entity.UserEntity +import com.example.minibooklibrary.data.preferences.PreferencesManager +import com.example.minibooklibrary.util.PasswordHasher +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class UserRepositoryTest { + + private val userDao: UserDao = mockk(relaxed = true) + private val preferences: PreferencesManager = mockk(relaxed = true) + private val repo = UserRepository(userDao, preferences) + + @Test + fun `register fails when username is shorter than 3 chars`() = runTest { + val result = repo.register("ab", "ab@example.com", "password123") + assertTrue(result is AuthResult.Failure) + assertEquals( + AuthResult.FailureReason.VALIDATION, + (result as AuthResult.Failure).reason + ) + } + + @Test + fun `register fails when email is malformed`() = runTest { + val result = repo.register("alice", "not-an-email", "password123") + assertTrue(result is AuthResult.Failure) + } + + @Test + fun `register fails when password is shorter than 6 chars`() = runTest { + val result = repo.register("alice", "alice@example.com", "12345") + assertTrue(result is AuthResult.Failure) + } + + @Test + fun `register fails when username is taken`() = runTest { + coEvery { userDao.countByUsername("alice") } returns 1 + val result = repo.register("alice", "alice@example.com", "password123") + assertEquals( + AuthResult.FailureReason.USERNAME_TAKEN, + (result as AuthResult.Failure).reason + ) + } + + @Test + fun `register inserts new user with hashed password`() = runTest { + coEvery { userDao.countByUsername("alice") } returns 0 + coEvery { userDao.insert(any()) } returns 42L + + val result = repo.register("alice", "alice@example.com", "password123") + + assertTrue(result is AuthResult.Success) + val success = result as AuthResult.Success + assertEquals(42L, success.userId) + assertEquals("alice", success.username) + + coVerify { + userDao.insert(match { entity -> + entity.username == "alice" && + entity.email == "alice@example.com" && + entity.passwordHash != "password123" && // ensure not plaintext + entity.passwordSalt.isNotBlank() + }) + } + } + + @Test + fun `login succeeds with valid credentials and persists session`() = runTest { + val salt = PasswordHasher.newSalt() + val hash = PasswordHasher.hash("password123", salt) + coEvery { userDao.findByUsername("alice") } returns UserEntity( + id = 7L, + username = "alice", + email = "alice@example.com", + passwordHash = hash, + passwordSalt = salt + ) + + val result = repo.login("alice", "password123") + + assertTrue(result is AuthResult.Success) + verify { preferences.currentUserId = 7L } + verify { preferences.currentUsername = "alice" } + } + + @Test + fun `login fails for unknown username`() = runTest { + coEvery { userDao.findByUsername("ghost") } returns null + val result = repo.login("ghost", "anything") + assertTrue(result is AuthResult.Failure) + assertEquals( + AuthResult.FailureReason.INVALID_CREDENTIALS, + (result as AuthResult.Failure).reason + ) + } + + @Test + fun `login fails when password is wrong`() = runTest { + val salt = PasswordHasher.newSalt() + coEvery { userDao.findByUsername("alice") } returns UserEntity( + id = 7L, + username = "alice", + email = "alice@example.com", + passwordHash = PasswordHasher.hash("correct", salt), + passwordSalt = salt + ) + + val result = repo.login("alice", "wrong") + + assertTrue(result is AuthResult.Failure) + assertEquals( + AuthResult.FailureReason.INVALID_CREDENTIALS, + (result as AuthResult.Failure).reason + ) + } + + @Test + fun `logout clears session in preferences`() { + // preferences is relaxed β€” clearSession() is auto-stubbed. + repo.logout() + verify { preferences.clearSession() } + } +} diff --git a/app/src/test/java/com/example/minibooklibrary/domain/EnumStorageTest.kt b/app/src/test/java/com/example/minibooklibrary/domain/EnumStorageTest.kt new file mode 100644 index 0000000..9e99619 --- /dev/null +++ b/app/src/test/java/com/example/minibooklibrary/domain/EnumStorageTest.kt @@ -0,0 +1,39 @@ +package com.example.minibooklibrary.domain + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Pin the persisted [storageValue]s for each enum. Future contributors who reorder or + * rename a constant must update these tests, which acts as a tripwire against breaking + * existing user data. + */ +class EnumStorageTest { + + @Test + fun `ReadingStatus storage values are stable`() { + assertEquals("want_to_read", ReadingStatus.WANT_TO_READ.storageValue) + assertEquals("currently_reading", ReadingStatus.CURRENTLY_READING.storageValue) + assertEquals("finished", ReadingStatus.FINISHED.storageValue) + } + + @Test + fun `ReadingStatus from storage value falls back to want-to-read`() { + assertEquals(ReadingStatus.WANT_TO_READ, ReadingStatus.fromStorageValue(null)) + assertEquals(ReadingStatus.WANT_TO_READ, ReadingStatus.fromStorageValue("garbage")) + assertEquals(ReadingStatus.FINISHED, ReadingStatus.fromStorageValue("finished")) + } + + @Test + fun `SortOrder storage values are stable`() { + assertEquals("date_desc", SortOrder.DATE_ADDED_DESC.storageValue) + assertEquals("title_asc", SortOrder.TITLE_ASC.storageValue) + assertEquals("rating_desc", SortOrder.RATING_DESC.storageValue) + } + + @Test + fun `StatusFilter ALL is the identity filter default`() { + assertEquals(StatusFilter.ALL, StatusFilter.fromStorageValue(null)) + assertEquals(StatusFilter.WANT_TO_READ, StatusFilter.fromStorageValue("want_to_read")) + } +} diff --git a/app/src/test/java/com/example/minibooklibrary/ui/auth/AuthViewModelTest.kt b/app/src/test/java/com/example/minibooklibrary/ui/auth/AuthViewModelTest.kt new file mode 100644 index 0000000..fd8edbe --- /dev/null +++ b/app/src/test/java/com/example/minibooklibrary/ui/auth/AuthViewModelTest.kt @@ -0,0 +1,95 @@ +package com.example.minibooklibrary.ui.auth + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import app.cash.turbine.test +import com.example.minibooklibrary.data.repository.AuthResult +import com.example.minibooklibrary.data.repository.UserRepository +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AuthViewModelTest { + + @get:Rule + val instantExecutor = InstantTaskExecutorRule() + + private val testDispatcher = StandardTestDispatcher() + @Before fun setUp() = Dispatchers.setMain(testDispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + private val userRepo: UserRepository = mockk(relaxed = true) + private fun newVm() = AuthViewModel(userRepo) + + @Test + fun `mismatched passwords produce inline error`() = runTest(testDispatcher) { + val vm = newVm() + vm.register("alice", "alice@example.com", "abc123", "differs") + testDispatcher.scheduler.advanceUntilIdle() + assertEquals("Passwords don't match", vm.uiState.value.errorMessage) + } + + @Test + fun `successful login emits LoggedIn event and unsets loading`() = runTest(testDispatcher) { + coEvery { userRepo.login("alice", "abc123") } returns + AuthResult.Success(7L, "alice") + val vm = newVm() + + vm.events.test { + vm.login("alice", "abc123") + testDispatcher.scheduler.advanceUntilIdle() + val event = awaitItem() + assertTrue(event is AuthEvent.LoggedIn) + assertEquals(7L, (event as AuthEvent.LoggedIn).userId) + assertEquals(false, vm.uiState.value.isLoading) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `failed login surfaces error message`() = runTest(testDispatcher) { + coEvery { userRepo.login(any(), any()) } returns + AuthResult.Failure(AuthResult.FailureReason.INVALID_CREDENTIALS, "nope") + val vm = newVm() + vm.login("alice", "wrong") + testDispatcher.scheduler.advanceUntilIdle() + assertNotNull(vm.uiState.value.errorMessage) + assertEquals("nope", vm.uiState.value.errorMessage) + } + + @Test + fun `consumeError clears the error after the UI handles it`() = runTest(testDispatcher) { + coEvery { userRepo.login(any(), any()) } returns + AuthResult.Failure(AuthResult.FailureReason.INVALID_CREDENTIALS, "nope") + val vm = newVm() + vm.login("a", "b") + testDispatcher.scheduler.advanceUntilIdle() + vm.consumeError() + assertEquals(null, vm.uiState.value.errorMessage) + } + + @Test + fun `logout clears session and emits LoggedOut`() = runTest(testDispatcher) { + // userRepo is relaxed; logout() needs no explicit stubbing. + val vm = newVm() + vm.events.test { + vm.logout() + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(AuthEvent.LoggedOut, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/app/src/test/java/com/example/minibooklibrary/ui/books/edit/AddEditBookViewModelTest.kt b/app/src/test/java/com/example/minibooklibrary/ui/books/edit/AddEditBookViewModelTest.kt new file mode 100644 index 0000000..a1e6750 --- /dev/null +++ b/app/src/test/java/com/example/minibooklibrary/ui/books/edit/AddEditBookViewModelTest.kt @@ -0,0 +1,211 @@ +package com.example.minibooklibrary.ui.books.edit + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import app.cash.turbine.test +import com.example.minibooklibrary.data.local.entity.BookEntity +import com.example.minibooklibrary.data.preferences.PreferencesManager +import com.example.minibooklibrary.data.remote.GoogleBooksService +import com.example.minibooklibrary.data.repository.BookRepository +import com.example.minibooklibrary.domain.BookLookup +import com.example.minibooklibrary.domain.ReadingStatus +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.Calendar + +@OptIn(ExperimentalCoroutinesApi::class) +class AddEditBookViewModelTest { + + @get:Rule + val instantExecutor = InstantTaskExecutorRule() + + private val testDispatcher = StandardTestDispatcher() + @Before fun setUp() = Dispatchers.setMain(testDispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + private val repo: BookRepository = mockk(relaxed = true) + private val prefs: PreferencesManager = mockk { + every { currentUserId } returns 1L + } + private val googleBooks: GoogleBooksService = mockk(relaxed = true) + + private fun newVm() = AddEditBookViewModel(repo, prefs, googleBooks) + + @Test + fun `validation fails when title is blank`() = runTest(testDispatcher) { + val vm = newVm() + vm.updateField { copy(title = "", author = "Me", year = "2024") } + vm.save() + testDispatcher.scheduler.advanceUntilIdle() + assertNotNull(vm.validation.value.titleError) + assertFalse(vm.validation.value.isValid) + } + + @Test + fun `validation fails for year out of range`() = runTest(testDispatcher) { + val futureYear = Calendar.getInstance().get(Calendar.YEAR) + 5 + val vm = newVm() + vm.updateField { copy(title = "T", author = "A", year = futureYear.toString()) } + vm.save() + testDispatcher.scheduler.advanceUntilIdle() + assertNotNull(vm.validation.value.yearError) + } + + @Test + fun `progress validation requires total pages when currently reading`() = runTest(testDispatcher) { + val vm = newVm() + vm.updateField { + copy( + title = "T", + author = "A", + year = "2024", + readingStatus = ReadingStatus.CURRENTLY_READING, + currentPage = "10", + totalPages = "0" + ) + } + vm.save() + testDispatcher.scheduler.advanceUntilIdle() + assertNotNull(vm.validation.value.pagesError) + } + + @Test + fun `progress validation rejects current greater than total`() = runTest(testDispatcher) { + val vm = newVm() + vm.updateField { + copy( + title = "T", + author = "A", + year = "2024", + readingStatus = ReadingStatus.CURRENTLY_READING, + currentPage = "300", + totalPages = "200" + ) + } + vm.save() + testDispatcher.scheduler.advanceUntilIdle() + assertNotNull(vm.validation.value.pagesError) + } + + @Test + fun `valid form persists a new book and emits Saved event`() = runTest(testDispatcher) { + coEvery { repo.add(any()) } returns 1L + val vm = newVm() + vm.updateField { + copy( + title = "Pragmatic Programmer", + author = "Andy Hunt", + category = "Tech", + year = "1999", + readingStatus = ReadingStatus.FINISHED, + rating = 4.5f + ) + } + + vm.events.test { + vm.save() + testDispatcher.scheduler.advanceUntilIdle() + val event = awaitItem() + assertTrue(event is EditEvent.Saved) + assertFalse((event as EditEvent.Saved).wasEdit) + cancelAndIgnoreRemainingEvents() + } + coVerify { + repo.add(match { + it.title == "Pragmatic Programmer" && + it.author == "Andy Hunt" && + it.userId == 1L && + it.readingStatus == ReadingStatus.FINISHED.storageValue && + it.rating == 4.5f + }) + } + } + + @Test + fun `loadForEdit hydrates form and marks isEditing true`() = runTest(testDispatcher) { + val existing = BookEntity( + id = 7L, + userId = 1L, + title = "Existing", + author = "Author", + category = "History", + year = 2010, + readingStatus = ReadingStatus.FINISHED.storageValue, + rating = 3.0f + ) + coEvery { repo.findById(7L) } returns existing + val vm = newVm() + vm.loadForEdit(7L) + testDispatcher.scheduler.advanceUntilIdle() + val form = vm.form.value + assertTrue(form.isEditing) + assertEquals("Existing", form.title) + assertEquals(ReadingStatus.FINISHED, form.readingStatus) + assertEquals(3.0f, form.rating, 0.0f) + } + + @Test + fun `lookupIsbn populates blank fields without overwriting user input`() = runTest(testDispatcher) { + coEvery { googleBooks.lookupByIsbn("9780201616224") } returns BookLookup( + title = "Remote Title", + author = "Remote Author", + category = "Tech", + year = 1999, + totalPages = 320 + ) + val vm = newVm() + // User has already typed a custom title β€” must NOT be overwritten. + vm.updateField { copy(title = "My Custom Title") } + vm.lookupIsbn("9780201616224") + testDispatcher.scheduler.advanceUntilIdle() + + val form = vm.form.value + assertEquals("My Custom Title", form.title) // preserved + assertEquals("Remote Author", form.author) // populated + assertEquals("Tech", form.category) // overrode the default-only "Fiction" + assertEquals("1999", form.year) + assertEquals("320", form.totalPages) + assertFalse(form.isLooking) + } + + @Test + fun `lookupIsbn surfaces a failure event when service returns null`() = runTest(testDispatcher) { + coEvery { googleBooks.lookupByIsbn(any()) } returns null + val vm = newVm() + vm.events.test { + vm.lookupIsbn("9780000000000") + testDispatcher.scheduler.advanceUntilIdle() + val event = awaitItem() + assertTrue(event is EditEvent.LookupFailed) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `lookupIsbn rejects too-short input without hitting the network`() = runTest(testDispatcher) { + val vm = newVm() + vm.events.test { + vm.lookupIsbn("123") + val event = awaitItem() + assertTrue(event is EditEvent.LookupFailed) + cancelAndIgnoreRemainingEvents() + } + // Service must not have been called for an obviously invalid ISBN. + coVerify(exactly = 0) { googleBooks.lookupByIsbn(any()) } + } +} diff --git a/app/src/test/java/com/example/minibooklibrary/ui/books/list/BookListViewModelTest.kt b/app/src/test/java/com/example/minibooklibrary/ui/books/list/BookListViewModelTest.kt new file mode 100644 index 0000000..1ae3530 --- /dev/null +++ b/app/src/test/java/com/example/minibooklibrary/ui/books/list/BookListViewModelTest.kt @@ -0,0 +1,174 @@ +package com.example.minibooklibrary.ui.books.list + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import app.cash.turbine.test +import com.example.minibooklibrary.data.local.entity.BookEntity +import com.example.minibooklibrary.data.preferences.PreferencesManager +import com.example.minibooklibrary.data.repository.BookRepository +import com.example.minibooklibrary.domain.ReadingStatus +import com.example.minibooklibrary.domain.SortOrder +import com.example.minibooklibrary.domain.StatusFilter +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class BookListViewModelTest { + + @get:Rule + val instantExecutor = InstantTaskExecutorRule() + + private val testDispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(testDispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + private val booksFlow = MutableStateFlow>(emptyList()) + + private val repo: BookRepository = mockk(relaxed = true) { + every { observeBooks(any()) } returns booksFlow + } + + // relaxed = true means property setters return Unit by default β€” no need to stub. + private val prefs: PreferencesManager = mockk(relaxed = true) { + every { currentUserId } returns 1L + every { lastSortOrder } returns SortOrder.DATE_ADDED_DESC + every { lastStatusFilter } returns StatusFilter.ALL + } + + private fun newVm() = BookListViewModel(repo, prefs) + + private fun book(id: Long, title: String, status: ReadingStatus, rating: Float = 0f, added: Long = id) = + BookEntity( + id = id, + userId = 1L, + title = title, + author = "A", + category = "Fiction", + year = 2024, + readingStatus = status.storageValue, + rating = rating, + dateAdded = added, + lastModified = added + ) + + @Test + fun `empty state surfaces hasNoBooksAtAll`() = runTest(testDispatcher) { + val vm = newVm() + booksFlow.value = emptyList() + vm.uiState.test { + // Wait until non-loading state arrives. + var state = awaitItem() + while (state.isLoading) state = awaitItem() + assertTrue(state.hasNoBooksAtAll) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `search filters by title and author`() = runTest(testDispatcher) { + val vm = newVm() + booksFlow.value = listOf( + book(1L, "Atomic Habits", ReadingStatus.FINISHED), + book(2L, "Deep Work", ReadingStatus.WANT_TO_READ) + ) + vm.uiState.test { + // Drain initial states. + var state = awaitItem() + while (state.isLoading) state = awaitItem() + // Wait until the loaded books arrive (at least one item). + while (state.books.size < 2) state = awaitItem() + + vm.setQuery("habit") + // Look for the filtered emission. + do { state = awaitItem() } while (state.query != "habit") + assertEquals(1, state.books.size) + assertEquals("Atomic Habits", state.books.first().title) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `filter by reading status excludes others`() = runTest(testDispatcher) { + val vm = newVm() + booksFlow.value = listOf( + book(1L, "Done", ReadingStatus.FINISHED), + book(2L, "Reading", ReadingStatus.CURRENTLY_READING), + book(3L, "Wishlist", ReadingStatus.WANT_TO_READ) + ) + vm.uiState.test { + var state = awaitItem() + while (state.isLoading || state.books.size < 3) state = awaitItem() + + vm.setStatusFilter(StatusFilter.CURRENTLY_READING) + do { state = awaitItem() } while (state.statusFilter != StatusFilter.CURRENTLY_READING) + assertEquals(1, state.books.size) + assertEquals("Reading", state.books.first().title) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `sort by title A-Z is alphabetical case-insensitive`() = runTest(testDispatcher) { + val vm = newVm() + booksFlow.value = listOf( + book(1L, "banana", ReadingStatus.WANT_TO_READ, added = 2L), + book(2L, "Apple", ReadingStatus.WANT_TO_READ, added = 1L), + book(3L, "cherry", ReadingStatus.WANT_TO_READ, added = 3L) + ) + vm.uiState.test { + var state = awaitItem() + while (state.isLoading || state.books.size < 3) state = awaitItem() + + vm.setSortOrder(SortOrder.TITLE_ASC) + do { state = awaitItem() } while (state.sortOrder != SortOrder.TITLE_ASC) + assertEquals(listOf("Apple", "banana", "cherry"), state.books.map { it.title }) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `sort and filter persist to preferences`() = runTest(testDispatcher) { + val vm = newVm() + vm.setSortOrder(SortOrder.RATING_DESC) + vm.setStatusFilter(StatusFilter.FINISHED) + verify { prefs.lastSortOrder = SortOrder.RATING_DESC } + verify { prefs.lastStatusFilter = StatusFilter.FINISHED } + } + + @Test + fun `delete forwards to repository`() = runTest(testDispatcher) { + val vm = newVm() + val target = book(99L, "Doomed", ReadingStatus.FINISHED) + coEvery { repo.delete(target) } returns 1 + vm.delete(target) + testDispatcher.scheduler.advanceUntilIdle() + coVerify { repo.delete(target) } + } + + @Test + fun `restore re-adds previously deleted book`() = runTest(testDispatcher) { + val vm = newVm() + val target = book(99L, "Saved", ReadingStatus.FINISHED) + coEvery { repo.add(target) } returns 99L + vm.restore(target) + testDispatcher.scheduler.advanceUntilIdle() + coVerify { repo.add(target) } + } +} diff --git a/app/src/test/java/com/example/minibooklibrary/util/PasswordHasherTest.kt b/app/src/test/java/com/example/minibooklibrary/util/PasswordHasherTest.kt new file mode 100644 index 0000000..ba9d774 --- /dev/null +++ b/app/src/test/java/com/example/minibooklibrary/util/PasswordHasherTest.kt @@ -0,0 +1,48 @@ +package com.example.minibooklibrary.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PasswordHasherTest { + + @Test + fun `hash is deterministic for same salt and password`() { + val salt = "abc123" + val a = PasswordHasher.hash("hunter2", salt) + val b = PasswordHasher.hash("hunter2", salt) + assertEquals(a, b) + } + + @Test + fun `different salts produce different hashes for same password`() { + val a = PasswordHasher.hash("hunter2", "salt1") + val b = PasswordHasher.hash("hunter2", "salt2") + assertNotEquals(a, b) + } + + @Test + fun `verify accepts the correct password`() { + val salt = PasswordHasher.newSalt() + val hash = PasswordHasher.hash("hunter2", salt) + assertTrue(PasswordHasher.verify("hunter2", salt, hash)) + } + + @Test + fun `verify rejects an incorrect password`() { + val salt = PasswordHasher.newSalt() + val hash = PasswordHasher.hash("hunter2", salt) + assertFalse(PasswordHasher.verify("hunter3", salt, hash)) + } + + @Test + fun `verify is constant time-ish for hashes of same length`() { + // Not a real timing test; just confirms we don't bail on first mismatch by length. + val salt = PasswordHasher.newSalt() + val correct = PasswordHasher.hash("password", salt) + val tamperedSameLength = correct.dropLast(2) + "FF" + assertFalse(PasswordHasher.verify("password", salt, tamperedSameLength)) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 3756278..bf90b0b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,8 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. +// Top-level build file. Plugins are declared here, then applied per-module. plugins { alias(libs.plugins.android.application) apply false -} \ No newline at end of file + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.parcelize) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.navigation.safeargs) apply false +} diff --git a/gradle.properties b/gradle.properties index 4387edc..2db74f3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,21 +1,13 @@ # Project-wide Gradle settings. -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. For more details, visit -# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects -# org.gradle.parallel=true -# AndroidX package structure to make it clearer which packages are bundled with the -# Android operating system, and which are packaged with your app's APK -# https://developer.android.com/topic/libraries/support-library/androidx-rn +# IDE settings configured through Android Studio override these. +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true + +# AndroidX android.useAndroidX=true -# Enables namespacing of each library's R class so that its R class includes only the -# resources declared in the library itself and none from the library's dependencies, -# thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true + +# Kotlin +kotlin.code.style=official +kotlin.incremental=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8104049..cf08e18 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,22 +1,89 @@ [versions] -agp = "8.13.1" +# Build tooling β€” pinned to versions that match the existing Gradle 8.13 wrapper. +agp = "8.11.0" +kotlin = "2.0.21" +ksp = "2.0.21-1.0.28" + +# AndroidX core +coreKtx = "1.13.1" +appcompat = "1.7.0" +activity = "1.9.3" +fragment = "1.8.5" +constraintlayout = "2.2.0" +material = "1.12.0" +recyclerview = "1.3.2" +viewpager2 = "1.1.0" +swiperefreshlayout = "1.1.0" + +# Lifecycle / ViewModel / LiveData +lifecycle = "2.8.7" + +# Coroutines +coroutines = "1.9.0" + +# Room +room = "2.6.1" + +# Navigation +navigation = "2.8.5" + +# DataStore / Preferences +preference = "1.2.1" + +# Image loading +coil = "2.7.0" + +# Testing junit = "4.13.2" -junitVersion = "1.3.0" -espressoCore = "3.7.0" -appcompat = "1.7.1" -material = "1.13.0" -activity = "1.12.1" -constraintlayout = "2.2.1" +mockk = "1.13.13" +coroutinesTest = "1.9.0" +archCoreTesting = "2.2.0" +turbine = "1.2.0" +roomTesting = "2.6.1" +junitExt = "1.2.1" +espresso = "3.6.1" [libraries] -junit = { group = "junit", name = "junit", version.ref = "junit" } -ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } -espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } -appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity" } +androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" } +androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } +androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } +androidx-viewpager2 = { group = "androidx.viewpager2", name = "viewpager2", version.ref = "viewpager2" } +androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefreshlayout" } +androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preference" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } -activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } -constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } + +androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" } +androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } + +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } + +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "roomTesting" } + +androidx-navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigation" } +androidx-navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigation" } + +coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" } + +junit = { group = "junit", name = "junit", version.ref = "junit" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" } +androidx-arch-core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "archCoreTesting" } +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitExt" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } - +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +navigation-safeargs = { id = "androidx.navigation.safeargs.kotlin", version.ref = "navigation" } diff --git a/screenshots/.gitkeep b/screenshots/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/screenshots/README.md b/screenshots/README.md new file mode 100644 index 0000000..7c51741 --- /dev/null +++ b/screenshots/README.md @@ -0,0 +1,42 @@ +# Screenshots + +The README references the following filenames. Drop your captures here using these exact +names so the markdown renders without further editing. + +| File | Screen | What to capture | +| ---- | ------ | --------------- | +| `01_onboarding.png` | Onboarding | The first onboarding slide β€” illustration + title + body + "Next" button | +| `02_login.png` | Login | The login form with the brand logo, hint text visible | +| `03_dashboard.png` | Dashboard | After signing in, with at least 5 books added so the stats card shows real numbers | +| `04_library.png` | Library list | The library tab with several books, search bar empty | +| `05_detail.png` | Book detail | A book in "Currently Reading" state, with progress bar visible and rating set | +| `06_edit.png` | Add / Edit | The Add Book form mid-typing, dropdowns visible | +| `07_settings.png` | Settings | The settings tab, with theme toggle row and data section visible | +| `08_dark_mode.png` | Dark mode | The Dashboard in dark mode (toggle Dark in Settings, then capture) | +| `09_pdf.png` | PDF preview | Open the exported PDF in a viewer, capture the first page | + +## How to capture + +### From an emulator (recommended for clean dimensions) + +1. Run the app on an Android Studio emulator with Pixel 6 / Pixel 7 device profile. +2. Sign in, add 5–10 sample books with varied statuses + ratings + cover images so the + screens look populated. +3. In the emulator window, click the camera icon (or `Ctrl + S` on Windows) to save a + screenshot. Files land in `~/Desktop` by default. +4. Rename to the table above and copy here. + +### From a real device + +1. Build and install the debug APK: `./gradlew installDebug` +2. Use device-native screenshot (Power + Volume Down). +3. Pull via ADB: `adb pull /sdcard/Pictures/Screenshots/` + +## Recommended size + +- **Width:** 1080 px (Pixel-class density-1) +- **Height:** ~2340 px for full-screen captures, or trimmed to the relevant region +- **Format:** PNG + +The README renders these in a 3-column table β€” ~360 px wide each. Don't worry about +exact pixel parity; just keep aspect ratios consistent so the table doesn't get jagged. From 0f0077a42effed66509a0791618adf55aad0e437 Mon Sep 17 00:00:00 2001 From: Sultan Zhalifunnas Musyaffa Date: Sun, 10 May 2026 01:08:16 +0700 Subject: [PATCH 2/4] Replace android.util.Patterns with Kotlin regex for email validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `android.util.Patterns.EMAIL_ADDRESS` is a static field that's null in JVM unit tests β€” the Android stub jar doesn't initialize it, and AGP's `isReturnDefaultValues = true` only handles method calls, not field access. That made every UserRepository test that exercised the register() path NPE before even reaching the assertion. Switching to a hand-rolled Kotlin Regex makes the validation work identically on the device and in JVM tests, with no extra dependency. Pattern is practical RFC 5322-ish β€” sufficient to bounce obvious typos at the form layer and matches the spirit of what `Patterns.EMAIL_ADDRESS` does anyway. Co-Authored-By: Claude Opus 4.7 --- .../data/repository/UserRepository.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/minibooklibrary/data/repository/UserRepository.kt b/app/src/main/java/com/example/minibooklibrary/data/repository/UserRepository.kt index 7db4b18..56b0cc0 100644 --- a/app/src/main/java/com/example/minibooklibrary/data/repository/UserRepository.kt +++ b/app/src/main/java/com/example/minibooklibrary/data/repository/UserRepository.kt @@ -98,10 +98,20 @@ class UserRepository( private fun validate(username: String, email: String, password: String): ValidationResult { if (username.length < 3) return ValidationResult.Invalid("Username must be at least 3 characters") - if (email.isEmpty() || !android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()) { + if (email.isEmpty() || !EMAIL_PATTERN.matches(email)) { return ValidationResult.Invalid("Please enter a valid email address") } if (password.length < 6) return ValidationResult.Invalid("Password must be at least 6 characters") return ValidationResult.Valid } + + companion object { + // Pure-Kotlin email regex. Replaces android.util.Patterns.EMAIL_ADDRESS so JVM + // unit tests don't NPE on the static-field access (the Android stub jar leaves + // those fields null, and `isReturnDefaultValues` only handles methods). + // Practical RFC 5322-ish β€” good enough to bounce obvious typos at the form level. + private val EMAIL_PATTERN = Regex( + "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$" + ) + } } From e4fbdbc918e996df882eaf310ce144ecebaa7891 Mon Sep 17 00:00:00 2001 From: Sultan Zhalifunnas Musyaffa Date: Sun, 10 May 2026 01:12:01 +0700 Subject: [PATCH 3/4] Restore L suffix on long defaultValue in navigation graph Safe Args' XML parser requires the L suffix for long defaultValues: error: Failed to parse defaultValue '0' as long The earlier audit "fix" that stripped the suffix was wrong. The original defaultValue="0L" was correct. Reverting to that form. Co-Authored-By: Claude Opus 4.7 --- app/src/main/res/navigation/main_nav_graph.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/navigation/main_nav_graph.xml b/app/src/main/res/navigation/main_nav_graph.xml index 3a7b459..fb0bd61 100644 --- a/app/src/main/res/navigation/main_nav_graph.xml +++ b/app/src/main/res/navigation/main_nav_graph.xml @@ -54,7 +54,7 @@ tools:layout="@layout/fragment_add_edit_book"> From 36ccc825bcbfbcb78777fe9862f88ab9afd07ba8 Mon Sep 17 00:00:00 2001 From: Sultan Zhalifunnas Musyaffa Date: Sun, 10 May 2026 01:31:47 +0700 Subject: [PATCH 4/4] Make CI green: fix adaptive-icon SDK qualifier, add org.json test dep, silence Room warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes surfaced by running ./gradlew testDebugUnitTest assembleDebug locally against AGP 8.11.0 / Gradle 8.13 / JDK 21 (Android Studio jbr): 1. mipmap-anydpi/ic_launcher{,_round}.xml moved to mipmap-anydpi-v26/ AAPT rejects on minSdk 21 unless the resource is gated by the v26 qualifier β€” adaptive icons are an API 26+ feature. The v26 qualifier tells the resource system to only use these XMLs on Android 8+, while older devices keep falling back to the raster mipmap-*dpi/*.webp variants. 2. testImplementation org.json:json:20240303 GoogleBooksParser uses org.json.JSONObject. On-device that's the platform impl, but the JVM unit-test android.jar stub leaves it unimplemented, so calls silently returned defaults and the parser always emitted null. Adding the reference impl as a test dep makes the parser execute the same code path in tests as on a real device. 3. exportSchema = false on the Room database The "Schema export directory was not provided" warning is noisy and we don't ship a v2 schema yet. Comment in the source explains how to flip it back on. Verification: 51/51 unit tests pass; assembleDebug produces a valid app-debug.apk. Co-Authored-By: Claude Opus 4.7 --- app/build.gradle.kts | 1 + .../com/example/minibooklibrary/data/local/BookDatabase.kt | 5 ++++- .../res/{mipmap-anydpi => mipmap-anydpi-v26}/ic_launcher.xml | 0 .../ic_launcher_round.xml | 0 gradle/libs.versions.toml | 5 +++++ 5 files changed, 10 insertions(+), 1 deletion(-) rename app/src/main/res/{mipmap-anydpi => mipmap-anydpi-v26}/ic_launcher.xml (100%) rename app/src/main/res/{mipmap-anydpi => mipmap-anydpi-v26}/ic_launcher_round.xml (100%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 393a73a..38c9a5b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -104,6 +104,7 @@ dependencies { testImplementation(libs.androidx.arch.core.testing) testImplementation(libs.turbine) testImplementation(libs.androidx.room.testing) + testImplementation(libs.org.json) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/java/com/example/minibooklibrary/data/local/BookDatabase.kt b/app/src/main/java/com/example/minibooklibrary/data/local/BookDatabase.kt index 00cca82..f5a30b6 100644 --- a/app/src/main/java/com/example/minibooklibrary/data/local/BookDatabase.kt +++ b/app/src/main/java/com/example/minibooklibrary/data/local/BookDatabase.kt @@ -21,7 +21,10 @@ import com.example.minibooklibrary.data.local.entity.UserEntity @Database( entities = [UserEntity::class, BookEntity::class], version = 1, - exportSchema = true + // Schema export emits CI-time JSON snapshots to track migrations. Off for now to keep + // the build noise-free; flip on (and configure room.schemaLocation) when you need to + // ship a v2 schema with a Migration. + exportSchema = false ) abstract class BookDatabase : RoomDatabase() { diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from app/src/main/res/mipmap-anydpi/ic_launcher.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 100% rename from app/src/main/res/mipmap-anydpi/ic_launcher_round.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cf08e18..a96771f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,6 +42,7 @@ turbine = "1.2.0" roomTesting = "2.6.1" junitExt = "1.2.1" espresso = "3.6.1" +orgJson = "20240303" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -81,6 +82,10 @@ turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitExt" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } +# Reference org.json impl β€” Android ships it on-device but the unit test stub jar +# leaves it stubbed-out, so we need a real impl on the test classpath. +org-json = { group = "org.json", name = "json", version.ref = "orgJson" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }