[
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug Report\nabout: Create a report to help us improve\ntitle: \"[BUG] \"\nlabels: bug\nassignees: ''\n\n---\n\n**Bug Description**\nA clear and concise description of what the bug is.\n\n**Steps to Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected Behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Device Information**\n- Brand and Model: [e.g., Samsung Galaxy S20]\n- Android Version: [e.g., Android 11]\n- App Version: [e.g., 1.2.0]\n\n**Additional Context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature Request\nabout: Suggest an idea for this project\ntitle: \"[FEATURE] \"\nlabels: enhancement\nassignees: ''\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n\n**Potential UI/UX Design Sketches**\nIf you have any ideas or sketches of how the feature should look or function, please attach or describe them here.\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: Android CI\n\non:\n  push:\n    branches:\n      - master\n      - develop\n      - testing\n  pull_request:\n    branches:\n      - master\n      - develop\n\njobs:\n  unit_tests:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - name: Set up JDK 21\n        uses: actions/setup-java@v4\n        with:\n          distribution: 'temurin'\n          java-version: 21\n      - name: Setup Gradle\n        uses: gradle/actions/setup-gradle@v4\n      - name: Run Unit Tests\n        run: ./gradlew testDebugUnitTest\n\n  android_tests:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Set up JDK 21\n        uses: actions/setup-java@v4\n        with:\n          distribution: 'temurin'\n          java-version: 21\n\n      - name: Setup Gradle\n        uses: gradle/actions/setup-gradle@v4\n\n      - name: Enable KVM\n        run: |\n          echo 'KERNEL==\"kvm\", GROUP=\"kvm\", MODE=\"0666\"' | sudo tee /etc/udev/rules.d/99-kvm.rules\n          sudo udevadm control --reload-rules\n          sudo udevadm trigger --name-match=kvm\n\n      - name: Run Android Emulator\n        uses: reactivecircus/android-emulator-runner@v2\n        with:\n          api-level: 29\n          target: default\n          arch: x86_64\n          profile: Nexus 6\n          emulator-options: -no-snapshot -no-window -gpu swiftshader_indirect\n          script: ./gradlew connectedCheck\n\n  build_and_release:\n    if: github.ref == 'refs/heads/master'\n    runs-on: ubuntu-latest\n    needs: [unit_tests, android_tests]\n    steps:\n      - uses: actions/checkout@v4\n      - name: Set up JDK 21\n        uses: actions/setup-java@v4\n        with:\n          java-version: '21'\n          distribution: 'temurin'\n\n      - name: Decode keystore\n        run: echo \"${{ secrets.KEYSTORE_BASE64 }}\" | base64 -d > ${{ github.workspace }}/key_store.jks\n\n      - name: Setup Gradle\n        uses: gradle/actions/setup-gradle@v4\n\n      - name: Build app\n        run: ./gradlew assembleProductionRelease\n        env:\n          KEYSTORE_PATH: ${{ github.workspace }}/key_store.jks\n          RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}\n          RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}\n          RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}\n\n      - name: Retrieve Version\n        run: echo \"APP_VERSION_NAME=$(grep '^versionName=' gradle.properties | cut -d'=' -f2)\" >> $GITHUB_ENV\n\n      - name: Create Release on GitHub\n        uses: softprops/action-gh-release@v2\n        env:\n          GITHUB_TOKEN: ${{ secrets.SHIORI_TOKEN }}\n        with:\n          tag_name: v${{ env.APP_VERSION_NAME }}\n          name: Release - v${{ env.APP_VERSION_NAME }}\n          generate_release_notes: true\n          prerelease: false\n          files: presentation/build/outputs/apk/production/release/*.apk\n"
  },
  {
    "path": ".gitignore",
    "content": "*.iml\n.gradle\n/local.properties\n/.idea/*\n/.idea/codeStyles\n/.idea/caches\n/.idea/libraries\n/.idea/modules.xml\n/.idea/workspace.xml\n/.idea/navEditor.xml\n/.idea/assetWizardSettings.xml\n/.idea/appInsightsSettings.xml\n/.idea/migrations.xml\n/.kotlin\npresentation/release\npresentation/production\npresentation/staging\n.DS_Store\n/build\n/captures\n.externalNativeBuild\n.cxx\nlocal.properties\nkeystore.properties\nresources\ncambios.patch\n*.aab\n*.apk\n/docs\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "<h1 align=\"center\">\n  <img src=\"images/page_keeper_logo.png\" width=\"120\" alt=\"EhViewer\">\n  <br>Shiori<br>\n</h1>\n\n<p align=\"center\">\n  <a href=\"https://github.com/DesarrolloAntonio/Shiori-Android-Client/actions\">\n    <img src=\"https://github.com/DesarrolloAntonio/Shiori-Android-Client/actions/workflows/ci.yml/badge.svg\" alt=\"GitHub Actions\">\n  </a>\n  <a href=\"https://github.com/DesarrolloAntonio/Shiori-Android-Client/blob/master/LICENSE\">\n    <img src=\"https://img.shields.io/github/license/DesarrolloAntonio/Shiori-Android-Client\" alt=\"License\">\n  </a>\n  <a href=\"https://github.com/DesarrolloAntonio/Shiori-Android-Client/releases\">\n    <img src=\"https://img.shields.io/github/v/release/DesarrolloAntonio/Shiori-Android-Client\" alt=\"Release\">\n  </a>\n  <a href=\"https://github.com/DesarrolloAntonio/Shiori-Android-Client/issues\">\n    <img src=\"https://img.shields.io/github/issues/DesarrolloAntonio/Shiori-Android-Client\" alt=\"Issues\">\n  </a>\n <a href=\"https://github.com/DesarrolloAntonio/Shiori-Android-Client/commits\">\n    <img src=\"https://img.shields.io/github/commit-activity/m/DesarrolloAntonio/Shiori-Android-Client\" alt=\"Commit Activity\">\n  </a>\n</p>\n\n<div align=\"center\">\n  <h3>\n    <a href=\"#description\">Description</a>\n    <span> | </span>\n    <a href=\"#screenshot\">Screenshot</a>\n    <span> | </span>\n    <a href=\"#features\">Features</a>\n    <span> | </span>\n    <a href=\"#technologies-used\">Technologies Used</a>\n    <span> | </span>\n    <a href=\"#download\">Download</a>\n    <span> | </span>\n    <a href=\"#license\">License</a>\n  </h3>\n</div>\n\n## Description\nShiori is an innovative bookmark management application that revolutionizes the way users save, organize, and access their favorite web pages. Built upon the robust [Shiori platform](https://github.com/go-shiori/shiori), Shiori offers a seamless experience across all devices.\n\n## Screenshots\n|                                                      |                                                      |                                                      |                                                      |\n|:----------------------------------------------------:|:----------------------------------------------------:|:----------------------------------------------------:|:----------------------------------------------------:|\n| ![Screenshot 1](images/screenshots/Screenshot_1.png) | ![Screenshot 2](images/screenshots/Screenshot_2.png) | ![Screenshot 3](images/screenshots/Screenshot_3.png) | ![Screenshot 4](images/screenshots/Screenshot_4.png) |\n| ![Screenshot 5](images/screenshots/Screenshot_5.png) | ![Screenshot 6](images/screenshots/Screenshot_6.png) | ![Screenshot 7](images/screenshots/Screenshot_7.png) | ![Screenshot 8](images/screenshots/Screenshot_8.png) |\n\n\n## Features\n- **Save Pages Easily**: Instantly capture and access web pages at any time, even offline.\n- **Superior Organization**: Custom labels, descriptions, and thumbnails for efficient bookmark sorting.\n- **Cloud Synchronization**: Sync your bookmarks across all devices.\n- **Intuitive Interface**: User-friendly navigation for a seamless experience.\n\n## Technologies Used\nShiori is built using a variety of modern and robust technologies to ensure scalability, maintainability, and performance:\n- **Clean Architecture**: Ensuring separation of concerns and modular design.\n- **Dependency Injection (DI)**: For managing dependencies effectively.\n- **Model-View-ViewModel (MVVM)**: For a responsive and powerful user interface.\n- **Use Cases**: Defining clear business logic.\n- **Repository Pattern**: For efficient data handling and abstraction.\n- **Protobuf (Proto)**: For efficient data serialization.\n- \n## Development Status ⚠️\nPlease note that Shiori is currently under development. While we strive to provide a stable experience, you may encounter bugs or incomplete features. We encourage users to:\n- Report any issues you find on our [GitHub Issues page](https://github.com/DesarrolloAntonio/Shiori-Android-Client/issues)\n- Be aware that some features might be unstable or work in progress\n- Expect regular updates as we continue to improve the application\n\n## Download\n\nShiori is available for download on various platforms:\n\n<p>\n  <a href=\"https://github.com/DesarrolloAntonio/Shiori-Android-Client/releases/latest\">\n    <img src=\"images/badge_github.png\" alt=\"Get it on GitHub\" height=\"80\">\n  </a>\n  <a href=\"https://play.google.com/store/apps/details?id=com.desarrollodroide.pagekeeper\">\n    <img src=\"https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png\" alt=\"Get Shiori on Google Play\" height=\"80\">\n  </a>\n  <a href=\"https://apt.izzysoft.de/fdroid/index/apk/com.desarrollodroide.pagekeeper\">\n    <img src=\"https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png\" alt=\"Get Shiori on IzzyOnDroid\" height=\"80\">\n  </a>\n  <a href=\"https://f-droid.org/en/packages/com.desarrollodroide.pagekeeper\">\n    <img src=\"images/badge_fdroid.png\" alt=\"Get it on F-Droid\" height=\"80\">\n  </a>\n</p>\n\n## License\nThis project is licensed under the Apache License - see the [LICENSE](LICENSE) file for details.\n\n"
  },
  {
    "path": "build.gradle",
    "content": "buildscript {\n    ext {\n        compose_ui_version = '1.1.1'\n    }\n    dependencies {\n        classpath 'com.google.protobuf:protobuf-java:3.19.4'\n        classpath \"de.mannodermaus.gradle.plugins:android-junit5:1.10.2.0\"\n    }\n}// Top-level build file where you can add configuration options common to all sub-projects/modules.\nplugins {\n    id 'com.android.application' version '8.5.2' apply false\n    id 'com.android.library' version '8.5.2' apply false\n    id 'org.jetbrains.kotlin.android' version '2.0.0' apply false\n    id 'org.jetbrains.kotlin.plugin.compose' version '2.0.0' apply false\n}"
  },
  {
    "path": "common/.gitignore",
    "content": "/build"
  },
  {
    "path": "common/README.md",
    "content": "# :core:common module\n\n![Dependency graph](../../docs/images/graphs/dep_graph_core_common.png)\n"
  },
  {
    "path": "common/build.gradle.kts",
    "content": "plugins {\n    id(\"com.android.library\")\n    id (\"org.jetbrains.kotlin.android\")\n}\n\nandroid {\n    namespace = \"com.desarrollodroide.common\"\n    compileSdk = (findProperty(\"compileSdkVersion\") as String).toInt()\n\n    defaultConfig {\n        minSdk = (findProperty(\"minSdkVersion\") as String).toInt()\n        targetSdk = (findProperty(\"targetSdkVersion\") as String).toInt()\n    }\n\n    compileOptions {\n        sourceCompatibility = JavaVersion.VERSION_21\n        targetCompatibility = JavaVersion.VERSION_21\n    }\n    kotlinOptions {\n        jvmTarget = \"21\"\n    }\n}\n\njava {\n    toolchain {\n        languageVersion = JavaLanguageVersion.of(21)\n    }\n}\n"
  },
  {
    "path": "common/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n</manifest>"
  },
  {
    "path": "common/src/main/java/com/desarrollodroide/common/result/ErrorHandler.kt",
    "content": "package com.desarrollodroide.common.result\n\n/**\n * Defines a contract for handling errors that may occur during the application's operations.\n * Allows obtaining a specific [Result.ErrorType] based on the error or API status code.\n */\ninterface ErrorHandler {\n    /**\n     * Returns an [Result.ErrorType] based on the given throwable.\n     *\n     * @param throwable The throwable that caused the error.\n     * @return The specific [Result.ErrorType] that represents the error.\n     */\n    fun getError(throwable: Throwable): Result.ErrorType\n\n    /**\n     * Returns an [Result.ErrorType] for API errors based on the status code, optional throwable, and message.\n     *\n     * @param statusCode The HTTP status code of the API error.\n     * @param throwable Optional throwable that may have caused the API error.\n     * @param message Optional message describing the API error.\n     * @return The specific [Result.ErrorType] that represents the API error.\n     */\n    fun getApiError(statusCode: Int, throwable: Throwable? = null, message: String? = null): Result.ErrorType\n}\n"
  },
  {
    "path": "common/src/main/java/com/desarrollodroide/common/result/NetworkLogEntry.kt",
    "content": "package com.desarrollodroide.common.result\n\ndata class NetworkLogEntry(\n    val timestamp: String,\n    val priority: String, // \"I\" for Info (request), \"S\" for Success (response), \"E\" for Error\n    val url: String,\n    val message: String\n)"
  },
  {
    "path": "common/src/main/java/com/desarrollodroide/common/result/Result.kt",
    "content": "package com.desarrollodroide.common.result\n/**\n * Represents the outcome of an operation that can end in success, failure, or be in progress.\n * It is a sealed class that can take one of the following forms:\n * - Success: Indicates the operation was successful.\n * - Loading: Indicates the operation is in progress.\n * - Error: Indicates the operation failed.\n *\n * @param T The expected data type in case of success.\n * @param data The resulting data in case of success. Null if the operation was not successful.\n * @param error The error that occurred if the operation failed.\n */\nsealed class Result<out T>(\n    val data: T? = null,\n    val error: ErrorType? = null\n) {\n    class Success<T>(data: T) : Result<T>(data)\n    class Loading<T>(data: T? = null) : Result<T>(data)\n    class Error<T>(error: ErrorType? = null, data: T? = null) : Result<T>(data, error)\n\n    /**\n     * Represents various error types that can occur.\n     * Includes:\n     * - DatabaseError: For errors related to database operations.\n     * - IOError: For input/output operation failures.\n     * - HttpError: For HTTP request failures, with status code and optional message.\n     * - Unknown: For undetermined errors.\n     * - SessionExpired: Specifically for session expiration errors.\n     */\n    sealed class ErrorType(\n        val throwable: Throwable? = null,\n        val statusCode: Int? = null,\n        val message: String? = null\n    ) {\n        class DatabaseError(throwable: Throwable? = null) : ErrorType(throwable)\n        class IOError(throwable: Throwable? = null) : ErrorType(throwable)\n        class HttpError(throwable: Throwable? = null, statusCode: Int, message: String? = null) : ErrorType(throwable, statusCode, message)\n        class Unknown(throwable: Throwable? = null) : ErrorType(throwable)\n        class SessionExpired(throwable: Throwable? = null, message: String? = null) : ErrorType(throwable, message = message)\n        class SyncErrorException(errorType: ErrorType) : Exception(errorType.toString())\n    }\n}\n"
  },
  {
    "path": "data/.gitignore",
    "content": "/build"
  },
  {
    "path": "data/build.gradle.kts",
    "content": "plugins {\n    id (\"com.android.library\")\n    id (\"org.jetbrains.kotlin.android\")\n    id (\"com.google.devtools.ksp\") version \"2.0.0-1.0.21\"\n    id (\"com.google.protobuf\") version \"0.9.4\"\n    id (\"de.mannodermaus.android-junit5\")\n}\n\nandroid {\n    namespace = \"com.desarrollodroide.data\"\n    compileSdk = (findProperty(\"compileSdkVersion\") as String).toInt()\n\n    defaultConfig {\n        testInstrumentationRunnerArguments += mapOf(\"runnerBuilder\" to \"de.mannodermaus.junit5.AndroidJUnit5Builder\")\n        minSdk = (findProperty(\"minSdkVersion\") as String).toInt()\n        targetSdk = (findProperty(\"targetSdkVersion\") as String).toInt()\n\n        testInstrumentationRunner = \"androidx.test.runner.AndroidJUnitRunner\"\n\n        vectorDrawables {\n            useSupportLibrary = true\n        }\n    }\n\n    buildTypes {\n        release {\n            isMinifyEnabled = false\n        }\n    }\n    compileOptions {\n        sourceCompatibility = JavaVersion.VERSION_21\n        targetCompatibility = JavaVersion.VERSION_21\n    }\n    kotlinOptions {\n        jvmTarget = \"21\"\n    }\n    packagingOptions {\n        jniLibs {\n            excludes += setOf(\"META-INF/LICENSE*\")\n        }\n        resources {\n            excludes += setOf(\"META-INF/LICENSE*\")\n        }\n    }\n    // JUnit 5 will bundle in files with identical paths, exclude them\n}\n\njava {\n    toolchain {\n        languageVersion = JavaLanguageVersion.of(21)\n    }\n}\n\ndependencies {\n    // Project module dependencies\n    implementation(project(\":network\"))\n    implementation(project(\":model\"))\n    implementation(project(\":common\"))\n\n    // Retrofit for HTTP requests and networking\n    implementation (libs.bundles.retrofit) // Retrofit with logging, Gson, and scalar converters for REST API communication.\n\n    // Koin for dependency injection, specifically tailored for use with Jetpack Compose\n    implementation (libs.koin.androidx.compose) // Koin library for dependency injection within Android Compose applications.\n\n    // AndroidX core libraries for fundamental functionality\n    implementation (libs.androidx.core) // Core utility functions and backward-compatible versions of Android framework components.\n    implementation (libs.androidx.datastore.preferences) // DataStore for storing key-value pairs asynchronously and transactionally.\n    implementation (libs.androidx.datastore.core) // Core DataStore functionality.\n    implementation (libs.androidx.paging.compose) // Paging library for Jetpack Compose.\n    implementation (libs.androidx.lifecycle.runtime) // Lifecycle components for Jetpack Compose.\n\n    // Protocol Buffers for efficient serialization of structured data\n    implementation(libs.protobuf.kotlin.lite) // Protocol Buffers Lite for Kotlin, for efficient data serialization.\n\n    // Room for abstracting SQLite database access and providing compile-time checks of SQL queries\n    implementation(libs.androidx.room) // Room for database access, abstracting SQLite and providing LiveData support.\n    ksp(libs.androidx.room.compiler) // Kotlin Symbol Processing (KSP) for Room to generate database access code at compile time.\n    implementation(libs.androidx.room.paging) // Replace with the appropriate version if different.\n\n    // WorkManager\n    implementation(libs.androidx.work) // WorkManager for managing background tasks.\n\n    // Testing libraries\n    testImplementation(libs.junit.jupiter) // JUnit Jupiter for unit testing with JUnit 5.\n    testRuntimeOnly(libs.junit.jupiter.engine) // JUnit Jupiter Engine for running JUnit 5 tests.\n    testImplementation(libs.junit.jupiter.api) // JUnit Jupiter API for writing tests and extensions in JUnit 5.\n    testImplementation(libs.mockito.core) // Mockito for mocking objects in tests.\n    testImplementation(libs.mockito.kotlin) // Kotlin extension for Mockito to better support Kotlin features.\n    testImplementation(libs.kotlin.coroutines.test) // Coroutines Test library for testing Kotlin coroutines.\n    testImplementation(libs.kotlin.test.junit5) // Kotlin Test library for JUnit 5 support.\n    testImplementation(libs.androidx.paging.common) // Common Paging library for testing.\n    testImplementation(\"app.cash.turbine:turbine:1.1.0\") // Turbine for testing flows.\n\n\n    // Android Testing libraries\n    androidTestImplementation (\"androidx.test:core:1.5.0\") // Core testing library for Android, providing API for test infrastructure.\n    androidTestImplementation (\"androidx.test:runner:1.5.0\") // Android Test Runner for running instrumented tests.\n    androidTestImplementation (\"androidx.test:rules:1.5.0\") // Android Test Rules for defining complex test cases.\n    androidTestImplementation(libs.androidx.room.testing) // Room Testing support for testing Room databases.\n    androidTestImplementation(libs.kotlin.coroutines.test) // Coroutines Test library for testing coroutines in Android tests.\n    androidTestImplementation(\"de.mannodermaus.junit5:android-test-core:1.2.2\") // Android support for JUnit 5 tests.\n    androidTestRuntimeOnly(\"de.mannodermaus.junit5:android-test-runner:1.2.2\") // JUnit 5 Runner for running Android tests with JUnit 5.\n}\n\n\n// Setup protobuf configuration, generating lite Java and Kotlin classes\nprotobuf {\n    protoc {\n        artifact = libs.protobuf.protoc.get().toString()\n    }\n    generateProtoTasks {\n        all().forEach { task ->\n            task.builtins {\n                val java by registering {\n                    option(\"lite\")\n                }\n                val kotlin by registering {\n                    option(\"lite\")\n                }\n            }\n        }\n    }\n}\n\ntasks.withType<Test> {\n    useJUnitPlatform()\n    testLogging {\n        events(\"passed\", \"failed\", \"skipped\")\n        showStandardStreams = true\n    }\n}\n"
  },
  {
    "path": "data/consumer-rules.pro",
    "content": ""
  },
  {
    "path": "data/proguard-rules.pro",
    "content": "# Add project specific ProGuard rules here.\n# You can control the set of applied configuration files using the\n# proguardFiles setting in build.gradle.kts.\n#\n# For more details, see\n#   http://developer.android.com/guide/developing/tools/proguard.html\n\n# If your project uses WebView with JS, uncomment the following\n# and specify the fully qualified class name to the JavaScript interface\n# class:\n#-keepclassmembers class fqcn.of.javascript.interface.for.webview {\n#   public *;\n#}\n\n# Uncomment this to preserve the line number information for\n# debugging stack traces.\n#-keepattributes SourceFile,LineNumberTable\n\n# If you keep the line number information, uncomment this to\n# hide the original source file name.\n#-renamesourcefileattribute SourceFile"
  },
  {
    "path": "data/src/androidTest/java/com/desarrollodroide/data/local/room/BookmarkHtmlDaoTest.kt",
    "content": "package com.desarrollodroide.data.local.room\n\nimport androidx.room.Room\nimport androidx.test.core.app.ApplicationProvider\nimport com.desarrollodroide.data.local.room.dao.BookmarkHtmlDao\nimport com.desarrollodroide.data.local.room.database.BookmarksDatabase\nimport com.desarrollodroide.data.local.room.entity.BookmarkHtmlEntity\nimport kotlinx.coroutines.runBlocking\nimport org.junit.After\nimport org.junit.Assert\nimport org.junit.Before\nimport org.junit.Test\nclass BookmarkHtmlDaoTest {\n\n    private lateinit var database: BookmarksDatabase\n    private lateinit var bookmarkHtmlDao: BookmarkHtmlDao\n\n    private val bookmarkHtml = BookmarkHtmlEntity(\n        id = 1,\n        url = \"http://example.com\",\n        readableContentHtml = \"<html>Test Content</html>\"\n    )\n\n    @Before\n    fun setup() {\n        database = Room.inMemoryDatabaseBuilder(\n            ApplicationProvider.getApplicationContext(),\n            BookmarksDatabase::class.java\n        )\n            .allowMainThreadQueries()\n            .build()\n\n        bookmarkHtmlDao = database.bookmarkHtmlDao()\n    }\n\n    @After\n    fun tearDown() {\n        database.close()\n    }\n\n    @Test\n    fun testInsertAndFetchBookmarkHtml(): Unit = runBlocking {\n        bookmarkHtmlDao.insertOrUpdate(bookmarkHtml)\n        val retrievedHtml = bookmarkHtmlDao.getHtmlContent(bookmarkHtml.id)\n        Assert.assertEquals(bookmarkHtml.readableContentHtml, retrievedHtml)\n        bookmarkHtmlDao.getBookmarkHtml(bookmarkHtml.id)?.let {\n            Assert.assertEquals(bookmarkHtml, it)\n        }\n    }\n\n    @Test\n    fun testUpdateBookmarkHtml() = runBlocking {\n        bookmarkHtmlDao.insertOrUpdate(bookmarkHtml)\n        val updatedBookmarkHtml = bookmarkHtml.copy(readableContentHtml = \"<html>Updated Content</html>\")\n        bookmarkHtmlDao.insertOrUpdate(updatedBookmarkHtml)\n        val retrievedHtml = bookmarkHtmlDao.getHtmlContent(bookmarkHtml.id)\n        Assert.assertEquals(updatedBookmarkHtml.readableContentHtml, retrievedHtml)\n    }\n}"
  },
  {
    "path": "data/src/androidTest/java/com/desarrollodroide/data/local/room/BookmarksDaoTest.kt",
    "content": "package com.desarrollodroide.data.local.room\n\nimport androidx.paging.PagingSource\nimport androidx.room.Room\nimport androidx.test.platform.app.InstrumentationRegistry\nimport com.desarrollodroide.data.local.room.dao.BookmarksDao\nimport com.desarrollodroide.data.local.room.database.BookmarksDatabase\nimport com.desarrollodroide.data.local.room.entity.BookmarkEntity\nimport com.desarrollodroide.data.local.room.entity.TagEntity\nimport com.desarrollodroide.model.Tag\nimport junit.framework.TestCase.assertEquals\nimport junit.framework.TestCase.assertFalse\nimport junit.framework.TestCase.assertNotNull\nimport junit.framework.TestCase.assertTrue\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.runBlocking\nimport org.junit.After\nimport org.junit.Before\nimport org.junit.Test\n\nclass BookmarksDaoTest {\n\n    private lateinit var database: BookmarksDatabase\n    private lateinit var bookmarksDao: BookmarksDao\n    private val bookmark = BookmarkEntity(\n        id = 1,\n        url = \"http://example.com\",\n        title = \"Test Bookmark\",\n        excerpt = \"This is a test bookmark\",\n        author = \"Author Name\",\n        isPublic = 1,\n        modified = \"2020-01-01\",\n        createdAt = \"2020-01-02\",\n        imageURL = \"http://example.com/image.png\",\n        hasContent = true,\n        hasArchive = true,\n        hasEbook = true,\n        tags = listOf(),\n        createArchive = true,\n        createEbook = true\n    )\n\n    private val tag = Tag(id = 1, name = \"Test Tag\")\n\n\n    @Before\n    fun setup() {\n        database = Room.inMemoryDatabaseBuilder(\n            InstrumentationRegistry.getInstrumentation().context,\n            BookmarksDatabase::class.java\n        )\n            .allowMainThreadQueries()\n            .build()\n\n        bookmarksDao = database.bookmarksDao()\n    }\n\n    @After\n    fun tearDown() {\n        database.close()\n    }\n\n    @Test\n    fun testInsertAndFetchBookmarks() = runBlocking {\n        bookmarksDao.insertAll(listOf(bookmark))\n        val retrievedBookmarks = bookmarksDao.getAll().first()\n        assertTrue(retrievedBookmarks.contains(bookmark))\n        bookmarksDao.deleteAll()\n        assertTrue(bookmarksDao.getAll().first().isEmpty())\n    }\n\n    @Test\n    fun testUpdateBookmark() = runBlocking {\n        bookmarksDao.insertAll(listOf(bookmark))\n        val updatedBookmark = bookmark.copy(title = \"Updated Title\", url = \"http://updated.com\", modified = \"2020-01-03\")\n        bookmarksDao.insertAll(listOf(updatedBookmark))\n        val retrievedBookmarks = bookmarksDao.getAll().first()\n        assertTrue(retrievedBookmarks.any {\n            it.id == bookmark.id && it.title == \"Updated Title\" && it.url == \"http://updated.com\" && it.modified == \"2020-01-03\"\n        })\n    }\n\n    @Test\n    fun testDeleteBookmarkById() = runBlocking {\n        bookmarksDao.insertAll(listOf(bookmark))\n        val deletedRows = bookmarksDao.deleteBookmarkById(1)\n        assertEquals(1, deletedRows)\n        assertTrue(bookmarksDao.getAll().first().isEmpty())\n    }\n\n    @Test\n    fun testIsEmpty() = runBlocking {\n        assertTrue(bookmarksDao.isEmpty())\n        bookmarksDao.insertAll(listOf(bookmark))\n        assertFalse(bookmarksDao.isEmpty())\n    }\n\n    @Test\n    fun testGetPagingBookmarksWithoutTags() = runBlocking {\n        bookmarksDao.insertAll(listOf(bookmark))\n        val pagingSource = bookmarksDao.getPagingBookmarksWithoutTags(\"Test\")\n        val loadResult = pagingSource.load(\n            PagingSource.LoadParams.Refresh(\n                key = null,\n                loadSize = 1,\n                placeholdersEnabled = false\n            )\n        )\n        assertTrue(loadResult is PagingSource.LoadResult.Page)\n        assertEquals(1, (loadResult as PagingSource.LoadResult.Page).data.size)\n    }\n\n    @Test\n    fun testInsertAllWithTags() = runBlocking {\n        val bookmarkWithTag = bookmark.copy(tags = listOf(tag))\n        bookmarksDao.insertAllWithTags(listOf(bookmarkWithTag))\n        val retrievedBookmarks = bookmarksDao.getAll().first()\n        assertEquals(1, retrievedBookmarks.size)\n        assertEquals(1, retrievedBookmarks[0].tags.size)\n        assertEquals(\"Test Tag\", retrievedBookmarks[0].tags[0].name)\n    }\n\n    @Test\n    fun testUpdateBookmarkWithTags(): Unit = runBlocking {\n        // Insert the initial bookmark\n        bookmarksDao.insertAllWithTags(listOf(bookmark))\n\n        // Create an updated version of the bookmark with changed fields\n        val updatedTag = Tag(id = 2, name = \"Updated Tag\")\n        val updatedBookmark = bookmark.copy(\n            title = \"Updated Title\",\n            url = \"http://updated-example.com\",\n            excerpt = \"This is an updated test bookmark\",\n            author = \"Updated Author Name\",\n            isPublic = 0,\n            modified = \"2023-01-01\",\n            createdAt = \"2023-01-02\",\n            imageURL = \"http://updated-example.com/image.png\",\n            hasContent = false,\n            hasArchive = false,\n            hasEbook = false,\n            tags = listOf(updatedTag),\n            createArchive = false,\n            createEbook = false\n        )\n\n        // Update the bookmark\n        bookmarksDao.updateBookmarkWithTags(updatedBookmark)\n\n        // Retrieve the updated bookmark\n        val retrievedBookmark = bookmarksDao.getBookmarkById(1)\n\n        // Assert that the bookmark is not null\n        assertNotNull(retrievedBookmark)\n\n        // Check all fields of the updated bookmark\n        retrievedBookmark?.let { bookmark ->\n            assertEquals(1, bookmark.id)\n            assertEquals(\"Updated Title\", bookmark.title)\n            assertEquals(\"http://updated-example.com\", bookmark.url)\n            assertEquals(\"This is an updated test bookmark\", bookmark.excerpt)\n            assertEquals(\"Updated Author Name\", bookmark.author)\n            assertEquals(0, bookmark.isPublic)\n            assertEquals(\"2023-01-01\", bookmark.modified)\n            assertEquals(\"2023-01-02\", bookmark.createdAt)\n            assertEquals(\"http://updated-example.com/image.png\", bookmark.imageURL)\n            assertFalse(bookmark.hasContent)\n            assertFalse(bookmark.hasArchive)\n            assertFalse(bookmark.hasEbook)\n            assertFalse(bookmark.createArchive)\n            assertFalse(bookmark.createEbook)\n\n            // Check the updated tag\n            assertEquals(1, bookmark.tags.size)\n            assertEquals(2, bookmark.tags[0].id)\n        }\n    }\n\n    @Test\n    fun testGetAllBookmarkIds() = runBlocking {\n        bookmarksDao.insertAll(listOf(bookmark, bookmark.copy(id = 2)))\n        val bookmarkIds = bookmarksDao.getAllBookmarkIds()\n        assertEquals(listOf(1, 2), bookmarkIds)\n    }\n\n    @Test\n    fun testGetBookmarkById() = runBlocking {\n        bookmarksDao.insertAll(listOf(bookmark))\n        val retrievedBookmark = bookmarksDao.getBookmarkById(1)\n        assertNotNull(retrievedBookmark)\n        assertEquals(bookmark, retrievedBookmark)\n    }\n\n}\n"
  },
  {
    "path": "data/src/androidTest/java/com/desarrollodroide/data/local/room/TagsDaoTest.kt",
    "content": "package com.desarrollodroide.data.local.room\n\nimport androidx.room.Room\nimport androidx.test.core.app.ApplicationProvider\nimport com.desarrollodroide.data.local.room.dao.TagDao\nimport com.desarrollodroide.data.local.room.database.BookmarksDatabase\nimport com.desarrollodroide.data.local.room.entity.TagEntity\nimport junit.framework.TestCase.assertFalse\nimport junit.framework.TestCase.assertTrue\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.runBlocking\nimport org.junit.After\nimport org.junit.Before\nimport org.junit.Test\n\nclass TagDaoTest {\n\n    private lateinit var database: BookmarksDatabase\n    private lateinit var tagDao: TagDao\n\n    private val tag = TagEntity(\n        id = 1,\n        name = \"Test Tag\",\n        nBookmarks = 5\n    )\n\n    @Before\n    fun setup() {\n        database = Room.inMemoryDatabaseBuilder(\n            ApplicationProvider.getApplicationContext(),\n            BookmarksDatabase::class.java\n        )\n            .allowMainThreadQueries()\n            .build()\n\n        tagDao = database.tagDao()\n    }\n\n    @After\n    fun tearDown() {\n        database.close()\n    }\n\n    @Test\n    fun testInsertAndFetchTags() = runBlocking {\n        tagDao.insertTag(tag)\n        val retrievedTags = tagDao.getAllTags().first()\n        assertTrue(retrievedTags.contains(tag))\n        tagDao.deleteAllTags()\n        assertTrue(tagDao.getAllTags().first().isEmpty())\n    }\n\n    @Test\n    fun testDeleteTag() = runBlocking {\n        tagDao.insertTag(tag)\n        tagDao.deleteTag(tag)\n        val retrievedTags = tagDao.getAllTags().first()\n        assertFalse(retrievedTags.contains(tag))\n    }\n\n    @Test\n    fun testInsertAndFetchMultipleTags() = runBlocking {\n        val tags = listOf(\n            TagEntity(1, \"Tag1\", 2),\n            TagEntity(2, \"Tag2\", 3)\n        )\n        tagDao.insertAllTags(tags)\n        val retrievedTags = tagDao.getAllTags().first()\n        assertTrue(retrievedTags.containsAll(tags))\n    }\n}\n"
  },
  {
    "path": "data/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n</manifest>"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/di/DataModule.kt",
    "content": "package com.desarrollodroide.data.di\n\nimport android.content.Context\nimport androidx.datastore.core.DataStoreFactory\nimport androidx.datastore.core.handlers.ReplaceFileCorruptionHandler\nimport androidx.datastore.preferences.core.PreferenceDataStoreFactory\nimport androidx.datastore.preferences.core.emptyPreferences\nimport androidx.datastore.preferences.preferencesDataStoreFile\nimport androidx.work.WorkManager\nimport com.desarrollodroide.common.result.ErrorHandler\nimport com.desarrollodroide.data.helpers.CrashHandler\nimport com.desarrollodroide.data.helpers.CrashHandlerImpl\nimport com.desarrollodroide.data.local.datastore.HideTagSerializer\nimport com.desarrollodroide.data.local.datastore.RememberUserPreferencesSerializer\nimport com.desarrollodroide.data.local.datastore.SystemPreferencesSerializer\nimport com.desarrollodroide.data.local.datastore.UserPreferencesSerializer\nimport com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource\nimport com.desarrollodroide.data.local.preferences.SettingsPreferencesDataSourceImpl\nimport com.desarrollodroide.data.repository.BookmarksRepository\nimport com.desarrollodroide.data.repository.BookmarksRepositoryImpl\nimport com.desarrollodroide.data.repository.AuthRepository\nimport com.desarrollodroide.data.repository.AuthRepositoryImpl\nimport com.desarrollodroide.data.repository.ErrorHandlerImpl\nimport com.desarrollodroide.data.repository.FileRepository\nimport com.desarrollodroide.data.repository.FileRepositoryImpl\nimport com.desarrollodroide.data.repository.SettingsRepository\nimport com.desarrollodroide.data.repository.SettingsRepositoryImpl\nimport com.desarrollodroide.data.repository.SyncWorks\nimport com.desarrollodroide.data.repository.SyncWorksImpl\nimport com.desarrollodroide.data.repository.SystemRepository\nimport com.desarrollodroide.data.repository.SystemRepositoryImpl\nimport com.desarrollodroide.data.repository.TagsRepository\nimport com.desarrollodroide.data.repository.TagsRepositoryImpl\nimport com.desarrollodroide.data.repository.workers.SyncWorker\nimport com.desarrollodroide.network.retrofit.FileRemoteDataSource\nimport org.koin.android.ext.koin.androidContext\nimport org.koin.core.qualifier.named\nimport org.koin.dsl.module\n\nfun dataModule() = module {\n\n    val preferencesDataStoreQualifier = named(\"preferencesDataStore\")\n    val protoDataStoreQualifier = named(\"protoDataStore\")\n    val protoRememberUserDataStoreQualifier = named(\"protoRememberUserDataStore\")\n    val protoHideTagDataStoreQualifier = named(\"protoHideTagDataStore\")\n    val protoSystemDataStoreQualifier = named(\"protoSystemDataStore\")\n\n    single(preferencesDataStoreQualifier) {\n        PreferenceDataStoreFactory.create(\n            corruptionHandler = ReplaceFileCorruptionHandler(\n                produceNewData = { emptyPreferences() }\n            ),\n            produceFile = { androidContext().preferencesDataStoreFile(\"user_data\") }\n        )\n    }\n\n    single(protoDataStoreQualifier) {\n        DataStoreFactory.create(\n            serializer = UserPreferencesSerializer,\n            produceFile = { androidContext().preferencesDataStoreFile(\"objects_data\")},\n            corruptionHandler = null,\n        )\n    }\n\n    single(protoRememberUserDataStoreQualifier) {\n        DataStoreFactory.create(\n            serializer = RememberUserPreferencesSerializer,\n            produceFile = { androidContext().preferencesDataStoreFile(\"remember_user_data\")},\n            corruptionHandler = null,\n        )\n    }\n\n    single(protoHideTagDataStoreQualifier) {\n        DataStoreFactory.create(\n            serializer = HideTagSerializer,\n            produceFile = { androidContext().preferencesDataStoreFile(\"hide_tag_data\")},\n            corruptionHandler = null,\n        )\n    }\n\n    single(protoSystemDataStoreQualifier) {\n        DataStoreFactory.create(\n            serializer = SystemPreferencesSerializer,\n            produceFile = { androidContext().preferencesDataStoreFile(\"system_data\")},\n            corruptionHandler = null,\n        )\n    }\n\n    single { SettingsPreferencesDataSourceImpl(\n        dataStore = get(preferencesDataStoreQualifier),\n        protoDataStore = get(protoDataStoreQualifier),\n        systemPreferences = get(protoSystemDataStoreQualifier),\n        rememberUserProtoDataStore = get(protoRememberUserDataStoreQualifier),\n        hideTagDataStore = get(protoHideTagDataStoreQualifier)\n    ) as SettingsPreferenceDataSource }\n\n\n\n    single { AuthRepositoryImpl(\n        apiService = get(),\n        settingsPreferenceDataSource = get(),\n        errorHandler = get()\n    ) as AuthRepository }\n\n    single { SettingsRepositoryImpl(\n        settingsPreferenceDataSource = get()\n    ) as SettingsRepository }\n\n    single { BookmarksRepositoryImpl(\n        apiService = get(),\n        bookmarksDao = get(),\n        errorHandler = get()\n    ) as BookmarksRepository }\n\n    single { FileRepositoryImpl(\n        context = androidContext(),\n        remoteDataSource = get(),\n    ) as FileRepository }\n\n    single {\n        SystemRepositoryImpl(\n            apiService = get(),\n            settingsPreferenceDataSource = get(),\n            errorHandler = get()\n        ) as SystemRepository\n    }\n\n    single {\n        TagsRepositoryImpl(\n            apiService = get(),\n            tagsDao = get(),\n            errorHandler = get()\n        ) as TagsRepository\n    }\n\n    single { FileRemoteDataSource() }\n    single { ErrorHandlerImpl() as ErrorHandler }\n\n    single { WorkManager.getInstance(get<Context>()) }\n    single { SyncWorker.Factory() }\n\n    single { SyncWorksImpl(\n        workManager = get(),\n        bookmarksDao = get(),\n        ) as SyncWorks\n    }\n\n    single {\n        CrashHandlerImpl(\n            settingsPreferenceDataSource = get()\n        ) as CrashHandler\n    }\n\n}"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/di/PersistenceModule.kt",
    "content": "package com.desarrollodroide.data.di\n\nimport com.desarrollodroide.data.local.room.database.BookmarksDatabase\nimport org.koin.android.ext.koin.androidContext\nimport org.koin.dsl.module\n\nfun databaseModule() = module {\n\n  single { BookmarksDatabase.create(androidContext()) }\n  single { get<BookmarksDatabase>().bookmarksDao() }\n  single { get<BookmarksDatabase>().tagDao() }\n  single { get<BookmarksDatabase>().bookmarkHtmlDao() }\n\n}\n\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/extensions/GSONS.kt",
    "content": "package com.desarrollodroide.data.extensions\n\nimport com.desarrollodroide.data.helpers.GSON\nimport com.google.gson.JsonElement\n\ninline fun <reified T> String.toBean() = GSON.fromJson<T>(this)\n\ninline fun <reified T> JsonElement.toBean() = GSON.fromJson<T>(this)\n\nfun Any.toJson() = GSON.toJson(this)\n\nfun JsonElement.toJson() = GSON.toJson(this)\n\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/extensions/IntExtensions.kt",
    "content": "package com.desarrollodroide.data.extensions\n\n/**\n * Checks if an integer ID is a temporary timestamp-based ID rather than a real server ID.\n *\n * Temporary IDs are generated from System.currentTimeMillis() / 1000 (epoch seconds),\n * producing values like 1,700,000,000+. Real server IDs are sequential (1, 2, 3...),\n * so any ID over 1 million is clearly a temporary local ID.\n */\nfun Int.isTimestampId(): Boolean = this > 1_000_000"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/extensions/StringExtensions.kt",
    "content": "package com.desarrollodroide.data.extensions\n\nfun String.removeTrailingSlash(): String {\n    return if (this.endsWith(\"/\")) {\n        this.dropLast(1)\n    } else {\n        this\n    }\n}"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/extensions/TagExtensions.kt",
    "content": "package com.desarrollodroide.data.extensions\n\nimport com.desarrollodroide.model.Tag\n\nfun List<Tag>.toTagPattern(): String {\n    if (isEmpty()) return \"\"\n\n    val escapedNames = map { tag ->\n        \"\\\"name\\\":\\\"${tag.name.replace(\"\\\"\", \"\\\\\\\"\").replace(\"'\", \"''\")}\\\"\"\n    }\n    return \"%${escapedNames.joinToString(\"%' OR tags LIKE '%\")}%\"\n}"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/helpers/Constants.kt",
    "content": "package com.desarrollodroide.data.helpers\n\nenum class ThemeMode {\n    DARK, LIGHT, AUTO\n}\nenum class BookmarkViewType {\n    FULL,\n    SMALL\n}\n\nconst val SHIORI_GITHUB_URL = \"https://github.com/go-shiori/shiori\"\nconst val SHIORI_ANDROID_CLIENT_GITHUB_URL = \"https://github.com/DesarrolloAntonio/Shiori-Android-Client\"\nconst val SESSION_HAS_BEEN_EXPIRED = \"session has been expired\"\n\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/helpers/CrashHandler.kt",
    "content": "package com.desarrollodroide.data.helpers\n\nimport com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource\n\ninterface CrashHandler {\n    fun initialize()\n\n    companion object {\n        fun create(settingsPreferenceDataSource: SettingsPreferenceDataSource): CrashHandler {\n            return CrashHandlerImpl(settingsPreferenceDataSource).also { handler ->\n                Thread.setDefaultUncaughtExceptionHandler(handler)\n            }\n        }\n    }\n}"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/helpers/CrashHandlerImpl.kt",
    "content": "package com.desarrollodroide.data.helpers\n\nimport android.util.Log\nimport com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.SupervisorJob\nimport kotlinx.coroutines.launch\nimport java.text.SimpleDateFormat\nimport java.util.Date\nimport java.util.Locale\n\nclass CrashHandlerImpl(\n    private val settingsPreferenceDataSource: SettingsPreferenceDataSource,\n) : Thread.UncaughtExceptionHandler, CrashHandler {\n\n    private val previousHandler = Thread.getDefaultUncaughtExceptionHandler()\n    private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)\n\n    override fun initialize() {\n        Thread.setDefaultUncaughtExceptionHandler(this)\n        Log.d(\"CrashHandler\", \"Initialized\")\n    }\n\n    override fun uncaughtException(thread: Thread, throwable: Throwable) {\n        try {\n            val timestamp = SimpleDateFormat(\"yyyy-MM-dd HH:mm:ss\", Locale.getDefault()).format(Date())\n            val stackTrace = throwable.stackTraceToString()\n\n            val crashLog = buildString {\n                appendLine(\"Timestamp: $timestamp\")\n                appendLine(\"Thread: ${thread.name}\")\n                appendLine(\"Exception: ${throwable.javaClass.name}\")\n                appendLine(\"Message: ${throwable.message}\")\n                appendLine(\"\\nStack trace:\")\n                appendLine(stackTrace)\n            }\n\n            Log.d(\"CrashHandler\", \"Saving crash: $crashLog\")\n\n            coroutineScope.launch {\n                try {\n                    settingsPreferenceDataSource.setLastCrashLog(crashLog)\n                    Log.d(\"CrashHandler\", \"Crash saved successfully\")\n\n                    // Verificar inmediatamente que se guardó\n                    val saved = settingsPreferenceDataSource.getLastCrashLog()\n                    Log.d(\"CrashHandler\", \"Verified saved crash: $saved\")\n                } catch (e: Exception) {\n                    Log.e(\"CrashHandler\", \"Error saving crash\", e)\n                }\n            }\n        } catch (e: Exception) {\n            Log.e(\"CrashHandler\", \"Error in uncaughtException\", e)\n        }\n\n        previousHandler?.uncaughtException(thread, throwable)\n    }\n}"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/helpers/GSON.kt",
    "content": "package com.desarrollodroide.data.helpers\n\nimport com.google.gson.GsonBuilder\nimport com.google.gson.JsonElement\nimport com.google.gson.reflect.TypeToken\n\nobject GSON {\n\n    var gson = GsonBuilder().setLenient().create()\n\n    inline fun <reified T> fromJson(json: String): T {\n        val type = object : TypeToken<T>() {}.type\n        return gson.fromJson(json, type)\n    }\n\n    inline fun <reified T> fromJson(jsonElement: JsonElement): T {\n        val type = object : TypeToken<T>() {}.type\n        return gson.fromJson(jsonElement, type)\n    }\n\n    fun toJson(any: Any) = gson.toJson(any)\n\n    fun toJson(jsonElement: JsonElement) = gson.toJson(jsonElement)\n\n}\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/helpers/TagTypeAdapter.kt",
    "content": "package com.desarrollodroide.data.helpers\n\nimport com.google.gson.*\nimport com.desarrollodroide.model.Tag\nimport com.desarrollodroide.network.model.TagDTO\nimport java.lang.reflect.Type\n\nclass TagTypeAdapter : JsonSerializer<Tag> {\n    override fun serialize(src: Tag?, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement {\n        val jsonObject = JsonObject()\n        if (src != null) {\n            jsonObject.addProperty(\"name\", src.name)\n        }\n        return jsonObject\n    }\n}\n\n\nclass AddTagDTOAdapter : JsonSerializer<TagDTO> {\n    override fun serialize(src: TagDTO?, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement {\n        val jsonObject = JsonObject()\n        if (src?.name != null) {\n            jsonObject.addProperty(\"name\", src.name)\n        }\n        return jsonObject\n    }\n}\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/datastore/ChangeListVersions.kt",
    "content": "package com.desarrollodroide.data.local.datastore\n\n/**\n * Class summarizing the local version of each model for sync\n */\ndata class ChangeListVersions(\n    val topicVersion: Int = -1,\n    val newsResourceVersion: Int = -1,\n)\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/datastore/HideTagSerializer.kt",
    "content": "package com.desarrollodroide.data.local.datastore\n\nimport androidx.datastore.core.CorruptionException\nimport androidx.datastore.core.Serializer\nimport com.desarrollodroide.data.HideTag\nimport com.google.protobuf.InvalidProtocolBufferException\nimport java.io.InputStream\nimport java.io.OutputStream\n\n/**\n * Serializer for the [HideTag] object defined in your .proto file.\n */\nobject HideTagSerializer : Serializer<HideTag> {\n    override val defaultValue: HideTag = HideTag.getDefaultInstance()\n\n    override suspend fun readFrom(input: InputStream): HideTag {\n        try {\n            return HideTag.parseFrom(input)\n        } catch (exception: InvalidProtocolBufferException) {\n            throw CorruptionException(\"Cannot read proto.\", exception)\n        }\n    }\n\n    override suspend fun writeTo(t: HideTag, output: OutputStream) = t.writeTo(output)\n}"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/datastore/RememberUserPreferencesSerializer.kt",
    "content": "package com.desarrollodroide.data.local.datastore\n\nimport androidx.datastore.core.CorruptionException\nimport androidx.datastore.core.Serializer\nimport com.desarrollodroide.data.RememberUserPreferences\nimport com.google.protobuf.InvalidProtocolBufferException\nimport java.io.InputStream\nimport java.io.OutputStream\n\n/**\n * Serializer for the [RememberUserPreferences] object defined in user_prefs.proto.\n */\nobject RememberUserPreferencesSerializer : Serializer<RememberUserPreferences> {\n    override val defaultValue: RememberUserPreferences = RememberUserPreferences.getDefaultInstance()\n\n    override suspend fun readFrom(input: InputStream): RememberUserPreferences {\n        try {\n            return RememberUserPreferences.parseFrom(input)\n        } catch (exception: InvalidProtocolBufferException) {\n            throw CorruptionException(\"Cannot read proto.\", exception)\n        }\n    }\n\n    override suspend fun writeTo(t: RememberUserPreferences, output: OutputStream) = t.writeTo(output)\n}\n\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/datastore/SystemPreferencesSerializer.kt",
    "content": "package com.desarrollodroide.data.local.datastore\n\nimport androidx.datastore.core.CorruptionException\nimport androidx.datastore.core.Serializer\nimport com.desarrollodroide.data.SystemPreferences\nimport com.google.protobuf.InvalidProtocolBufferException\nimport java.io.InputStream\nimport java.io.OutputStream\n\n/**\n * Serializer for the [SystemPreferencesSerializer] object defined in user_prefs.proto.\n */\nobject SystemPreferencesSerializer : Serializer<SystemPreferences> {\n    override val defaultValue: SystemPreferences = SystemPreferences.getDefaultInstance()\n\n    override suspend fun readFrom(input: InputStream): SystemPreferences {\n        try {\n            return SystemPreferences.parseFrom(input)\n        } catch (exception: InvalidProtocolBufferException) {\n            throw CorruptionException(\"Cannot read proto.\", exception)\n        }\n    }\n    override suspend fun writeTo(t: SystemPreferences, output: OutputStream) = t.writeTo(output)\n}\n\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/datastore/UserPreferencesSerializer.kt",
    "content": "package com.desarrollodroide.data.local.datastore\n\nimport androidx.datastore.core.CorruptionException\nimport androidx.datastore.core.Serializer\nimport com.google.protobuf.InvalidProtocolBufferException\nimport com.desarrollodroide.data.UserPreferences\nimport java.io.InputStream\nimport java.io.OutputStream\n\n/**\n * Serializer for the [UserPreferences] object defined in user_prefs.proto.\n */\nobject UserPreferencesSerializer : Serializer<UserPreferences> {\n    override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance()\n\n    override suspend fun readFrom(input: InputStream): UserPreferences {\n        try {\n            return UserPreferences.parseFrom(input)\n        } catch (exception: InvalidProtocolBufferException) {\n            throw CorruptionException(\"Cannot read proto.\", exception)\n        }\n    }\n    override suspend fun writeTo(t: UserPreferences, output: OutputStream) = t.writeTo(output)\n}\n\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/preferences/SettingsPreferenceDataSource.kt",
    "content": "package com.desarrollodroide.data.local.preferences\n\nimport com.desarrollodroide.data.UserPreferences\nimport com.desarrollodroide.data.helpers.ThemeMode\nimport com.desarrollodroide.model.Account\nimport com.desarrollodroide.model.Tag\nimport com.desarrollodroide.model.User\nimport kotlinx.coroutines.flow.Flow\n\ninterface SettingsPreferenceDataSource {\n\n    val userDataStream: Flow<User>\n    val compactViewFlow: Flow<Boolean>\n    val makeArchivePublicFlow: Flow<Boolean>\n    val createEbookFlow: Flow<Boolean>\n    val autoAddBookmarkFlow: Flow<Boolean>\n    val createArchiveFlow: Flow<Boolean>\n    val hideTagFlow: Flow<Tag?>\n    val selectedCategoriesFlow: Flow<List<String>>\n\n    fun getUser(): Flow<User>\n    suspend fun saveUser(\n        session: UserPreferences,\n        serverUrl: String,\n        password: String,\n    )\n    val rememberUserDataStream: Flow<Account>\n    fun getRememberUser(): Flow<Account>\n    suspend fun saveRememberUser(\n        url: String,\n        userName: String,\n        password: String,\n    )\n\n    suspend fun getUrl(): String\n    suspend fun getSession(): String\n    suspend fun getToken(): String\n    suspend fun resetData()\n    suspend fun resetRememberUser()\n    fun setTheme(mode: ThemeMode)\n    fun getThemeMode(): ThemeMode\n    suspend fun setMakeArchivePublic(newValue: Boolean)\n    suspend fun setCreateEbook(newValue: Boolean)\n    suspend fun setCreateArchive(newValue: Boolean)\n    suspend fun setCompactView(isCompactView: Boolean)\n    suspend fun setAutoAddBookmark(isAutoAddBookmark: Boolean)\n    suspend fun getCategoriesVisible(): Boolean\n    suspend fun setCategoriesVisible(isCategoriesVisible: Boolean)\n    suspend fun setSelectedCategories(categories: List<String>)\n    fun getUseDynamicColors(): Boolean\n    fun setUseDynamicColors(newValue: Boolean)\n    suspend fun setHideTag(tag: Tag?)\n    suspend fun addSelectedCategory(tag: Tag)\n    suspend fun removeSelectedCategory(tag: Tag)\n    suspend fun getLastSyncTimestamp(): Long\n    suspend fun setLastSyncTimestamp(timestamp: Long)\n    suspend fun setCurrentTimeStamp()\n    suspend fun getServerVersion(): String\n    suspend fun setServerVersion(version: String)\n    suspend fun getLastCrashLog(): String\n    suspend fun setLastCrashLog(crash: String)\n    suspend fun clearLastCrashLog()\n}"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/preferences/SettingsPreferencesDataSourceImpl.kt",
    "content": "package com.desarrollodroide.data.local.preferences\n\nimport android.util.Log\nimport androidx.datastore.core.DataStore\nimport androidx.datastore.preferences.core.*\nimport com.desarrollodroide.data.UserPreferences\nimport com.desarrollodroide.data.copy\nimport com.desarrollodroide.data.mapper.toProtoEntity\nimport com.desarrollodroide.model.Account\nimport com.desarrollodroide.model.User\nimport com.desarrollodroide.network.model.SessionDTO\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.catch\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.flow.map\nimport androidx.datastore.preferences.core.Preferences\nimport androidx.datastore.preferences.core.edit\nimport com.desarrollodroide.data.HideTag\nimport com.desarrollodroide.data.RememberUserPreferences\nimport com.desarrollodroide.data.SystemPreferences\nimport com.desarrollodroide.data.helpers.ThemeMode\nimport com.desarrollodroide.model.Tag\nimport kotlinx.coroutines.flow.firstOrNull\nimport kotlinx.coroutines.runBlocking\nimport java.time.ZoneId\nimport java.time.ZonedDateTime\n\nclass SettingsPreferencesDataSourceImpl(\n    private val dataStore: DataStore<Preferences>,\n    private val protoDataStore: DataStore<UserPreferences>,\n    private val rememberUserProtoDataStore: DataStore<RememberUserPreferences>,\n    private val systemPreferences: DataStore<SystemPreferences>,\n    private val hideTagDataStore: DataStore<HideTag>,\n\n    ) : SettingsPreferenceDataSource {\n\n    val THEME_MODE_KEY = stringPreferencesKey(\"theme_mode\")\n    val CATEGORIES_VISIBLE = booleanPreferencesKey(\"categories_visible\")\n    val USE_DYNAMIC_COLORS = booleanPreferencesKey(\"use_dynamic_colors\")\n\n    // Use with stateIn\n    override val userDataStream = protoDataStore.data\n        .map {\n            User(\n                token = it.token,\n                session = it.session,\n                account = Account(\n                    id = it.id,\n                    userName = it.username,\n                    owner = it.owner,\n                    password = it.password,\n                    serverUrl = it.url,\n                )\n            )\n        }\n\n    override fun getUser(): Flow<User> {\n        return protoDataStore.data\n            .catch {\n                Log.v(\"Error!!!\", it.message.toString())\n            }\n            .map { preference ->\n                User(\n                    token = preference.token,\n                    session = preference.session,\n                    account = Account(\n                        id = preference.id,\n                        userName = preference.username,\n                        owner = preference.owner,\n                        password = preference.password,\n                        serverUrl = preference.url,\n                    )\n                )\n            }\n    }\n\n    override suspend fun saveUser(\n        session: UserPreferences,\n        serverUrl: String,\n        password: String,\n    ) {\n        protoDataStore.updateData { protoSession ->\n            protoSession.copy {\n                this.id = session.id\n                this.username = session.username\n                this.password = password\n                this.session = session.session\n                this.url = serverUrl\n                this.token = session.token\n            }\n        }\n    }\n\n    override val rememberUserDataStream = rememberUserProtoDataStore.data\n        .map {\n            Account(\n                id = it.id,\n                userName = it.username,\n                owner = false,\n                password = it.password,\n                serverUrl = it.url,\n            )\n        }\n\n    override fun getRememberUser(): Flow<Account> {\n        return rememberUserProtoDataStore.data\n            .catch {\n                Log.v(\"Error!!!\", it.message.toString())\n            }\n            .map { preference ->\n                Account(\n                    id = preference.id,\n                    userName = preference.username,\n                    owner = false,\n                    password = preference.password,\n                    serverUrl = preference.url,\n                )\n            }\n    }\n\n    override suspend fun saveRememberUser(\n        url: String,\n        userName: String,\n        password: String,\n    ) {\n        rememberUserProtoDataStore.updateData { protoSession ->\n            protoSession.copy {\n                this.id = 1\n                this.username = userName\n                this.password = password\n                this.url = url\n            }\n        }\n    }\n\n    override suspend fun getUrl(): String = getUser().first().account.serverUrl\n\n    override suspend fun getSession(): String = getUser().first().session\n\n    override suspend fun getToken(): String = getUser().first().token\n\n    override suspend fun resetData() {\n        saveUser(\n            password = \"\",\n            session = SessionDTO(null, null, null).toProtoEntity(),\n            serverUrl = \"\",\n        )\n        setHideTag(null)\n        setSelectedCategories(emptyList())\n        setLastSyncTimestamp(0)\n        setServerVersion(\"\")\n    }\n\n    override suspend fun resetRememberUser() {\n        saveRememberUser(\n            url = \"\",\n            userName = \"\",\n            password = \"\"\n        )\n    }\n\n    override fun setTheme(mode: ThemeMode) {\n        runBlocking {\n            dataStore.edit { preferences ->\n                preferences[THEME_MODE_KEY] = mode.name\n            }\n        }\n    }\n\n    override fun getThemeMode(): ThemeMode {\n        return runBlocking {\n            val preferences = dataStore.data.firstOrNull()\n            val modeName = preferences?.get(THEME_MODE_KEY) ?: ThemeMode.AUTO.name\n            ThemeMode.valueOf(modeName)\n        }\n    }\n\n    override val compactViewFlow: Flow<Boolean> by lazy {\n        systemPreferences.data\n            .map { it.compactView }\n    }\n\n    override suspend fun setCompactView(isCompactView: Boolean) {\n        systemPreferences.updateData { preferences ->\n            preferences.toBuilder().setCompactView(isCompactView).build()\n        }\n    }\n\n    override suspend fun setCategoriesVisible(isCategoriesVisible: Boolean) {\n        runBlocking {\n            dataStore.edit { preferences ->\n                preferences[CATEGORIES_VISIBLE] = isCategoriesVisible\n            }\n        }\n    }\n    override suspend fun getCategoriesVisible(): Boolean = runBlocking {\n        dataStore.data.firstOrNull()?.get(CATEGORIES_VISIBLE) ?: false\n    }\n\n    override val makeArchivePublicFlow: Flow<Boolean> by lazy {\n        systemPreferences.data\n            .map { it.makeArchivePublic }\n    }\n\n    override suspend fun setMakeArchivePublic(newValue: Boolean) {\n        systemPreferences.updateData { preferences ->\n            preferences.toBuilder().setMakeArchivePublic(newValue).build()\n        }\n    }\n\n    override val createEbookFlow: Flow<Boolean> by lazy {\n        systemPreferences.data\n            .map { it.createEbook }\n    }\n\n    override suspend fun setCreateEbook(newValue: Boolean) {\n        systemPreferences.updateData { preferences ->\n            preferences.toBuilder().setCreateEbook(newValue).build()\n        }\n    }\n\n    override fun getUseDynamicColors(): Boolean = runBlocking {\n        dataStore.data.firstOrNull()?.get(USE_DYNAMIC_COLORS) ?: false\n    }\n\n    override fun setUseDynamicColors(newValue: Boolean) {\n        runBlocking {\n            dataStore.edit { preferences ->\n                preferences[USE_DYNAMIC_COLORS] = newValue\n            }\n        }\n    }\n\n    override val autoAddBookmarkFlow: Flow<Boolean> by lazy {\n        systemPreferences.data\n            .map { it.autoAddBookmark }\n    }\n\n    override suspend fun setAutoAddBookmark(isAutoAddBookmark: Boolean) {\n        systemPreferences.updateData { preferences ->\n            preferences.toBuilder().setAutoAddBookmark(isAutoAddBookmark).build()\n        }\n    }\n\n    override val createArchiveFlow: Flow<Boolean> by lazy {\n        systemPreferences.data\n            .map { it.createArchive }\n    }\n\n    override suspend fun setCreateArchive(newValue: Boolean) {\n        systemPreferences.updateData { preferences ->\n            preferences.toBuilder().setCreateArchive(newValue).build()\n        }\n    }\n\n    override val hideTagFlow: Flow<Tag?> by lazy {\n        hideTagDataStore.data\n            .map { hideTag ->\n                if (hideTag == HideTag.getDefaultInstance()) null\n                else Tag(id = hideTag.id, name = hideTag.name, selected = false, nBookmarks = 0)\n            }\n    }\n\n    override suspend fun setHideTag(tag: Tag?) {\n        hideTagDataStore.updateData { currentHideTag ->\n            when (tag) {\n                null -> HideTag.getDefaultInstance()\n                else -> currentHideTag.toBuilder()\n                    .setId(tag.id)\n                    .setName(tag.name)\n                    .build()\n            }\n        }\n    }\n\n    override val selectedCategoriesFlow: Flow<List<String>> = systemPreferences.data\n        .map { preferences ->\n            preferences.selectedCategoriesList.distinct()\n        }\n\n    override suspend fun setSelectedCategories(categories: List<String>) {\n        systemPreferences.updateData { preferences ->\n            preferences.toBuilder()\n                .clearSelectedCategories()\n                .addAllSelectedCategories(categories.distinct())\n                .build()\n        }\n    }\n\n    override suspend fun addSelectedCategory(tag: Tag) {\n        systemPreferences.updateData { preferences ->\n            preferences.toBuilder()\n                .addSelectedCategories(tag.id.toString())\n                .build()\n        }\n    }\n\n    override suspend fun removeSelectedCategory(tag: Tag) {\n        systemPreferences.updateData { preferences ->\n            preferences.toBuilder()\n                .clearSelectedCategories()\n                .addAllSelectedCategories(preferences.selectedCategoriesList.filter { it != tag.id.toString() })\n                .build()\n        }\n    }\n\n    override suspend fun getLastSyncTimestamp(): Long {\n        return systemPreferences.data.map { preferences ->\n            preferences.lastSyncTimestamp\n        }.first()\n    }\n\n    override suspend fun setLastSyncTimestamp(timestamp: Long) {\n        systemPreferences.updateData { preferences ->\n            preferences.toBuilder()\n                .setLastSyncTimestamp(timestamp)\n                .build()\n        }\n    }\n\n    override suspend fun setCurrentTimeStamp() {\n        systemPreferences.updateData { preferences ->\n            preferences.toBuilder()\n                .setLastSyncTimestamp(ZonedDateTime.now(ZoneId.systemDefault()).toEpochSecond())\n                .build()\n        }\n    }\n\n    override suspend fun getServerVersion(): String {\n        return systemPreferences.data.map { preferences ->\n            preferences.serverVersion\n        }.first()\n    }\n\n    override suspend fun setServerVersion(version: String) {\n        systemPreferences.updateData { preferences ->\n            preferences.toBuilder()\n                .setServerVersion(version)\n                .build()\n        }\n    }\n\n    override suspend fun getLastCrashLog(): String {\n        return systemPreferences.data.map { it.lastCrashLog }.first()\n    }\n\n    override suspend fun setLastCrashLog(crash: String) {\n        systemPreferences.updateData { preferences ->\n            preferences.toBuilder()\n                .setLastCrashLog(crash)\n                .build()\n        }\n    }\n\n    override suspend fun clearLastCrashLog() {\n        setLastCrashLog(\"\")\n    }\n}\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/room/converters/TagsConverter.kt",
    "content": "package com.desarrollodroide.data.local.room.converters\n\nimport androidx.room.TypeConverter\nimport com.google.gson.Gson\nimport com.google.gson.JsonParseException\nimport com.google.gson.reflect.TypeToken\nimport com.desarrollodroide.model.Tag\n\nclass TagsConverter {\n    @TypeConverter\n    fun fromTagsList(tags: List<Tag>): String {\n        val gson = Gson()\n        return gson.toJson(tags)\n    }\n\n    @TypeConverter\n    fun toTagsList(tagsString: String): List<Tag> {\n        return try {\n            val type = object : TypeToken<List<Tag>>() {}.type\n            Gson().fromJson(tagsString, type)\n        } catch (e: JsonParseException) {\n            emptyList()\n        }\n    }\n}"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/room/dao/BookmarkHtmlDao.kt",
    "content": "package com.desarrollodroide.data.local.room.dao\n\nimport androidx.room.Dao\nimport androidx.room.Insert\nimport androidx.room.OnConflictStrategy\nimport androidx.room.Query\nimport com.desarrollodroide.data.local.room.entity.BookmarkHtmlEntity\n\n@Dao\ninterface BookmarkHtmlDao {\n\n    @Insert(onConflict = OnConflictStrategy.REPLACE)\n    suspend fun insertOrUpdate(bookmarkHtml: BookmarkHtmlEntity)\n\n    @Query(\"SELECT readableContentHtml FROM bookmark_html WHERE id = :bookmarkId\")\n    suspend fun getHtmlContent(bookmarkId: Int): String?\n\n    @Query(\"SELECT * FROM bookmark_html WHERE id = :bookmarkId\")\n    suspend fun getBookmarkHtml(bookmarkId: Int): BookmarkHtmlEntity?\n}"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/room/dao/BookmarksDao.kt",
    "content": "package com.desarrollodroide.data.local.room.dao\n\nimport androidx.paging.PagingSource\nimport androidx.room.Dao\nimport androidx.room.Insert\nimport androidx.room.OnConflictStrategy\nimport androidx.room.Query\nimport androidx.room.Transaction\nimport androidx.room.Update\nimport com.desarrollodroide.data.local.room.entity.BookmarkEntity\nimport com.desarrollodroide.data.local.room.entity.BookmarkTagCrossRef\nimport kotlinx.coroutines.flow.Flow\n\n@Dao\ninterface BookmarksDao {\n\n  // Basic CRUD operations\n\n  /**\n   * Retrieves all bookmarks from the database.\n   * @return A Flow of List<BookmarkEntity> representing all bookmarks.\n   */\n  @Query(\"SELECT * FROM bookmarks\")\n  fun getAll(): Flow<List<BookmarkEntity>>\n\n  /**\n   * Inserts a single bookmark into the database and returns the new rowId.\n   * @param bookmark The BookmarkEntity to insert.\n   * @return The new rowId for the inserted item.\n   */\n  @Insert(onConflict = OnConflictStrategy.REPLACE)\n  suspend fun insertBookmark(bookmark: BookmarkEntity): Long\n\n  /**\n   * Inserts a list of bookmarks into the database, replacing any existing entries with the same IDs.\n   * @param bookmarks The list of BookmarkEntity objects to insert.\n   */\n  @Insert(onConflict = OnConflictStrategy.REPLACE)\n  suspend fun insertAll(bookmarks: List<BookmarkEntity>)\n\n  /**\n   * Deletes all bookmarks from the database.\n   */\n  @Query(\"DELETE FROM bookmarks\")\n  suspend fun deleteAll()\n\n  /**\n   * Deletes a specific bookmark by its ID.\n   * @param bookmarkId The ID of the bookmark to delete.\n   * @return The number of rows affected (should be 1 if successful, 0 if the bookmark was not found).\n   */\n  @Query(\"DELETE FROM bookmarks WHERE id = :bookmarkId\")\n  suspend fun deleteBookmarkById(bookmarkId: Int): Int\n\n  /**\n   * Checks if the bookmarks table is empty.\n   * @return true if the table is empty, false otherwise.\n   */\n  @Query(\"SELECT (SELECT COUNT(*) FROM bookmarks) == 0\")\n  suspend fun isEmpty(): Boolean\n\n  // Paging operations\n\n  /**\n   * Retrieves bookmarks for paging, filtered by search text and tags.\n   * @param searchText The text to search for in bookmark titles.\n   * @param tagIds The list of tag IDs to filter by.\n   * @return A PagingSource of BookmarkEntity objects.\n   */\n  @Query(\"\"\"\n        SELECT * FROM bookmarks\n        WHERE (:searchText = '' OR title LIKE '%' || :searchText || '%')\n        AND EXISTS (\n            SELECT 1 FROM bookmark_tag_cross_ref \n            WHERE bookmark_tag_cross_ref.bookmarkId = bookmarks.id\n            AND bookmark_tag_cross_ref.tagId IN (:tagIds)\n        )\n        ORDER BY id DESC\n    \"\"\")\n  fun getPagingBookmarks(\n    searchText: String,\n    tagIds: List<Int>\n  ): PagingSource<Int, BookmarkEntity>\n\n  /**\n   * Retrieves bookmarks for paging, filtered by search text without considering tags.\n   * @param searchText The text to search for in bookmark titles.\n   * @return A PagingSource of BookmarkEntity objects.\n   */\n  @Query(\"\"\"\n        SELECT * FROM bookmarks\n        WHERE title LIKE '%' || :searchText || '%'\n        ORDER BY id DESC\n    \"\"\")\n  fun getPagingBookmarksWithoutTags(searchText: String): PagingSource<Int, BookmarkEntity>\n\n  /**\n   * Retrieves bookmarks for paging, filtered by tags.\n   * @param tagIds The list of tag IDs to filter by.\n   * @return A PagingSource of BookmarkEntity objects.\n   */\n  @Query(\"\"\"\n        SELECT * FROM bookmarks\n        WHERE EXISTS (\n            SELECT 1 FROM bookmark_tag_cross_ref \n            WHERE bookmark_tag_cross_ref.bookmarkId = bookmarks.id\n            AND bookmark_tag_cross_ref.tagId IN (:tagIds)\n        )\n        ORDER BY id DESC\n    \"\"\")\n  fun getPagingBookmarksByTags(tagIds: List<Int>): PagingSource<Int, BookmarkEntity>\n\n  /**\n   * Retrieves all bookmarks for paging without any filters.\n   * @return A PagingSource of BookmarkEntity objects.\n   */\n  @Query(\"\"\"\n        SELECT * FROM bookmarks\n        ORDER BY id DESC\n    \"\"\")\n  fun getAllPagingBookmarks(): PagingSource<Int, BookmarkEntity>\n\n  // Tag-related operations\n\n  /**\n   * Inserts bookmark-tag cross references into the database.\n   * @param crossRefs The list of BookmarkTagCrossRef objects to insert.\n   */\n  @Insert(onConflict = OnConflictStrategy.REPLACE)\n  suspend fun insertBookmarkTagCrossRefs(crossRefs: List<BookmarkTagCrossRef>)\n\n  /**\n   * Clears all bookmark-tag cross references from the database.\n   */\n  @Query(\"DELETE FROM bookmark_tag_cross_ref\")\n  suspend fun clearBookmarkTagCrossRefs()\n\n  /**\n   * Inserts a list of bookmarks along with their associated tags.\n   * This method performs the following steps in a single transaction:\n   * 1. Clears existing bookmark-tag cross references\n   * 2. Deletes all existing bookmarks\n   * 3. Inserts the new bookmarks\n   * 4. Creates new bookmark-tag cross references for bookmarks with tags\n   *\n   * @param bookmarks The list of BookmarkEntity objects to insert, including their tags.\n   */\n  @Transaction\n  suspend fun insertAllWithTags(bookmarks: List<BookmarkEntity>) {\n    clearBookmarkTagCrossRefs()\n    deleteAll()\n    insertAll(bookmarks)\n    val bookmarksWithTags = bookmarks.filter { it.tags.isNotEmpty() }\n    bookmarksWithTags.forEach { bookmark ->\n      val crossRefs = bookmark.tags.map { tag ->\n        BookmarkTagCrossRef(bookmarkId = bookmark.id, tagId = tag.id)\n      }\n      insertBookmarkTagCrossRefs(crossRefs)\n    }\n  }\n\n  /**\n   * Updates an existing bookmark in the local database.\n   *\n   * This method uses Room's @Update annotation, which generates the necessary SQL\n   * to update the bookmark based on its primary key. If the bookmark doesn't exist\n   * in the database, no action will be taken.\n   *\n   * @param bookmark The BookmarkEntity to be updated in the database.\n   *                 It must have a valid ID that matches an existing entry.\n   */\n  @Update\n  suspend fun updateBookmark(bookmark: BookmarkEntity)\n\n  /**\n   * Retrieves a list of all bookmark IDs from the local database.\n   * This can be useful for performing operations on all bookmarks, such as\n   * deleting or updating them.\n   *\n   * @return A list of all bookmark IDs in the database.\n   */\n  @Query(\"SELECT id FROM bookmarks\")\n  suspend fun getAllBookmarkIds(): List<Int>\n\n  /**\n   * Retrieves a bookmark by its ID.\n   * This can be useful to determine if a bookmark already exists in the database\n   * and if its version is outdated.\n   *\n   * @param bookmarkId The ID of the bookmark to retrieve.\n   * @return The BookmarkEntity if found, or null otherwise.\n   */\n  @Query(\"SELECT * FROM bookmarks WHERE id = :bookmarkId\")\n  suspend fun getBookmarkById(bookmarkId: Int): BookmarkEntity?\n\n  /**\n   * Updates an existing bookmark in the local database, including its associated tags.\n   *\n   * This method performs the following steps in a single transaction:\n   * 1. Updates the bookmark entity\n   * 2. Deletes all existing tag associations for the bookmark\n   * 3. Inserts new tag associations for the bookmark\n   *\n   * @param bookmark The BookmarkEntity to be updated in the database.\n   *                 It must have a valid ID that matches an existing entry.\n   */\n  @Transaction\n  suspend fun updateBookmarkWithTags(bookmark: BookmarkEntity) {\n    updateBookmark(bookmark)\n    deleteBookmarkTagCrossRefs(bookmark.id)\n    val newCrossRefs = bookmark.tags.map { tag ->\n      BookmarkTagCrossRef(bookmarkId = bookmark.id, tagId = tag.id)\n    }\n    insertBookmarkTagCrossRefs(newCrossRefs)\n  }\n\n  /**\n   * Deletes all bookmark-tag cross references associated with a bookmark.\n   *\n   * @param bookmarkId The ID of the bookmark to delete associated tags for.\n   */\n  @Query(\"DELETE FROM bookmark_tag_cross_ref WHERE bookmarkId = :bookmarkId\")\n  suspend fun deleteBookmarkTagCrossRefs(bookmarkId: Int)\n\n}"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/room/dao/TagDao.kt",
    "content": "package com.desarrollodroide.data.local.room.dao\n\nimport androidx.room.Dao\nimport androidx.room.Delete\nimport androidx.room.Insert\nimport androidx.room.OnConflictStrategy\nimport androidx.room.Query\nimport androidx.room.Transaction\nimport com.desarrollodroide.data.local.room.entity.TagEntity\nimport kotlinx.coroutines.flow.Flow\n\n@Dao\ninterface TagDao {\n    @Query(\"SELECT * FROM tags\")\n    fun getAllTags(): Flow<List<TagEntity>>\n\n    @Insert(onConflict = OnConflictStrategy.REPLACE)\n    suspend fun insertTag(tag: TagEntity)\n\n    @Insert(onConflict = OnConflictStrategy.REPLACE)\n    suspend fun insertAllTags(tags: List<TagEntity>)\n\n    @Delete\n    suspend fun deleteTag(tag: TagEntity)\n\n    @Query(\"DELETE FROM tags\")\n    suspend fun deleteAllTags()\n\n    @Transaction\n    @Query(\"\"\"\n        SELECT DISTINCT t.* \n        FROM tags t\n        LEFT JOIN bookmark_tag_cross_ref bt ON t.id = bt.tagId \n        ORDER BY t.name\n    \"\"\")\n    fun observeAllTags(): Flow<List<TagEntity>>\n}\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/room/database/BookmarksDatabase.kt",
    "content": "package com.desarrollodroide.data.local.room.database\n\nimport android.content.Context\nimport android.util.Log\nimport androidx.room.Database\nimport androidx.room.Room\nimport androidx.room.RoomDatabase\nimport androidx.room.TypeConverters\nimport androidx.room.migration.Migration\nimport androidx.sqlite.db.SupportSQLiteDatabase\nimport com.desarrollodroide.data.local.room.dao.BookmarksDao\nimport com.desarrollodroide.data.local.room.entity.BookmarkEntity\nimport com.desarrollodroide.data.local.room.converters.TagsConverter\nimport com.desarrollodroide.data.local.room.dao.BookmarkHtmlDao\nimport com.desarrollodroide.data.local.room.dao.TagDao\nimport com.desarrollodroide.data.local.room.entity.BookmarkHtmlEntity\nimport com.desarrollodroide.data.local.room.entity.BookmarkTagCrossRef\nimport com.desarrollodroide.data.local.room.entity.TagEntity\nimport java.util.concurrent.Executors\n\n@Database(\n    entities = [BookmarkEntity::class, TagEntity::class, BookmarkHtmlEntity::class, BookmarkTagCrossRef::class],\n    version = 7\n)\n@TypeConverters(TagsConverter::class)\nabstract class BookmarksDatabase : RoomDatabase() {\n\n    abstract fun bookmarksDao(): BookmarksDao\n    abstract fun tagDao(): TagDao\n    abstract fun bookmarkHtmlDao(): BookmarkHtmlDao\n\n    companion object {\n        // Migraciones anteriores\n        val MIGRATION_1_2: Migration = object : Migration(1, 2) {\n            override fun migrate(database: SupportSQLiteDatabase) {\n                database.execSQL(\"ALTER TABLE bookmarks ADD COLUMN has_ebook INTEGER NOT NULL DEFAULT 0\")\n            }\n        }\n\n        val MIGRATION_2_3: Migration = object : Migration(2, 3) {\n            override fun migrate(database: SupportSQLiteDatabase) {\n                database.execSQL(\"ALTER TABLE bookmarks ADD COLUMN create_ebook INTEGER NOT NULL DEFAULT 0\")\n            }\n        }\n\n        val MIGRATION_3_4: Migration = object : Migration(3, 4) {\n            override fun migrate(database: SupportSQLiteDatabase) {\n                database.execSQL(\n                    \"\"\"\n                    CREATE TABLE IF NOT EXISTS `tags` (\n                        `id` INTEGER PRIMARY KEY NOT NULL,\n                        `name` TEXT NOT NULL,\n                        `n_bookmarks` INTEGER NOT NULL\n                    )\n                    \"\"\"\n                )\n            }\n        }\n\n        val MIGRATION_4_5: Migration = object : Migration(4, 5) {\n            override fun migrate(database: SupportSQLiteDatabase) {\n                database.execSQL(\n                    \"\"\"\n                    CREATE TABLE IF NOT EXISTS `bookmark_html` (\n                        `id` INTEGER PRIMARY KEY NOT NULL,\n                        `url` TEXT NOT NULL,\n                        `readableContentHtml` TEXT NOT NULL\n                    )\n                    \"\"\"\n                )\n            }\n        }\n\n        val MIGRATION_5_6: Migration = object : Migration(5, 6) {\n            override fun migrate(db: SupportSQLiteDatabase) {\n                db.execSQL(\"\"\"\n                    CREATE TABLE IF NOT EXISTS `bookmark_tag_cross_ref` (\n                        `bookmarkId` INTEGER NOT NULL,\n                        `tagId` INTEGER NOT NULL,\n                        PRIMARY KEY(`bookmarkId`, `tagId`)\n                    )\n                \"\"\")\n            }\n        }\n\n        val MIGRATION_6_7: Migration = object : Migration(6, 7) {\n            override fun migrate(db: SupportSQLiteDatabase) {\n                db.execSQL(\"ALTER TABLE bookmarks ADD COLUMN created_at TEXT NOT NULL DEFAULT ''\")\n            }\n        }\n\n        fun create(context: Context): BookmarksDatabase {\n            return Room.databaseBuilder(\n                context,\n                BookmarksDatabase::class.java, \"bookmarks_database\"\n            )\n                .allowMainThreadQueries()\n                .addMigrations(\n                    MIGRATION_1_2,\n                    MIGRATION_2_3,\n                    MIGRATION_3_4,\n                    MIGRATION_4_5,\n                    MIGRATION_5_6,\n                    MIGRATION_6_7\n                )\n                .setQueryCallback({ sqlQuery, bindArgs ->\n                    Log.d(\"SQL Query\", \"SQL Query: $sqlQuery SQL Args: $bindArgs\")\n                }, Executors.newSingleThreadExecutor())\n                .build()\n        }\n    }\n}\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/room/entity/BookmarkEntity.kt",
    "content": "package com.desarrollodroide.data.local.room.entity\n\nimport androidx.room.ColumnInfo\nimport androidx.room.Entity\nimport androidx.room.PrimaryKey\nimport com.desarrollodroide.model.Tag\n\n@Entity(tableName = \"bookmarks\")\ndata class BookmarkEntity(\n    @PrimaryKey\n    val id: Int,\n    val url: String,\n    val title: String,\n    val excerpt: String,\n    val author: String,\n    @ColumnInfo(name = \"is_public\")\n    val isPublic: Int,\n    @ColumnInfo(name = \"created_at\")\n    val createdAt: String,\n    @ColumnInfo(name = \"modified_date\")\n    val modified: String,\n    @ColumnInfo(name = \"image_url\")\n    val imageURL: String,\n    @ColumnInfo(name = \"has_content\")\n    val hasContent: Boolean,\n    @ColumnInfo(name = \"has_archive\")\n    val hasArchive: Boolean,\n    @ColumnInfo(name = \"has_ebook\")\n    val hasEbook: Boolean,\n    val tags: List<Tag>,\n    @ColumnInfo(name = \"create_archive\")\n    val createArchive: Boolean,\n    @ColumnInfo(name = \"create_ebook\")\n    val createEbook: Boolean,\n)"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/room/entity/BookmarkHtmlEntity.kt",
    "content": "package com.desarrollodroide.data.local.room.entity\n\nimport androidx.room.Entity\nimport androidx.room.PrimaryKey\n\n@Entity(tableName = \"bookmark_html\")\ndata class BookmarkHtmlEntity(\n    @PrimaryKey\n    val id: Int,\n    val url: String,\n    val readableContentHtml: String\n)\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/room/entity/BookmarkTagCrossRef.kt",
    "content": "package com.desarrollodroide.data.local.room.entity\n\nimport androidx.room.ColumnInfo\nimport androidx.room.Entity\n\n@Entity(tableName = \"bookmark_tag_cross_ref\", primaryKeys = [\"bookmarkId\", \"tagId\"])\ndata class BookmarkTagCrossRef(\n    @ColumnInfo(name = \"bookmarkId\") val bookmarkId: Int,\n    @ColumnInfo(name = \"tagId\") val tagId: Int\n)"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/room/entity/BookmarkWithTags.kt",
    "content": "package com.desarrollodroide.data.local.room.entity\n\nimport androidx.room.Embedded\nimport androidx.room.Junction\nimport androidx.room.Relation\n\ndata class BookmarkWithTags(\n    @Embedded val bookmark: BookmarkEntity,\n    @Relation(\n        parentColumn = \"id\",\n        entityColumn = \"id\",\n        associateBy = Junction(BookmarkTagCrossRef::class)\n    )\n    val tags: List<TagEntity>\n)\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/room/entity/TagEntity.kt",
    "content": "package com.desarrollodroide.data.local.room.entity\n\nimport androidx.room.ColumnInfo\nimport androidx.room.Entity\nimport androidx.room.PrimaryKey\n\n@Entity(tableName = \"tags\")\ndata class TagEntity(\n    @PrimaryKey(autoGenerate = true) val id: Int,\n    val name: String,\n    @ColumnInfo(name = \"n_bookmarks\") val nBookmarks: Int,\n)"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/mapper/Mapper.kt",
    "content": "package com.desarrollodroide.data.mapper\n\nimport com.desarrollodroide.data.UserPreferences\nimport com.desarrollodroide.data.helpers.AddTagDTOAdapter\nimport com.desarrollodroide.data.helpers.TagTypeAdapter\nimport com.desarrollodroide.data.local.room.entity.BookmarkEntity\nimport com.desarrollodroide.data.local.room.entity.TagEntity\nimport com.desarrollodroide.model.*\nimport com.desarrollodroide.network.model.*\nimport com.google.gson.ExclusionStrategy\nimport com.google.gson.FieldAttributes\nimport com.google.gson.GsonBuilder\n\nfun SessionDTO.toDomainModel() = User(\n    token = token?:\"\",\n    session = session?:\"\",\n    account = account?.toDomainModel()?:Account()\n)\n\nfun AccountDTO.toDomainModel() = Account(\n    id = -1,\n    userName = userName?:\"\",\n    password = password?:\"\",\n    owner = isOwner?:false,\n    serverUrl = \"\",\n)\n\nfun SessionDTO.toProtoEntity(): UserPreferences = UserPreferences.newBuilder()\n    .setSession(session?:\"\")\n    .setUsername(account?.userName?:\"\")\n    .setId(account?.id?:-1)\n    .setOwner(account?.isOwner?:false)\n    .build()\n\nfun BookmarkDTO.toDomainModel(serverUrl: String = \"\") = Bookmark(\n    id = id?:0,\n    url = url?:\"\",\n    title = title?:\"\",\n    excerpt = excerpt?:\"\",\n    author = author?:\"\",\n    public = public?:0,\n    createAt = createdAt?:\"\",\n    modified = modified?:\"\",\n    imageURL = \"$serverUrl$imageURL\",\n    hasContent = hasContent?:false,\n    hasArchive = hasArchive?:false,\n    hasEbook = hasEbook?:false,\n    tags = tags?.map { it.toDomainModel() }?: emptyList(),\n    createArchive = createArchive?:false,\n    createEbook = createEbook?:false,\n)\n\nfun BookmarksDTO.toDomainModel(serverUrl: String) = Bookmarks(\n    error = \"\",\n    page = resolvedPage()?:0,\n    maxPage = resolvedMaxPage()?:0,\n    bookmarks = resolvedBookmarks()?.map { it.toDomainModel(serverUrl) }?: emptyList()\n)\n\nfun TagDTO.toDomainModel() = Tag(\n    id = id?:0,\n    name = name?:\"\",\n    selected = false,\n    nBookmarks = nBookmarks?:0\n)\n\nfun TagDTO.toEntityModel() = TagEntity(\n    id = id?:0,\n    name = name?:\"\",\n    nBookmarks = nBookmarks?:0\n)\n\nfun TagEntity.toDomainModel() = Tag(\n    id = id,\n    name = name,\n    selected = false,\n    nBookmarks = nBookmarks\n)\n\nfun Account.toRequestBody() =\n    LoginRequestPayload(\n        username = userName,\n        password = password\n    )\n\nfun Tag.toEntityModel() = TagEntity(\n    id = id,\n    name = name,\n    nBookmarks = nBookmarks\n)\n\nfun BookmarkDTO.toEntityModel() = BookmarkEntity(\n    id = id?:0,\n    url = url?:\"\",\n    title = title?:\"\",\n    excerpt = excerpt?:\"\",\n    author = author?:\"\",\n    isPublic = public?:0,\n    createdAt = createdAt?:\"\",\n    modified = modified?:\"\",\n    imageURL = imageURL?:\"\",\n    hasContent = hasContent?:false,\n    hasArchive = hasArchive?:false,\n    hasEbook = hasEbook?:false,\n    tags = tags?.map { it.toDomainModel() } ?: emptyList(),\n    createArchive = createArchive?:false,\n    createEbook = createEbook?:false,\n)\n\nfun BookmarkEntity.toDomainModel() = Bookmark(\n    id = id,\n    url = url,\n    title = title,\n    excerpt = excerpt,\n    author = author,\n    public = isPublic,\n    createAt = createdAt,\n    modified = modified,\n    imageURL = imageURL,\n    hasContent = hasContent,\n    hasArchive = hasArchive,\n    hasEbook = hasEbook,\n    tags = tags,\n    createArchive = createArchive,\n    createEbook = createEbook,\n)\n\nfun Bookmark.toEntityModel(modified: String? = null) = BookmarkEntity(\n    id = id,\n    url = url,\n    title = title,\n    excerpt = excerpt,\n    author = author,\n    isPublic = public,\n    createdAt = createAt,\n    modified = modified ?: this.modified,\n    imageURL = imageURL,\n    hasContent = hasContent,\n    hasArchive = hasArchive,\n    hasEbook = hasEbook,\n    tags = tags,\n    createArchive = createArchive,\n    createEbook = createEbook,\n)\n\nfun UpdateCachePayload.toDTO() = UpdateCachePayloadDTO(\n    createArchive = createArchive,\n    createEbook = createEbook,\n    ids = ids,\n    keepMetadata = keepMetadata,\n)\n\nfun UpdateCachePayload.toV1DTO() = UpdateCachePayloadV1DTO(\n    createArchive = createArchive,\n    createEbook = createEbook,\n    ids = ids,\n    keepMetadata = keepMetadata,\n    skipExist = skipExist\n)\n\nfun LivenessResponseDTO.toDomainModel() = LivenessResponse(\n    ok = ok?:false,\n    message = message?.toDomainModel()\n)\n\nfun ReleaseInfoDTO.toDomainModel() = ReleaseInfo(\n    version = version?:\"\",\n    date = date?:\"\",\n    commit = commit?:\"\"\n)\n\nfun LoginResponseDTO.toProtoEntity(\n    userName: String,\n): UserPreferences = UserPreferences.newBuilder()\n    .setSession(message?.session ?: message?.token ?: \"\")\n    .setUsername(userName)\n    .setToken(message?.token?:\"\")\n    .build()\n\nfun LoginResponseMessageDTO.toDomainModel() = LoginResponseMessage(\n    expires = expires?:0,\n    session = session?:\"\",\n    token = token?:\"\"\n)\n\nfun ReadableContentResponseDTO.toDomainModel() = ReadableContent(\n    ok = ok?:false,\n    message = resolvedMessage()?.toDomainModel() ?: ReadableMessage(\"\", \"\")\n)\n\nfun ReadableMessageDto.toDomainModel() = ReadableMessage(\n    content = content?:\"\",\n    html = html?:\"\"\n)\n\n\nfun SyncBookmarksResponseDTO.toDomainModel(): SyncBookmarksResponse {\n    return SyncBookmarksResponse(\n        deleted = message.deleted ?: emptyList(),\n        modified = message.modified?.toDomainModel() ?: ModifiedBookmarks(emptyList(), 0, 0)\n    )\n}\n\nfun ModifiedBookmarksDTO.toDomainModel(): ModifiedBookmarks {\n    return ModifiedBookmarks(\n        bookmarks = bookmarks?.map { it.toDomainModel() } ?: emptyList(),\n        maxPage = maxPage ?: 0,\n        page = page ?: 0\n    )\n}\n\nfun Bookmark.toAddBookmarkDTO() = BookmarkDTO(\n    id = null,\n    url = url,\n    title = title,\n    excerpt = excerpt,\n    author = null,\n    public = public,\n    createdAt = null,\n    modified = null,\n    imageURL = null,\n    hasContent = null,\n    hasArchive = null,\n    hasEbook = null,\n    tags = tags.map { TagDTO(id = null, name = it.name.lowercase().trim(), nBookmarks = null) },\n    createArchive = createArchive,\n    createEbook = createEbook\n)\n\nfun Bookmark.toEditBookmarkDTO() = BookmarkDTO(\n    id = id,\n    url = url,\n    title = title,\n    excerpt = excerpt,\n    author = author,\n    public = public,\n    createdAt = createAt,\n    modified = modified,\n    imageURL = imageURL,\n    hasContent = hasContent,\n    hasArchive = hasArchive,\n    hasEbook = hasEbook,\n    tags = tags.map { TagDTO(id = it.id, name = it.name.lowercase().trim(), nBookmarks = null) },\n    createArchive = createArchive,\n    createEbook = createEbook\n)\n\n/**\n * Converts a Bookmark to JSON format for updating existing bookmarks.\n * Includes all fields of the bookmark in the JSON output.\n */\nfun BookmarkDTO.toEditBookmarkJson() = GsonBuilder()\n    .registerTypeAdapter(TagDTO::class.java, AddTagDTOAdapter())\n    .setExclusionStrategies(object : ExclusionStrategy {\n        override fun shouldSkipField(f: FieldAttributes): Boolean {\n            return f.name == \"hasEbook\" || f.name == \"createEbook\"\n        }\n        override fun shouldSkipClass(clazz: Class<*>): Boolean = false\n    })\n    .create()\n    .toJson(this)\n\n\n\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/AuthRepository.kt",
    "content": "package com.desarrollodroide.data.repository\n\nimport com.desarrollodroide.common.result.Result\nimport com.desarrollodroide.model.User\nimport kotlinx.coroutines.flow.Flow\n\ninterface AuthRepository {\n\n  fun sendLogin(\n    username: String,\n    password: String,\n    serverUrl: String\n  ): Flow<Result<User?>>\n\n  fun sendLogout(\n    serverUrl: String,\n    xSession: String\n  ): Flow<Result<String?>>\n\n  fun sendLoginV1(\n    username: String,\n    password: String,\n    serverUrl: String\n  ): Flow<Result<User?>>\n}"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/AuthRepositoryImpl.kt",
    "content": "package com.desarrollodroide.data.repository\n\nimport com.desarrollodroide.common.result.ErrorHandler\nimport com.desarrollodroide.data.extensions.removeTrailingSlash\nimport com.desarrollodroide.data.extensions.toJson\nimport com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource\nimport com.desarrollodroide.data.mapper.*\nimport com.desarrollodroide.model.User\nimport com.desarrollodroide.network.model.LoginRequestPayload\nimport com.desarrollodroide.network.model.LoginResponseDTO\nimport com.desarrollodroide.network.model.SessionDTO\nimport com.desarrollodroide.network.retrofit.NetworkBoundResource\nimport com.desarrollodroide.network.retrofit.RetrofitNetwork\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.flowOf\nimport kotlinx.coroutines.flow.flowOn\n\nclass AuthRepositoryImpl(\n    private val apiService: RetrofitNetwork,\n    private val settingsPreferenceDataSource: SettingsPreferenceDataSource,\n    private val errorHandler: ErrorHandler\n) : AuthRepository {\n\n    override fun sendLogin(\n        username: String,\n        password: String,\n        serverUrl: String\n    ) = object :\n        NetworkBoundResource<SessionDTO, User>(errorHandler = errorHandler) {\n\n        override suspend fun saveRemoteData(response: SessionDTO) {\n            settingsPreferenceDataSource.saveUser(\n                password = password,\n                session = response.toProtoEntity(),\n                serverUrl = serverUrl,\n            )\n        }\n        override fun fetchFromLocal() = settingsPreferenceDataSource.getUser()\n\n        override suspend fun fetchFromRemote() = apiService.sendLogin(\n            \"${serverUrl.removeTrailingSlash()}/api/login\",\n            LoginRequestPayload(\n                username = username,\n                password = password\n            ).toJson()\n        )\n\n        override fun shouldFetch(data: User?) = true\n\n    }.asFlow().flowOn(Dispatchers.IO)\n\n\n    override fun sendLogout(\n        serverUrl: String,\n        xSession: String\n    ) = object :\n        NetworkBoundResource<String, String>(errorHandler = errorHandler) {\n\n        override suspend fun saveRemoteData(response: String) {\n            settingsPreferenceDataSource.resetData()\n        }\n\n        override fun fetchFromLocal() = flowOf(\"\")\n\n        override suspend fun fetchFromRemote() = apiService.sendLogout(\n            xSessionId = xSession,\n            url = \"${serverUrl.removeTrailingSlash()}/api/logout\")\n\n        override fun shouldFetch(data: String?) = true\n\n    }.asFlow().flowOn(Dispatchers.IO)\n\n    override fun sendLoginV1(\n        username: String,\n        password: String,\n        serverUrl: String\n    ) = object :\n        NetworkBoundResource<LoginResponseDTO, User>(errorHandler = errorHandler) {\n\n        override suspend fun saveRemoteData(response: LoginResponseDTO) {\n            settingsPreferenceDataSource.saveUser(\n                password = password,\n                session = response.toProtoEntity(username),\n                serverUrl = serverUrl,\n            )\n        }\n        override fun fetchFromLocal() = settingsPreferenceDataSource.getUser()\n\n        override suspend fun fetchFromRemote() = apiService.sendLoginV1(\n            \"${serverUrl.removeTrailingSlash()}/api/v1/auth/login\",\n            LoginRequestPayload(\n                username = username,\n                password = password\n            ).toJson()\n        )\n\n        override fun shouldFetch(data: User?) = true\n\n    }.asFlow().flowOn(Dispatchers.IO)\n\n}"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/BookmarksRepository.kt",
    "content": "package com.desarrollodroide.data.repository\n\nimport androidx.paging.PagingData\nimport kotlinx.coroutines.flow.Flow\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.common.result.Result\nimport com.desarrollodroide.model.ReadableContent\nimport com.desarrollodroide.model.SyncBookmarksRequestPayload\nimport com.desarrollodroide.model.SyncBookmarksResponse\nimport com.desarrollodroide.model.Tag\nimport com.desarrollodroide.model.UpdateCachePayload\n\ninterface BookmarksRepository {\n\n  fun getBookmarks(\n    xSession: String,\n    serverUrl: String\n  ): Flow<Result<List<Bookmark>?>>\n\n  fun getPagingBookmarks(\n      xSession: String,\n      serverUrl: String,\n      searchText: String,\n      tags: List<Tag>,\n      saveToLocal: Boolean\n  ): Flow<PagingData<Bookmark>>\n\n  suspend fun addBookmark(\n    xSession: String,\n    serverUrl: String,\n    bookmark: Bookmark\n  ): Bookmark\n\n  suspend fun deleteBookmark(\n    xSession: String,\n    serverUrl: String,\n    bookmarkId: Int\n  )\n\n  suspend fun editBookmark(\n    xSession: String,\n    serverUrl: String,\n    bookmark: Bookmark\n  ): Bookmark\n\n  suspend fun deleteAllLocalBookmarks()\n\n  suspend fun updateBookmarkCacheV1(\n    token: String,\n    serverUrl: String,\n    updateCachePayload: UpdateCachePayload,\n    bookmark: Bookmark?,\n  ): List<Bookmark>\n\n  fun getBookmarkReadableContent(\n    token: String,\n    serverUrl: String,\n    bookmarkId: Int\n  ): Flow<Result<ReadableContent>>\n\n  suspend fun syncAllBookmarks(\n    xSession: String,\n    serverUrl: String\n  ): Flow<SyncStatus>\n\n  fun getLocalPagingBookmarks(\n    tags: List<Tag>,\n    searchText: String\n  ): Flow<PagingData<Bookmark>>\n\n  fun syncBookmarks(\n    token: String,\n    serverUrl: String,\n    syncBookmarksRequestPayload: SyncBookmarksRequestPayload\n  ): Flow<Result<SyncBookmarksResponse>>\n\n  fun getBookmarkById(\n      token: String,\n      serverUrl: String,\n      bookmarkId: Int\n    ): Flow<Result<Bookmark?>>\n}"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/BookmarksRepositoryImpl.kt",
    "content": "package com.desarrollodroide.data.repository\n\nimport android.util.Log\nimport androidx.paging.Pager\nimport androidx.paging.PagingConfig\nimport androidx.paging.PagingData\nimport androidx.paging.map\nimport com.desarrollodroide.common.result.ErrorHandler\nimport com.desarrollodroide.common.result.Result\nimport com.desarrollodroide.data.extensions.removeTrailingSlash\nimport com.desarrollodroide.data.extensions.toJson\nimport com.desarrollodroide.data.helpers.SESSION_HAS_BEEN_EXPIRED\nimport com.desarrollodroide.data.local.room.dao.BookmarksDao\nimport com.desarrollodroide.data.local.room.entity.BookmarkEntity\nimport com.desarrollodroide.data.mapper.*\nimport com.desarrollodroide.data.repository.paging.BookmarkPagingSource\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.model.ReadableContent\nimport com.desarrollodroide.model.SyncBookmarksRequestPayload\nimport com.desarrollodroide.model.SyncBookmarksResponse\nimport com.desarrollodroide.model.Tag\nimport com.desarrollodroide.model.UpdateCachePayload\nimport com.desarrollodroide.network.model.BookmarkDTO\nimport com.desarrollodroide.network.model.BookmarksDTO\nimport com.desarrollodroide.network.model.SingleBookmarkResponseDTO\nimport com.desarrollodroide.network.model.ReadableContentResponseDTO\nimport com.desarrollodroide.network.model.SyncBookmarksResponseDTO\nimport com.desarrollodroide.network.retrofit.NetworkBoundResource\nimport com.desarrollodroide.network.retrofit.NetworkNoCacheResource\nimport com.desarrollodroide.network.retrofit.RetrofitNetwork\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.flow\nimport kotlinx.coroutines.flow.flowOn\nimport kotlinx.coroutines.flow.map\nimport retrofit2.Response\nimport java.time.LocalDateTime\nimport java.time.format.DateTimeFormatter\n\nclass BookmarksRepositoryImpl(\n    private val apiService: RetrofitNetwork,\n    private val bookmarksDao: BookmarksDao,\n    private val errorHandler: ErrorHandler\n) : BookmarksRepository {\n\n    private val TAG = \"BookmarksRepository\"\n\n    override fun getBookmarks(\n        xSession: String,\n        serverUrl: String\n    ) = object :\n        NetworkBoundResource<BookmarksDTO, List<Bookmark>>(errorHandler = errorHandler) {\n\n        override suspend fun saveRemoteData(response: BookmarksDTO) {\n            response.resolvedBookmarks()?.map { it.toEntityModel() }?.let { bookmarksList ->\n                bookmarksDao.deleteAll()\n                bookmarksDao.insertAll(bookmarksList)\n            }\n        }\n\n        override fun fetchFromLocal() = bookmarksDao.getAll().map { bookmarks ->\n            bookmarks.map { it.toDomainModel() }\n        }\n\n        override suspend fun fetchFromRemote() = apiService.getBookmarks(\n            xSessionId = xSession,\n            url = \"${serverUrl.removeTrailingSlash()}/api/bookmarks\"\n        )\n\n        override fun shouldFetch(data: List<Bookmark>?) = true\n\n    }.asFlow().flowOn(Dispatchers.IO)\n\n    override fun getPagingBookmarks(\n        xSession: String,\n        serverUrl: String,\n        searchText: String,\n        tags: List<Tag>,\n        saveToLocal: Boolean\n    ): Flow<PagingData<Bookmark>> {\n        return Pager(\n            config = PagingConfig(pageSize = 20, prefetchDistance = 2),\n            pagingSourceFactory = {\n                BookmarkPagingSource(\n                    remoteDataSource = apiService,\n                    bookmarksDao = bookmarksDao,\n                    serverUrl = serverUrl,\n                    xSessionId = xSession,\n                    searchText = searchText,\n                    tags = tags,\n                    saveToLocal = saveToLocal\n                )\n            }\n        ).flow\n    }\n\n    /**\n     * Retrieves a paginated list of bookmarks from the local database using Room and Paging.\n     *\n     * Configurations:\n     * - `pageSize = 30`: Suggests loading 30 items per page.\n     * - `prefetchDistance = 2`: Prefetches 2 pages ahead of the currently loaded page.\n     * - `enablePlaceholders = false`: Disables placeholders for unloaded items.\n     *\n     * Behavior:\n     * - Although `pageSize` is set to 30, Room may initially load more items (90 in this case) as an optimization\n     *   to reduce database queries and improve user experience during initial loads.\n     * - Subsequent loads will fetch additional items in increments of 30 as the user scrolls.\n     *\n     * @param tags List of tags to filter bookmarks.\n     * @param searchText Text to search bookmarks by title.\n     * @return A Flow of paginated data to observe and update the UI as more data is loaded.\n     */\n\n    override fun getLocalPagingBookmarks(\n        tags: List<Tag>,\n        searchText: String\n    ): Flow<PagingData<Bookmark>> {\n        val processedSearchText = searchText.trim()\n        val tagIds = tags.map { it.id }\n        return Pager(\n            config = PagingConfig(\n                pageSize = 30,\n                prefetchDistance = 2,\n                enablePlaceholders = false\n            ),\n            pagingSourceFactory = {\n                when {\n                    processedSearchText.isNotEmpty() && tagIds.isNotEmpty() -> {\n                        bookmarksDao.getPagingBookmarks(searchText = processedSearchText, tagIds = tagIds)\n                    }\n                    processedSearchText.isNotEmpty() && tagIds.isEmpty() -> {\n                        bookmarksDao.getPagingBookmarksWithoutTags(searchText = processedSearchText)\n                    }\n                    processedSearchText.isEmpty() && tagIds.isNotEmpty() -> {\n                        bookmarksDao.getPagingBookmarksByTags(tagIds = tagIds)\n                    }\n                    else -> {\n                        bookmarksDao.getAllPagingBookmarks()\n                    }\n                }\n            }\n        ).flow.map { pagingData ->\n            pagingData.map {\n                it.toDomainModel()\n            }\n        }\n    }\n\n    /**\n     * Synchronizes all bookmarks from the remote server to the local database.\n     *\n     * This method performs a full synchronization of all bookmarks, regardless of the current\n     * pagination state or user scroll position. It fetches all pages of bookmarks from the server\n     * and updates the local database accordingly.\n     *\n     * @param xSession The session token for authentication with the remote API.\n     * @param serverUrl The base URL of the server API.\n     * @return Flow<SyncStatus> A flow emitting the current status of the synchronization process.\n     *\n     * The flow emits the following states:\n     * - SyncStatus.Started: When the sync process begins.\n     * - SyncStatus.InProgress(currentPage: Int): As each page is being fetched and processed.\n     * - SyncStatus.Completed(totalBookmarks: Int): When all bookmarks have been successfully synced.\n     * - SyncStatus.Error(error: Result.ErrorType): If an error occurs during the sync process.\n     *\n     * Note: This method performs a complete sync independently of RemoteMediator.\n     * Use it for full synchronization when RemoteMediator's on-demand loading is insufficient.\n     */\n    override suspend fun syncAllBookmarks(\n        xSession: String,\n        serverUrl: String,\n    ): Flow<SyncStatus> = flow {\n        var currentPage = 1\n        var hasNextPage = true\n        val allBookmarks = mutableListOf<BookmarkEntity>()\n        try {\n            Log.d(TAG, \"Sync started\")\n            emit(SyncStatus.Started)\n\n            while (hasNextPage) {\n                Log.d(TAG, \"Fetching bookmarks for page $currentPage\")\n                emit(SyncStatus.InProgress(currentPage))\n                val bookmarksDto = apiService.getPagingBookmarks(\n                    xSessionId = xSession,\n                    url = \"${serverUrl.removeTrailingSlash()}/api/bookmarks?page=$currentPage\"\n                )\n                Log.d(TAG, \"Received response for page $currentPage with status: ${bookmarksDto.code()}\")\n                if (bookmarksDto.errorBody()?.string() == SESSION_HAS_BEEN_EXPIRED) {\n                    Log.e(TAG, \"Session has expired\")\n                    emit(SyncStatus.Error(Result.ErrorType.SessionExpired(message = SESSION_HAS_BEEN_EXPIRED)))\n                    return@flow\n                }\n                val bookmarks = bookmarksDto.body()?.resolvedBookmarks()?.map { it.toEntityModel() } ?: emptyList()\n                Log.d(TAG, \"Fetched ${bookmarks.size} bookmarks for page $currentPage\")\n                allBookmarks.addAll(bookmarks)\n                hasNextPage = hasNextPage(bookmarksDto)\n                Log.d(TAG, \"Has next page: $hasNextPage\")\n                if (hasNextPage) {\n                    currentPage++\n                }\n            }\n            Log.d(TAG, \"Inserting ${allBookmarks.size} bookmarks into database\")\n            bookmarksDao.insertAllWithTags(allBookmarks)\n            Log.d(TAG, \"Sync completed with ${allBookmarks.size} bookmarks\")\n            emit(SyncStatus.Completed(allBookmarks.size))\n        } catch (e: Exception) {\n            Log.e(TAG, \"Error during sync: ${e.message}\")\n            emit(SyncStatus.Error(Result.ErrorType.Unknown(throwable = e)))\n        }\n    }\n\n    private fun hasNextPage(bookmarksDto: Response<BookmarksDTO>): Boolean {\n        val body = bookmarksDto.body() ?: return false\n        val currentPage = body.resolvedPage() ?: return false\n        val maxPage = body.resolvedMaxPage() ?: return false\n        val bookmarks = body.resolvedBookmarks()\n\n        return currentPage < maxPage && bookmarks?.isNotEmpty() == true\n    }\n\n    override suspend fun addBookmark(\n        xSession: String,\n        serverUrl: String,\n        bookmark: Bookmark\n    ): Bookmark {\n        val response = apiService.addBookmark(\n            url = \"${serverUrl.removeTrailingSlash()}/api/bookmarks\",\n            xSessionId = xSession,\n            body = bookmark.toAddBookmarkDTO().toJson()\n        )\n        if (response.isSuccessful) {\n            response.body()?.resolvedBookmark()?.let {\n                return it.toDomainModel()\n            }\n            throw IllegalStateException(\"Response body is null\")\n        } else {\n            throw IllegalStateException(\"Error adding bookmark: ${response.errorBody()?.string()}\")\n        }\n    }\n\n    /**\n     * Deletes a bookmark from the remote server.\n     * The method uses a NetworkNoCacheResource to handle the network operation and error handling.\n     *\n     * @param xSession The session token for authentication with the remote API.\n     * @param serverUrl The base URL of the server API.\n     * @param bookmarkId The ID of the bookmark to be added.\n     * @return A Flow emitting a Result<Bookmark> representing the outcome of the add operation.\n     *         It can emit Loading, Success with the added bookmark, or Error states.\n     */\n    override suspend fun deleteBookmark(\n        xSession: String,\n        serverUrl: String,\n        bookmarkId: Int\n    ) {\n        val response = apiService.deleteBookmarks(\n            url = \"${serverUrl.removeTrailingSlash()}/api/bookmarks\",\n            xSessionId = xSession,\n            bookmarkIds = listOf(bookmarkId)\n        )\n        if (!response.isSuccessful) {\n            throw IllegalStateException(\"Error deleting bookmark: ${response.errorBody()?.string()}\")\n        }\n    }\n\n    /**\n     * Edits an existing bookmark both on the remote server and in the local database.\n     *\n     * This method performs the following steps:\n     * 1. Sends an edit request to the remote server.\n     * 2. If the server update is successful, updates the local database.\n     * 3. Emits the updated bookmark if both operations are successful.\n     *\n     * The method uses a NetworkNoCacheResource to handle the network operation and error handling.\n     *\n     * @param xSession The session token for authentication with the remote API.\n     * @param serverUrl The base URL of the server API.\n     * @param bookmark The Bookmark object containing the updated information.\n     * @return A Flow emitting a Result<Bookmark> representing the outcome of the edit operation.\n     *         It can emit Loading, Success with the updated bookmark, or Error states.\n     */\n    override suspend fun editBookmark(\n        xSession: String,\n        serverUrl: String,\n        bookmark: Bookmark\n    ): Bookmark {\n        val response = apiService.editBookmark(\n            url = \"${serverUrl.removeTrailingSlash()}/api/bookmarks\",\n            xSessionId = xSession,\n            body = bookmark.toEditBookmarkDTO().toEditBookmarkJson()\n        )\n        if (response.isSuccessful) {\n            response.body()?.resolvedBookmark()?.let { bookmarkDTO ->\n                // TODO force fields to avoid invalid backend response\n                val updatedEntity = bookmarkDTO.toEntityModel().copy(\n                    hasEbook = bookmark.hasEbook,\n                    createEbook = bookmark.createEbook\n                )\n                bookmarksDao.updateBookmark(updatedEntity)\n                return updatedEntity.toDomainModel()\n            }\n            throw IllegalStateException(\"Response body is null\")\n        } else {\n            throw IllegalStateException(\"${response.errorBody()?.string()}\")\n        }\n    }\n\n\n    override suspend fun updateBookmarkCacheV1(\n        token: String,\n        serverUrl: String,\n        updateCachePayload: UpdateCachePayload,\n        bookmark: Bookmark?,\n    ): List<Bookmark>  {\n        val response = apiService.updateBookmarksCacheV1(\n            url = \"${serverUrl.removeTrailingSlash()}/api/v1/bookmarks/cache\",\n            authorization = \"Bearer $token\",\n            body = updateCachePayload.toDTO().toJson()\n        )\n        if (response.isSuccessful) {\n            response.body()?.let {\n                it.message?.forEach { dto->\n                    // TODO change to toEntityModel when backend is fixed\n                    val updatedEntity = dto.toEntityModel().copy(\n                        createEbook = if (updateCachePayload.createEbook) true else bookmark?.createEbook?: false,\n                        createArchive = if (updateCachePayload.createArchive) true else bookmark?.createArchive?: false,\n                        hasEbook = if (updateCachePayload.createEbook) true else bookmark?.hasEbook?: false,\n                        hasArchive = if (updateCachePayload.createArchive) true else bookmark?.hasArchive?: false,\n                        modified = LocalDateTime.now().format(DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm:ss\"))\n                    )\n                    bookmarksDao.updateBookmark(updatedEntity)\n                }\n               return  it.message?.map { it.toDomainModel() }?: emptyList()\n            }\n            throw IllegalStateException(\"Response body is null\")\n        } else {\n            throw IllegalStateException(\"${response.errorBody()?.string()}\")\n        }\n    }\n\n    override suspend fun deleteAllLocalBookmarks()  { bookmarksDao.deleteAll() }\n\n    override fun getBookmarkReadableContent(\n        token: String,\n        serverUrl: String,\n        bookmarkId: Int\n    ) = object :\n        NetworkNoCacheResource<ReadableContentResponseDTO, ReadableContent>(errorHandler = errorHandler) {\n        override suspend fun fetchFromRemote(): Response<ReadableContentResponseDTO> = apiService.getBookmarkReadableContent(\n            url = \"${serverUrl.removeTrailingSlash()}/api/v1/bookmarks/${bookmarkId}/readable\",\n            authorization = \"Bearer $token\",\n        )\n\n        override fun fetchResult(data: ReadableContentResponseDTO): Flow<ReadableContent> {\n            return flow {\n                    emit(data.toDomainModel())\n            }\n        }\n    }.asFlow().flowOn(Dispatchers.IO)\n\n    /**\n     * Syncs the bookmarks between the remote server and the local database.\n     *\n     * This method performs the following steps:\n     * 1. Sends a sync request to the remote server.\n     * 2. If the server update is successful, updates the local database.\n     * 3. Emits the sync status if both operations are successful.\n     *\n     * The method uses a NetworkNoCacheResource to handle the network operation and error handling.\n     *\n     * @param token The session token for authentication with the remote API.\n     * @param serverUrl The base URL of the server API.\n     * @param syncBookmarksRequestPayload The payload containing the bookmarks to be synced.\n     * @return A Flow emitting a Result<SyncBookmarksResponse> representing the outcome of the sync operation.\n     *         It can emit Loading, Success with the sync result, or Error states.\n     */\n    override fun syncBookmarks(\n        token: String,\n        serverUrl: String,\n        syncBookmarksRequestPayload: SyncBookmarksRequestPayload\n    ): Flow<Result<SyncBookmarksResponse>> {\n        return object : NetworkNoCacheResource<SyncBookmarksResponseDTO, SyncBookmarksResponse>(errorHandler = errorHandler) {\n            override suspend fun fetchFromRemote(): Response<SyncBookmarksResponseDTO> {\n                return apiService.syncBookmarks(\n                    url = \"${serverUrl.removeTrailingSlash()}/api/v1/bookmarks/sync\",\n                    authorization = \"Bearer $token\",\n                    body = syncBookmarksRequestPayload.toJson()\n                )\n            }\n\n            override fun fetchResult(data: SyncBookmarksResponseDTO): Flow<SyncBookmarksResponse> {\n                return flow {\n                    emit(data.toDomainModel())\n                }\n            }\n        }.asFlow().flowOn(Dispatchers.IO)\n    }\n\n    override fun getBookmarkById(\n        token: String,\n        serverUrl: String,\n        bookmarkId: Int\n    ) = object :\n        NetworkNoCacheResource<SingleBookmarkResponseDTO, Bookmark>(errorHandler = errorHandler) {\n\n        override suspend fun fetchFromRemote(): Response<SingleBookmarkResponseDTO> = apiService.getBookmark(\n            url = \"${serverUrl.removeTrailingSlash()}/api/v1/bookmarks/$bookmarkId\",\n            authorization = \"Bearer $token\",\n        )\n\n        override fun fetchResult(data: SingleBookmarkResponseDTO): Flow<Bookmark> {\n            return flow {\n                val bookmark = data.resolvedBookmark()\n                    ?: throw IllegalStateException(\"Could not resolve bookmark from response\")\n                emit(bookmark.toDomainModel())\n            }\n        }\n    }.asFlow().flowOn(Dispatchers.IO)\n\n}\n\nsealed class SyncStatus {\n    data object Started : SyncStatus()\n    data class InProgress(val currentPage: Int) : SyncStatus()\n    data class Completed(val totalSynced: Int) : SyncStatus()\n    data class Error(val error: Result.ErrorType) : SyncStatus()\n}\n\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/ErrorHandlerImpl.kt",
    "content": "package com.desarrollodroide.data.repository\n\nimport com.desarrollodroide.common.result.ErrorHandler\nimport com.desarrollodroide.common.result.Result\nimport com.desarrollodroide.data.helpers.SESSION_HAS_BEEN_EXPIRED\nimport java.io.IOException\nimport java.sql.SQLException\n\nclass ErrorHandlerImpl : ErrorHandler {\n    override fun getError(throwable: Throwable): Result.ErrorType {\n        return when (throwable) {\n            is IOException -> Result.ErrorType.IOError(throwable)\n            is SQLException -> Result.ErrorType.DatabaseError(throwable)\n            else -> Result.ErrorType.Unknown(throwable)\n        }\n    }\n\n    override fun getApiError(\n        statusCode: Int,\n        throwable: Throwable?,\n        message: String?\n    ): Result.ErrorType {\n        return if (message?.contains(SESSION_HAS_BEEN_EXPIRED) == true)\n            Result.ErrorType.SessionExpired(throwable, message) else\n            Result.ErrorType.HttpError(throwable, statusCode, message)\n    }\n}"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/FileRepository.kt",
    "content": "package com.desarrollodroide.data.repository\n\nimport java.io.File\n\ninterface FileRepository {\n    suspend fun downloadFile(\n        url: String,\n        fileName: String,\n        sessionId: String,\n    ): File\n}\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/FileRepositoryImpl.kt",
    "content": "package com.desarrollodroide.data.repository\n\nimport android.content.Context\nimport com.desarrollodroide.network.retrofit.FileRemoteDataSource\nimport java.io.File\n\nclass FileRepositoryImpl(\n    private val context: Context,\n    private val remoteDataSource: FileRemoteDataSource\n) : FileRepository {\n    override suspend fun downloadFile(\n        url: String,\n        fileName: String,\n        sessionId: String,\n    ): File {\n        return remoteDataSource.downloadFile(\n            context = context,\n            url = url,\n            fileName = fileName,\n            sessionId = sessionId\n        )\n    }\n}\n\n\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/SettingsRepository.kt",
    "content": "package com.desarrollodroide.data.repository\n\nimport com.desarrollodroide.data.helpers.ThemeMode\nimport com.desarrollodroide.model.User\nimport kotlinx.coroutines.flow.Flow\n\ninterface SettingsRepository {\n    suspend fun getUser(): User\n    suspend fun getUserName(): Flow<String>\n\n    val userDataStream: Flow<User>\n    fun getThemeMode(): ThemeMode\n    suspend fun setThemeMode(themeMode: ThemeMode)\n\n    fun getUseDynamicColors(): Boolean\n    suspend fun setUseDynamicColors(useDynamicColors: Boolean)\n}"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/SettingsRepositoryImpl.kt",
    "content": "package com.desarrollodroide.data.repository\n\nimport com.desarrollodroide.data.helpers.ThemeMode\nimport com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource\nimport com.desarrollodroide.model.Account\nimport com.desarrollodroide.model.User\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.flow.map\n\nclass SettingsRepositoryImpl(\n    private val settingsPreferenceDataSource: SettingsPreferenceDataSource\n): SettingsRepository {\n    override suspend fun getUser() = settingsPreferenceDataSource.userDataStream.map {\n        User(\n            token = it.token,\n            session = it.session,\n            account = Account(\n                id = it.account.id,\n                userName = it.account.userName,\n                password = it.account.password,\n                owner = it.account.owner,\n                serverUrl = it.account.serverUrl,\n            )\n        )\n    }.first()\n\n    override suspend fun getUserName() = settingsPreferenceDataSource.userDataStream.map { it.account.userName }\n\n    override val userDataStream: Flow<User> =\n        settingsPreferenceDataSource.userDataStream\n\n    override suspend fun setThemeMode(themeMode: ThemeMode) {\n       settingsPreferenceDataSource.setTheme(themeMode)\n    }\n    override fun getThemeMode() = settingsPreferenceDataSource.getThemeMode()\n\n    override fun getUseDynamicColors() = settingsPreferenceDataSource.getUseDynamicColors()\n    override suspend fun setUseDynamicColors(useDynamicColors: Boolean) {\n        settingsPreferenceDataSource.setUseDynamicColors(useDynamicColors)\n    }\n\n}"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/SyncWorks.kt",
    "content": "package com.desarrollodroide.data.repository\n\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.model.PendingJob\nimport com.desarrollodroide.model.SyncOperationType\nimport com.desarrollodroide.model.UpdateCachePayload\nimport kotlinx.coroutines.flow.Flow\n\ninterface SyncWorks {\n\n    fun scheduleSyncWork(\n        operationType: SyncOperationType,\n        bookmark: Bookmark,\n        updateCachePayload: UpdateCachePayload? = null\n    )\n    fun getPendingJobs(): Flow<List<PendingJob>>\n    fun cancelAllSyncWorkers()\n    suspend fun retryAllPendingJobs()\n\n}"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/SyncWorksImpl.kt",
    "content": "package com.desarrollodroide.data.repository\n\nimport android.util.Log\nimport androidx.lifecycle.asFlow\nimport androidx.work.BackoffPolicy\nimport androidx.work.Constraints\nimport androidx.work.ExistingWorkPolicy\nimport androidx.work.NetworkType\nimport androidx.work.OneTimeWorkRequestBuilder\nimport androidx.work.WorkInfo\nimport androidx.work.WorkManager\nimport androidx.work.WorkRequest\nimport androidx.work.workDataOf\nimport com.desarrollodroide.data.extensions.toJson\nimport com.desarrollodroide.data.local.room.dao.BookmarksDao\nimport com.desarrollodroide.data.mapper.toDomainModel\nimport com.desarrollodroide.data.repository.workers.SyncWorker\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.model.PendingJob\nimport com.desarrollodroide.model.SyncOperationType\nimport com.desarrollodroide.model.UpdateCachePayload\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.flow\nimport kotlinx.coroutines.flow.flowOn\nimport kotlinx.coroutines.flow.map\nimport kotlinx.coroutines.withContext\nimport java.net.URLDecoder\nimport java.net.URLEncoder\nimport java.util.concurrent.TimeUnit\n\nclass SyncWorksImpl(\n    private val workManager: WorkManager,\n    private val bookmarksDao: BookmarksDao,\n) : SyncWorks {\n    override fun scheduleSyncWork(\n        operationType: SyncOperationType,\n        bookmark: Bookmark,\n        updateCachePayload: UpdateCachePayload?\n    ) {\n        val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()\n        val encodedTitle = URLEncoder.encode(bookmark.title, \"UTF-8\")\n        val syncWorkRequest = OneTimeWorkRequestBuilder<SyncWorker>()\n            .setInputData(workDataOf(\n                \"operationType\" to operationType.name,\n                \"bookmarkId\" to bookmark.id,\n                \"updateCachePayload\" to updateCachePayload?.toJson()\n            ))\n            .addTag(\"worker_${SyncWorker::class.java.name}\")\n            .addTag(\"operationType_${operationType.name}\")\n            .addTag(\"bookmarkId_${bookmark.id}\")\n            .addTag(\"bookmarkTitle_$encodedTitle\")\n            .setBackoffCriteria(\n                BackoffPolicy.LINEAR,\n                WorkRequest.MIN_BACKOFF_MILLIS,\n                TimeUnit.MILLISECONDS\n            )\n            .setConstraints(constraints)\n            .build()\n\n        workManager.beginUniqueWork(\n            \"sync_bookmark_${operationType.name}_${bookmark.id}\",\n            ExistingWorkPolicy.REPLACE,\n            listOf(syncWorkRequest)\n        ).enqueue()\n    }\n\n    override fun getPendingJobs(): Flow<List<PendingJob>> =\n        workManager.getWorkInfosByTagLiveData(\"worker_${SyncWorker::class.java.name}\")\n            .asFlow()\n            .map { workInfos ->\n                workInfos\n                    .filter { !it.state.isFinished }\n                    .mapNotNull { workInfo ->\n                        Log.d(\"SyncManager\", \"WorkInfo: id=${workInfo.id}, state=${workInfo.state}, tags=${workInfo.tags}\")\n\n                        val operationType = workInfo.getSyncOperationType()\n                        Log.d(\"SyncManager\", \"OperationType: $operationType\")\n\n                        operationType?.let {\n                            PendingJob(\n                                operationType = it,\n                                state = workInfo.state.name,\n                                bookmarkId = workInfo.getBookmarkId() ?: -1,\n                                bookmarkTitle = workInfo.getBookmarkTitle() ?: \"Unknown\",\n                            )\n                        }\n                    }\n                    .also { jobs ->\n                        Log.d(\"SyncManager\", \"Pending Jobs: ${jobs.size}\")\n                    }\n            }\n            .flowOn(Dispatchers.IO)\n\n\n    override fun cancelAllSyncWorkers() {\n        workManager.cancelAllWorkByTag(SyncWorker::class.java.name)\n    }\n\n    override suspend fun retryAllPendingJobs() {\n        val allWorkInfos = withContext(Dispatchers.IO) {\n            workManager.getWorkInfosByTag(\"worker_${SyncWorker::class.java.name}\").get()\n        }.filter { !it.state.isFinished }\n\n        allWorkInfos.forEach { workInfo ->\n            val operationType = workInfo.getSyncOperationType()\n            val bookmarkId = workInfo.getBookmarkId()\n\n            if (operationType != null && bookmarkId != null) {\n                val bookmark = bookmarksDao.getBookmarkById(bookmarkId)?.toDomainModel()\n\n                if (bookmark != null) {\n                    scheduleSyncWork(operationType, bookmark)\n                }\n            }\n        }\n    }\n\n    fun WorkInfo.getSyncOperationType(): SyncOperationType? {\n        return tags\n            .firstOrNull { it.startsWith(\"operationType_\") }\n            ?.substringAfter(\"operationType_\")\n            ?.let { SyncOperationType.fromString(it) }\n            .also { Log.d(\"SyncManager\", \"Parsed SyncOperationType: $it\") }\n    }\n\n    fun WorkInfo.getBookmarkId(): Int? {\n        return tags\n            .firstOrNull { it.startsWith(\"bookmarkId_\") }\n            ?.substringAfter(\"bookmarkId_\")\n            ?.toIntOrNull()\n            .also { Log.d(\"SyncManager\", \"BookmarkId: $it\") }\n    }\n\n    fun WorkInfo.getBookmarkTitle(): String? {\n        return tags\n            .firstOrNull { it.startsWith(\"bookmarkTitle_\") }\n            ?.substringAfter(\"bookmarkTitle_\")\n            ?.let { URLDecoder.decode(it, \"UTF-8\") }\n            .also { Log.d(\"SyncManager\", \"BookmarkTitle: $it\") }\n    }\n}\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/SystemRepository.kt",
    "content": "package com.desarrollodroide.data.repository\n\nimport com.desarrollodroide.common.result.Result\nimport com.desarrollodroide.model.LivenessResponse\nimport kotlinx.coroutines.flow.Flow\n\ninterface SystemRepository {\n\n    fun liveness(\n      serverUrl: String\n    ): Flow<Result<LivenessResponse?>>\n}"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/SystemRepositoryImpl.kt",
    "content": "package com.desarrollodroide.data.repository\n\nimport com.desarrollodroide.common.result.ErrorHandler\nimport com.desarrollodroide.data.extensions.removeTrailingSlash\nimport com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource\nimport com.desarrollodroide.data.mapper.toDomainModel\nimport com.desarrollodroide.model.LivenessResponse\nimport com.desarrollodroide.network.model.LivenessResponseDTO\nimport com.desarrollodroide.network.retrofit.NetworkNoCacheResource\nimport com.desarrollodroide.network.retrofit.RetrofitNetwork\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.flow\nimport kotlinx.coroutines.flow.flowOn\n\nclass SystemRepositoryImpl(\n    private val apiService: RetrofitNetwork,\n    private val settingsPreferenceDataSource: SettingsPreferenceDataSource,\n    private val errorHandler: ErrorHandler\n) : SystemRepository {\n    override fun liveness(\n        serverUrl: String,\n    ) = object :\n        NetworkNoCacheResource<LivenessResponseDTO, LivenessResponse?>(errorHandler = errorHandler) {\n\n        override suspend fun fetchFromRemote() = apiService.systemLiveness(\n            url = \"${serverUrl.removeTrailingSlash()}/system/liveness\"\n        )\n\n        override fun fetchResult(data: LivenessResponseDTO): Flow<LivenessResponse?> {\n            return flow {\n                data?.let {\n                    emit(it.toDomainModel())\n                }\n            }\n        }\n    }.asFlow().flowOn(Dispatchers.IO)\n\n}"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/TagsRepository.kt",
    "content": "package com.desarrollodroide.data.repository\n\nimport kotlinx.coroutines.flow.Flow\nimport com.desarrollodroide.common.result.Result\nimport com.desarrollodroide.model.Tag\n\ninterface TagsRepository {\n\n  fun getTags(\n    token: String,\n    serverUrl: String\n  ): Flow<Result<List<Tag>?>>\n\n  fun getLocalTags(): Flow<List<Tag>>\n}"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/TagsRepositoryImpl.kt",
    "content": "package com.desarrollodroide.data.repository\n\nimport android.util.Log\nimport com.desarrollodroide.common.result.ErrorHandler\nimport com.desarrollodroide.data.extensions.removeTrailingSlash\nimport com.desarrollodroide.data.local.room.dao.TagDao\nimport com.desarrollodroide.data.mapper.*\nimport com.desarrollodroide.model.Tag\nimport com.desarrollodroide.network.model.TagsDTO\nimport com.desarrollodroide.network.retrofit.NetworkBoundResource\nimport com.desarrollodroide.network.retrofit.RetrofitNetwork\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.flowOn\nimport kotlinx.coroutines.flow.map\nimport kotlinx.coroutines.flow.onEach\n\nclass TagsRepositoryImpl(\n    private val apiService: RetrofitNetwork,\n    private val tagsDao: TagDao,\n    private val errorHandler: ErrorHandler\n) : TagsRepository {\n\n    override fun getTags(\n        token: String,\n        serverUrl: String\n    ) = object :\n        NetworkBoundResource<TagsDTO, List<Tag>>(errorHandler = errorHandler) {\n\n        override suspend fun saveRemoteData(response: TagsDTO) {\n            response.message?.map { it.toEntityModel() }?.let { tagsList ->\n                tagsDao.deleteAllTags()\n                tagsDao.insertAllTags(tagsList)\n            }\n        }\n\n        override fun fetchFromLocal(): Flow<List<Tag>> = tagsDao.getAllTags().map {\n            it.map { it.toDomainModel() }\n        }\n\n        override suspend fun fetchFromRemote() = apiService.getTags(\n            authorization = \"Bearer $token\",\n            url = \"${serverUrl.removeTrailingSlash()}/api/v1/tags\"\n        )\n\n        override fun shouldFetch(data: List<Tag>?) = true\n\n    }.asFlow().flowOn(Dispatchers.IO)\n\n    override fun getLocalTags(): Flow<List<Tag>> {\n        return tagsDao.observeAllTags()\n            .onEach { entities ->\n                Log.d(\"TagsRepository\", \"Tags updated in repository: ${entities.size}\")\n            }\n            .map { entities ->\n                entities.map { it.toDomainModel() }\n            }\n    }\n\n}\n\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/paging/BookmarkPagingSource.kt",
    "content": "package com.desarrollodroide.data.repository.paging\n\nimport android.util.Log\nimport androidx.paging.PagingSource\nimport androidx.paging.PagingState\nimport com.desarrollodroide.data.extensions.removeTrailingSlash\nimport com.desarrollodroide.data.helpers.SESSION_HAS_BEEN_EXPIRED\nimport com.desarrollodroide.data.local.room.dao.BookmarksDao\nimport com.desarrollodroide.data.mapper.toDomainModel\nimport com.desarrollodroide.data.mapper.toEntityModel\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.model.Tag\nimport com.desarrollodroide.network.retrofit.RetrofitNetwork\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.flow.map\nimport retrofit2.HttpException\nimport java.io.IOException\n\nclass BookmarkPagingSource(\n    private val remoteDataSource: RetrofitNetwork,\n    private val bookmarksDao: BookmarksDao,\n    private val serverUrl: String,\n    private val xSessionId: String,\n    private val searchText: String,\n    private val tags: List<Tag>,\n    private val saveToLocal: Boolean,\n) : PagingSource<Int, Bookmark>() {\n\n    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Bookmark> {\n        return try {\n            val page = params.key ?: 1\n            val pageSize = params.loadSize // Not needed\n            val searchKeywordsParams = if (searchText.isNotEmpty())\"&keyword=$searchText\" else \"\"\n            val searchTagsParams = if (tags.isNotEmpty())\"&tags=${tags.joinToString(\",\") { it.name }}\" else \"\"\n            val bookmarksDto = remoteDataSource.getPagingBookmarks(\n                xSessionId = xSessionId,\n                url = \"${serverUrl.removeTrailingSlash()}/api/bookmarks?page=$page$searchKeywordsParams$searchTagsParams\",\n            )\n            if (bookmarksDto.errorBody()?.string() == SESSION_HAS_BEEN_EXPIRED) {\n                return LoadResult.Error(Exception(SESSION_HAS_BEEN_EXPIRED))\n            }\n            if (saveToLocal){\n                bookmarksDto.body()?.resolvedBookmarks()?.map { it.toEntityModel() }?.let { bookmarksList ->\n                    if (page == 1) {\n                        bookmarksDao.deleteAll()\n                    }\n                    bookmarksDao.insertAll(bookmarksList)\n                }\n            }\n            val bookmarks = bookmarksDto.body()?.resolvedBookmarks()?.map { it.toDomainModel() }?: emptyList()\n            LoadResult.Page(\n                data = bookmarks,\n                prevKey = if (page == 1) null else page - 1,\n                nextKey = if ((bookmarksDto.body()?.resolvedPage() ?: 0) >= (bookmarksDto.body()?.resolvedMaxPage() ?: 0)) null else page + 1\n            )\n        } catch (exception: IOException) {\n            Log.e(\"BookmarkPagingSource\", \"IOException\", exception)\n            return loadFromLocalWhenError()\n        } catch (exception: HttpException) {\n            Log.e(\"BookmarkPagingSource\", \"HttpException\", exception)\n            return loadFromLocalWhenError()\n        }\n    }\n\n    private suspend fun loadFromLocalWhenError(): LoadResult.Page<Int, Bookmark> {\n        val bookmarks = bookmarksDao.getAll().map { bookmarks ->\n            bookmarks.map { it.toDomainModel() }\n        }.first().reversed()\n        return LoadResult.Page(\n            data = bookmarks,\n            prevKey = null,\n            nextKey = null\n        )\n    }\n\n    override fun getRefreshKey(state: PagingState<Int, Bookmark>): Int? {\n        return state.anchorPosition\n    }\n\n}"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/paging/BookmarksRemoteMediator.kt",
    "content": "package com.desarrollodroide.data.repository.paging\n\nimport androidx.paging.ExperimentalPagingApi\nimport androidx.paging.LoadType\nimport androidx.paging.PagingState\nimport androidx.paging.RemoteMediator\nimport com.desarrollodroide.data.extensions.removeTrailingSlash\nimport com.desarrollodroide.data.helpers.SESSION_HAS_BEEN_EXPIRED\nimport com.desarrollodroide.data.local.room.dao.BookmarksDao\nimport com.desarrollodroide.data.mapper.toDomainModel\nimport com.desarrollodroide.data.mapper.toEntityModel\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.model.Tag\nimport com.desarrollodroide.network.retrofit.RetrofitNetwork\nimport kotlinx.coroutines.flow.first\n\n@OptIn(ExperimentalPagingApi::class)\nclass BookmarksRemoteMediator(\n    private val apiService: RetrofitNetwork,\n    private val bookmarksDao: BookmarksDao,\n    private val serverUrl: String,\n    private val xSessionId: String,\n    private val searchText: String,\n    private val tags: List<Tag>\n) : RemoteMediator<Int, Bookmark>() {\n\n    override suspend fun load(\n        loadType: LoadType,\n        state: PagingState<Int, Bookmark>\n    ): MediatorResult {\n        return try {\n            val page = when (loadType) {\n                LoadType.REFRESH -> 1\n                LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)\n                LoadType.APPEND -> {\n                    val lastItem = state.lastItemOrNull()\n                    if (lastItem == null) {\n                        1\n                    } else {\n                        (lastItem.id / state.config.pageSize) + 1\n                    }\n                }\n            }\n\n            val response = apiService.getPagingBookmarks(\n                xSessionId = xSessionId,\n                url = \"${serverUrl.removeTrailingSlash()}/api/bookmarks?page=$page&keyword=$searchText&tags=${tags.joinToString(\",\") { it.name }}\",\n            )\n\n            if (response.isSuccessful) {\n                val bookmarksDto = response.body()\n                val bookmarks = bookmarksDto?.bookmarks?.map { it.toEntityModel() } ?: emptyList()\n\n                if (loadType == LoadType.REFRESH) {\n                    bookmarksDao.deleteAll()\n                }\n                bookmarksDao.insertAll(bookmarks)\n\n                val endOfPaginationReached = (bookmarksDto?.page ?: 0) >= (bookmarksDto?.maxPage ?: 0)\n\n                MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)\n            } else {\n                if (response.errorBody()?.string() == SESSION_HAS_BEEN_EXPIRED) {\n                    MediatorResult.Error(Exception(SESSION_HAS_BEEN_EXPIRED))\n                } else {\n                    MediatorResult.Error(Exception(\"Error loading data\"))\n                }\n            }\n        } catch (e: Exception) {\n            // If there's a network error, we load data from local database\n            val localBookmarks = loadFromLocalWhenError()\n            MediatorResult.Success(endOfPaginationReached = true)\n        }\n    }\n\n    private suspend fun loadFromLocalWhenError(): List<Bookmark> {\n        return bookmarksDao.getAll()\n            .first()\n            .map { it.toDomainModel() }\n            .reversed()\n    }\n}"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/paging/LocalBookmarkPagingSource.kt",
    "content": "package com.desarrollodroide.data.repository.paging\n\nimport android.annotation.SuppressLint\nimport android.util.Log\nimport androidx.paging.PagingSource\nimport androidx.paging.PagingState\nimport com.desarrollodroide.data.local.room.dao.BookmarksDao\nimport com.desarrollodroide.data.mapper.toDomainModel\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.model.Tag\n\n//@SuppressLint(\"LongLogTag\")\n//class LocalBookmarkPagingSource(\n//    private val bookmarksDao: BookmarksDao,\n//    private val searchText: String,\n//    private val tags: List<Tag>\n//) : PagingSource<Int, Bookmark>() {\n//\n//    companion object {\n//        private const val TAG = \"LocalBookmarkPagingSource\"\n//        private const val STARTING_PAGE_INDEX = 0\n//        private const val PAGE_SIZE = 30\n//    }\n//\n//    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Bookmark> {\n//        return try {\n//            val page = params.key ?: STARTING_PAGE_INDEX\n//            val offset = page * PAGE_SIZE\n//\n//            Log.d(TAG, \"Loading page: $page, pageSize: $PAGE_SIZE, offset: $offset\")\n//            Log.d(TAG, \"Search text: '$searchText', Tags: ${tags.map { it.name }.joinToString()}\")\n//\n//            val bookmarks = bookmarksDao.getPagingBookmarks(\n//                searchText = searchText,\n//                tags = tags.map { it.name },\n//                tagsListSize = tags.size,\n//                limit = PAGE_SIZE,\n//                offset = offset\n//            )\n//\n//            Log.d(TAG, \"Loaded ${bookmarks.size} bookmarks\")\n//\n//            val totalCount = bookmarksDao.getPagingBookmarksCount(\n//                searchText = searchText,\n//                tags = tags.map { it.name },\n//                tagsListSize = tags.size\n//            )\n//\n//            Log.d(TAG, \"Total count of bookmarks matching criteria: $totalCount\")\n//\n//            val nextKey = if (offset + bookmarks.size < totalCount) page + 1 else null\n//            val prevKey = if (page > 0) page - 1 else null\n//\n//            Log.d(TAG, \"Next key: $nextKey, Previous key: $prevKey\")\n//\n//            LoadResult.Page(\n//                data = bookmarks.map { it.toDomainModel() }.also {\n//                    Log.d(TAG, \"Mapped ${it.size} bookmarks to domain model\")\n//                },\n//                prevKey = prevKey,\n//                nextKey = nextKey\n//            )\n//        } catch (e: Exception) {\n//            Log.e(TAG, \"Error loading bookmarks\", e)\n//            LoadResult.Error(e)\n//        }\n//    }\n//\n//    override fun getRefreshKey(state: PagingState<Int, Bookmark>): Int? {\n//        return state.anchorPosition?.let { anchorPosition ->\n//            val anchorPage = state.closestPageToPosition(anchorPosition)\n//            anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)\n//        }.also { Log.d(TAG, \"Refresh key: $it\") }\n//    }\n//}\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/workers/SyncWorker.kt",
    "content": "package com.desarrollodroide.data.repository.workers\n\nimport android.content.Context\nimport android.util.Log\nimport androidx.work.CoroutineWorker\nimport androidx.work.ListenableWorker\nimport androidx.work.WorkerFactory\nimport androidx.work.WorkerParameters\nimport androidx.work.workDataOf\nimport com.desarrollodroide.data.extensions.isTimestampId\nimport com.desarrollodroide.data.extensions.toBean\nimport com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource\nimport com.desarrollodroide.data.local.room.dao.BookmarksDao\nimport com.desarrollodroide.data.mapper.toDomainModel\nimport com.desarrollodroide.data.mapper.toEntityModel\nimport com.desarrollodroide.data.repository.AuthRepository\nimport com.desarrollodroide.data.repository.BookmarksRepository\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.model.SyncOperationType\nimport kotlinx.coroutines.flow.first\nimport org.koin.core.component.inject\nimport org.koin.core.component.KoinComponent\nimport com.desarrollodroide.data.helpers.SESSION_HAS_BEEN_EXPIRED\nimport com.desarrollodroide.model.UpdateCachePayload\nimport kotlinx.coroutines.flow.filterNot\nimport kotlinx.coroutines.flow.firstOrNull\nimport java.time.LocalDateTime\nimport java.time.format.DateTimeFormatter\n\nclass BookmarkNotFoundException(bookmarkId: Int) :\n    Exception(\"Bookmark not found for ID: $bookmarkId\")\n\nclass SyncWorker(\n    context: Context,\n    params: WorkerParameters\n) : CoroutineWorker(context, params), KoinComponent {\n\n    private val bookmarksRepository: BookmarksRepository by inject()\n    private val bookmarksDao: BookmarksDao by inject()\n    private val settingsPreferenceDataSource: SettingsPreferenceDataSource by inject()\n    private val authRepository: AuthRepository by inject()\n\n    override suspend fun doWork(): Result {\n        val operationType = inputData.getString(\"operationType\")?.let { SyncOperationType.valueOf(it) }\n        val bookmarkId = inputData.getInt(\"bookmarkId\", -1)\n        val updateCachePayload = inputData.getString(\"updateCachePayload\")?.toBean<UpdateCachePayload>()\n\n        Log.v(\"SyncWorker\", \"Performing sync operation: $operationType\")\n        Log.v(\"SyncWorker\", \"BookmarkId: $bookmarkId\")\n        Log.v(\"SyncWorker\", \"UpdateCachePayload: $updateCachePayload\")\n\n        if (operationType == null || bookmarkId == -1) {\n            return Result.failure()\n        }\n\n        return try {\n            val xSession = settingsPreferenceDataSource.getSession()\n            val serverUrl = settingsPreferenceDataSource.getUrl()\n            val token = settingsPreferenceDataSource.getToken()\n\n            try {\n                performSyncOperation(\n                    xSession = xSession,\n                    serverUrl = serverUrl,\n                    operationType = operationType,\n                    bookmarkId = bookmarkId,\n                    updateCachePayload = updateCachePayload,\n                    token = token\n                )\n                Log.v(\"SyncWorker\", \"Sync completed successfully\")\n                Result.success()\n            } catch (e: Exception) {\n                if (isSessionExpiredException(e)) {\n                    val sessionRefreshed = refreshSession()\n                    if (sessionRefreshed) {\n                        try {\n                            val newSession = settingsPreferenceDataSource.getSession()\n                            performSyncOperation(\n                                xSession = newSession,\n                                serverUrl = serverUrl,\n                                operationType = operationType,\n                                bookmarkId = bookmarkId,\n                                updateCachePayload = updateCachePayload,\n                                token = token\n                            )\n                            Log.v(\"SyncWorker\", \"Sync completed successfully after session refresh\")\n                            Result.success()\n                        } catch (retryException: Exception) {\n                            Log.e(\"SyncWorker\", \"Error after session refresh: ${retryException.message}\")\n                            Result.retry()\n                        }\n                    } else {\n                        Log.e(\"SyncWorker\", \"Failed to refresh session\")\n                        Result.retry()\n                    }\n                } else if (e is BookmarkNotFoundException) {\n                    Log.w(\"SyncWorker\", \"Bookmark not found, marking as success to avoid retry loop: ${e.message}\")\n                    Result.success()\n                } else {\n                    Log.e(\"SyncWorker\", \"Error during sync: ${e.message}\", e)\n                    Result.retry()\n                }\n            }\n        } catch (e: Exception) {\n            Log.e(\"SyncWorker\", \"Unexpected error: ${e.message}\")\n            Result.retry()\n        }\n    }\n\n\n    private suspend fun refreshSession(): Boolean {\n        val serverUrl = settingsPreferenceDataSource.getUrl()\n        val rememberedUser = settingsPreferenceDataSource.getUser().first()\n\n        if (rememberedUser.account.userName.isEmpty() || rememberedUser.account.password.isEmpty()) {\n            return false\n        }\n\n        return authRepository.sendLoginV1(\n            username = rememberedUser.account.userName,\n            password = rememberedUser.account.password,\n            serverUrl = serverUrl\n        )\n            .filterNot { it is com.desarrollodroide.common.result.Result.Loading }\n            .firstOrNull()?.let { result ->\n                when (result) {\n                    is com.desarrollodroide.common.result.Result.Success -> true\n                    else -> false\n                }\n            } ?: false\n    }\n\n    private suspend fun performSyncOperation(\n        xSession: String,\n        serverUrl: String,\n        operationType: SyncOperationType,\n        bookmarkId: Int,\n        updateCachePayload: UpdateCachePayload?,\n        token: String\n    ) {\n        when (operationType) {\n            SyncOperationType.CREATE -> {\n                val updatedBookmark = syncCreateBookmark(xSession, serverUrl, bookmarkId)\n                bookmarksDao.deleteBookmarkById(bookmarkId)\n                bookmarksDao.insertBookmark(updatedBookmark.toEntityModel(\n                    modified = LocalDateTime.now().format(DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm:ss\"))\n                ))\n                val outputData = workDataOf(\n                    \"syncResult\" to \"SUCCESS\",\n                    \"originalBookmarkId\" to bookmarkId,\n                    \"newBookmarkId\" to updatedBookmark.id\n                )\n                Result.success(outputData)\n            }\n            SyncOperationType.UPDATE -> {\n                if (bookmarkId.isTimestampId()) {\n                    Result.success()\n                } else {\n                    syncUpdateBookmark(xSession, serverUrl, bookmarkId)\n                }\n            }\n            SyncOperationType.DELETE -> syncDeleteBookmark(xSession, serverUrl, bookmarkId)\n            SyncOperationType.CACHE -> syncCacheBookmark(token, serverUrl, bookmarkId, updateCachePayload)\n\n        }\n    }\n\n    private fun isSessionExpiredException(e: Exception): Boolean {\n        return e.message?.contains(SESSION_HAS_BEEN_EXPIRED) == true\n    }\n\n    private suspend fun syncCreateBookmark(xSession: String, serverUrl: String, bookmarkId: Int): Bookmark {\n        val bookmark = bookmarksDao.getBookmarkById(bookmarkId)?.toDomainModel()\n            ?: throw BookmarkNotFoundException(bookmarkId)\n        return bookmarksRepository.addBookmark(xSession, serverUrl, bookmark)\n    }\n\n    private suspend fun syncUpdateBookmark(xSession: String, serverUrl: String, bookmarkId: Int) {\n        val bookmark = bookmarksDao.getBookmarkById(bookmarkId)?.toDomainModel()\n            ?: throw BookmarkNotFoundException(bookmarkId)\n        bookmarksRepository.editBookmark(xSession, serverUrl, bookmark)\n    }\n\n    private suspend fun syncDeleteBookmark(xSession: String, serverUrl: String, bookmarkId: Int) {\n        bookmarksRepository.deleteBookmark(xSession, serverUrl, bookmarkId)\n    }\n    private suspend fun syncCacheBookmark(token: String, serverUrl: String, bookmarkId: Int, updateCachePayload: UpdateCachePayload?) {\n        if (updateCachePayload == null) {\n            Log.e(\"SyncWorker\", \"UpdateCachePayload is null for CACHE operation\")\n            throw IllegalStateException(\"UpdateCachePayload is required for CACHE operation\")\n        }\n        val bookmark = bookmarksDao.getBookmarkById(bookmarkId)?.toDomainModel()\n        bookmarksRepository.updateBookmarkCacheV1(token, serverUrl, updateCachePayload, bookmark)\n    }\n\n    class Factory : WorkerFactory(), KoinComponent {\n        override fun createWorker(\n            appContext: Context,\n            workerClassName: String,\n            workerParameters: WorkerParameters\n        ): ListenableWorker? {\n            return when (workerClassName) {\n                SyncWorker::class.java.name -> SyncWorker(appContext, workerParameters)\n                else -> null\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/util/SyncUtilities.kt",
    "content": "package com.desarrollodroide.data.util\n\nimport android.util.Log\nimport com.desarrollodroide.data.local.datastore.ChangeListVersions\nimport com.desarrollodroide.network.model.util.NetworkChangeList\nimport kotlin.coroutines.cancellation.CancellationException\n\n/**\n * Interface marker for a class that manages synchronization between local data and a remote\n * source for a [Syncable].\n */\ninterface Synchronizer {\n    suspend fun getChangeListVersions(): ChangeListVersions\n\n    suspend fun updateChangeListVersions(update: ChangeListVersions.() -> ChangeListVersions)\n\n    /**\n     * Syntactic sugar to call [Syncable.syncWith] while omitting the synchronizer argument\n     */\n    suspend fun Syncable.sync() = this@sync.syncWith(this@Synchronizer)\n}\n\n/**\n * Interface marker for a class that is synchronized with a remote source. Syncing must not be\n * performed concurrently and it is the [Synchronizer]'s responsibility to ensure this.\n */\ninterface Syncable {\n    /**\n     * Synchronizes the local database backing the repository with the network.\n     * Returns if the sync was successful or not.\n     */\n    suspend fun syncWith(synchronizer: Synchronizer): Boolean\n}\n\n/**\n * Attempts [block], returning a successful [Result] if it succeeds, otherwise a [Result.Failure]\n * taking care not to break structured concurrency\n */\nprivate suspend fun <T> suspendRunCatching(block: suspend () -> T): Result<T> = try {\n    Result.success(block())\n} catch (cancellationException: CancellationException) {\n    throw cancellationException\n} catch (exception: Exception) {\n    Log.i(\n        \"suspendRunCatching\",\n        \"Failed to evaluate a suspendRunCatchingBlock. Returning failure Result\",\n        exception,\n    )\n    Result.failure(exception)\n}\n\n/**\n * Utility function for syncing a repository with the network.\n * [versionReader] Reads the current version of the model that needs to be synced\n * [changeListFetcher] Fetches the change list for the model\n * [versionUpdater] Updates the [ChangeListVersions] after a successful sync\n * [modelDeleter] Deletes models by consuming the ids of the models that have been deleted.\n * [modelUpdater] Updates models by consuming the ids of the models that have changed.\n *\n * Note that the blocks defined above are never run concurrently, and the [Synchronizer]\n * implementation must guarantee this.\n */\nsuspend fun Synchronizer.changeListSync(\n    versionReader: (ChangeListVersions) -> Int,\n    changeListFetcher: suspend (Int) -> List<NetworkChangeList>,\n    versionUpdater: ChangeListVersions.(Int) -> ChangeListVersions,\n    modelDeleter: suspend (List<String>) -> Unit,\n    modelUpdater: suspend (List<String>) -> Unit,\n) = suspendRunCatching {\n    // Fetch the change list since last sync (akin to a git fetch)\n    val currentVersion = versionReader(getChangeListVersions())\n    val changeList = changeListFetcher(currentVersion)\n    if (changeList.isEmpty()) return@suspendRunCatching true\n\n    val (deleted, updated) = changeList.partition(NetworkChangeList::isDelete)\n\n    // Delete models that have been deleted server-side\n    modelDeleter(deleted.map(NetworkChangeList::id))\n\n    // Using the change list, pull down and save the changes (akin to a git pull)\n    modelUpdater(updated.map(NetworkChangeList::id))\n\n    // Update the last synced version (akin to updating local git HEAD)\n    val latestVersion = changeList.last().changeListVersion\n    updateChangeListVersions {\n        versionUpdater(latestVersion)\n    }\n}.isSuccess\n"
  },
  {
    "path": "data/src/main/proto/prefs.proto",
    "content": "syntax = \"proto3\";\n\noption java_package = \"com.desarrollodroide.data\";\noption java_multiple_files = true;\n\nmessage UserPreferences {\n  reserved 8; // number previously used for isLegacyApi\n\n  uint32 id = 1;\n  string username = 2;\n  bool owner = 3;\n  string password = 4;\n  string session = 5;\n  string url = 6;\n  bool rememberPassword = 7;\n  string token = 9;\n\n}\n\nmessage RememberUserPreferences {\n\n  uint32 id = 1;\n  string username = 2;\n  string password = 3;\n  string url = 4;\n\n}\n\nmessage SystemPreferences {\n\n  bool makeArchivePublic = 1;\n  bool createEbook = 2;\n  bool createArchive = 3;\n  bool autoAddBookmark = 4;\n  bool compactView = 5;\n  repeated string selectedCategories = 6;\n  int64 lastSyncTimestamp = 7;\n  string serverVersion = 8;\n  string lastCrashLog = 9;\n\n}\n\n\nmessage HideTag {\n\n  int32 id = 1;\n  string name = 2;\n\n}"
  },
  {
    "path": "data/src/test/java/com/desarrollodroide/data/extensions/IntExtensionsTest.kt",
    "content": "package com.desarrollodroide.data.extensions\n\nimport org.junit.jupiter.api.Assertions.*\nimport org.junit.jupiter.api.Test\n\nclass IntExtensionsTest {\n\n    @Test\n    fun `isTimestampId returns true for timestamp-based ids`() {\n        // Given - a value generated from System.currentTimeMillis() / 1000\n        val timestampId = 1739000000\n\n        // When\n        val result = timestampId.isTimestampId()\n\n        // Then\n        assertTrue(result)\n    }\n\n    @Test\n    fun `isTimestampId returns true for values just above threshold`() {\n        // Given\n        val id = 1_000_001\n\n        // When\n        val result = id.isTimestampId()\n\n        // Then\n        assertTrue(result)\n    }\n\n    @Test\n    fun `isTimestampId returns false for regular server ids`() {\n        // Given\n        val regularId = 1\n\n        // When\n        val result = regularId.isTimestampId()\n\n        // Then\n        assertFalse(result)\n    }\n\n    @Test\n    fun `isTimestampId returns false for threshold value`() {\n        // Given\n        val thresholdId = 1_000_000\n\n        // When\n        val result = thresholdId.isTimestampId()\n\n        // Then\n        assertFalse(result)\n    }\n\n    @Test\n    fun `isTimestampId returns false for large server ids`() {\n        // Given - even a server with many bookmarks\n        val largeServerId = 999_999\n\n        // When\n        val result = largeServerId.isTimestampId()\n\n        // Then\n        assertFalse(result)\n    }\n}"
  },
  {
    "path": "data/src/test/java/com/desarrollodroide/data/extensions/StringExtensionsKtTest.kt",
    "content": "package com.desarrollodroide.data.extensions\n\nimport org.junit.jupiter.api.Assertions.*\nimport org.junit.jupiter.api.Test\nclass StringExtensionsTest {\n\n    @Test\n    fun `removeTrailingSlash removes trailing slash if present`() {\n        // Given a string with a trailing slash\n        val stringWithSlash = \"https://www.example.com/\"\n\n        // When removeTrailingSlash is called\n        val result = stringWithSlash.removeTrailingSlash()\n\n        // Then the result should not have a trailing slash\n        assertEquals(\"https://www.example.com\", result)\n    }\n\n    @Test\n    fun `removeTrailingSlash does nothing if no trailing slash present`() {\n        // Given a string without a trailing slash\n        val stringWithoutSlash = \"https://www.example.com\"\n\n        // When removeTrailingSlash is called\n        val result = stringWithoutSlash.removeTrailingSlash()\n\n        // Then the result should be the same as the input\n        assertEquals(stringWithoutSlash, result)\n    }\n\n    @Test\n    fun `removeTrailingSlash works with empty string`() {\n        // Given an empty string\n        val emptyString = \"\"\n\n        // When removeTrailingSlash is called\n        val result = emptyString.removeTrailingSlash()\n\n        // Then the result should still be an empty string\n        assertEquals(\"\", result)\n    }\n\n    @Test\n    fun `removeTrailingSlash does nothing to string without any slash`() {\n        // Given a string without any slashes\n        val stringWithoutAnySlash = \"www.example.com\"\n\n        // When removeTrailingSlash is called\n        val result = stringWithoutAnySlash.removeTrailingSlash()\n\n        // Then the result should be the same as the input\n        assertEquals(stringWithoutAnySlash, result)\n    }\n\n}\n"
  },
  {
    "path": "data/src/test/java/com/desarrollodroide/data/helpers/TagTypeAdapterTest.kt",
    "content": "package com.desarrollodroide.data.helpers\n\nimport com.desarrollodroide.model.Tag\nimport com.google.gson.GsonBuilder\nimport org.junit.jupiter.api.Assertions.assertEquals\nimport org.junit.jupiter.api.Test\n\nclass TagTypeAdapterTest {\n\n    @Test\n    fun `secondary constructor initializes properties correctly`() {\n        // Given a tag name\n        val tagName = \"exampleTag\"\n\n        // When creating a Tag using the secondary constructor\n        val tag = Tag(id = 1, tagName)\n\n        // Then the properties should be set to default values except for the name\n        assertEquals(1, tag.id)\n        assertEquals(tagName, tag.name)\n        assertEquals(false, tag.selected)\n        assertEquals(0, tag.nBookmarks)\n    }\n\n    @Test\n    fun `TagTypeAdapter serializes Tag correctly with all fields`() {\n        // Given a Tag object with all fields initialized\n        val tag = Tag(1, \"exampleTag\", true, 5)\n\n        // And a Gson instance with TagTypeAdapter registered\n        val gson = GsonBuilder()\n            .registerTypeAdapter(Tag::class.java, TagTypeAdapter())\n            .create()\n\n        // When serializing the Tag object\n        val json = gson.toJson(tag, Tag::class.java)\n\n        // Then the resulting JSON should contain all the necessary properties\n        val expectedJson = \"{\\\"name\\\":\\\"exampleTag\\\"}\" // Note: Only 'name' is expected as per TagTypeAdapter\n        assertEquals(expectedJson, json)\n    }\n\n}"
  },
  {
    "path": "data/src/test/java/com/desarrollodroide/data/local/datastore/HideTagSerializerTest.kt",
    "content": "package com.desarrollodroide.data.local.datastore\n\nimport androidx.datastore.core.CorruptionException\nimport com.desarrollodroide.data.HideTag\nimport kotlinx.coroutines.runBlocking\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.assertThrows\nimport java.io.ByteArrayInputStream\nimport java.io.ByteArrayOutputStream\nimport kotlin.test.assertEquals\nimport kotlin.test.assertTrue\n\nclass HideTagSerializerTest {\n\n    private val testHideTag = HideTag.newBuilder()\n        .setId(1)\n        .setName(\"TestTag\")\n        .build()\n\n    @Test\n    fun `test writeTo serializes object correctly`() = runBlocking {\n        val testOutputStream = ByteArrayOutputStream()\n        HideTagSerializer.writeTo(testHideTag, testOutputStream)\n        val serializedData = testOutputStream.toByteArray()\n        assertTrue(serializedData.isNotEmpty())\n    }\n\n    @Test\n    fun `test readFrom deserializes object correctly`() = runBlocking {\n        val testOutputStream = ByteArrayOutputStream()\n        HideTagSerializer.writeTo(testHideTag, testOutputStream)\n        val serializedData = testOutputStream.toByteArray()\n        val testInputStream = ByteArrayInputStream(serializedData)\n        val deserializedObject = HideTagSerializer.readFrom(testInputStream)\n        assertEquals(testHideTag.id, deserializedObject.id)\n        assertEquals(testHideTag.name, deserializedObject.name)\n    }\n\n    @Test\n    fun `test readFrom throws CorruptionException on corrupted data`(): Unit = runBlocking {\n        val corruptedData = \"corruptedData\".toByteArray()\n        val testInputStream = ByteArrayInputStream(corruptedData)\n        assertThrows<CorruptionException> {\n            HideTagSerializer.readFrom(testInputStream)\n        }\n    }\n}"
  },
  {
    "path": "data/src/test/java/com/desarrollodroide/data/local/datastore/RememberUserPreferencesSerializerTest.kt",
    "content": "package com.desarrollodroide.data.local.datastore\n\nimport androidx.datastore.core.CorruptionException\nimport com.desarrollodroide.data.RememberUserPreferences\nimport kotlinx.coroutines.runBlocking\nimport org.junit.jupiter.api.Assertions.assertEquals\nimport org.junit.jupiter.api.Assertions.assertTrue\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.assertThrows\nimport java.io.ByteArrayInputStream\nimport java.io.ByteArrayOutputStream\n\nclass RememberUserPreferencesSerializerTest {\n\n    private val testRememberUserPreferences = RememberUserPreferences.newBuilder()\n        .setId(1)\n        .setUsername(\"userTest\")\n        .setPassword(\"passTest\")\n        .setUrl(\"https://example.com\")\n        .build()\n\n    @Test\n    fun `test writeTo serializes object correctly`() = runBlocking {\n        val testOutputStream = ByteArrayOutputStream()\n        RememberUserPreferencesSerializer.writeTo(testRememberUserPreferences, testOutputStream)\n        val serializedData = testOutputStream.toByteArray()\n        assertTrue(serializedData.isNotEmpty())\n    }\n\n    @Test\n    fun `test readFrom deserializes object correctly`() = runBlocking {\n        val testOutputStream = ByteArrayOutputStream()\n        RememberUserPreferencesSerializer.writeTo(testRememberUserPreferences, testOutputStream)\n        val serializedData = testOutputStream.toByteArray()\n        val testInputStream = ByteArrayInputStream(serializedData)\n        val deserializedObject = RememberUserPreferencesSerializer.readFrom(testInputStream)\n        assertEquals(testRememberUserPreferences.id, deserializedObject.id)\n        assertEquals(testRememberUserPreferences.username, deserializedObject.username)\n        assertEquals(testRememberUserPreferences.password, deserializedObject.password)\n        assertEquals(testRememberUserPreferences.url, deserializedObject.url)\n    }\n\n\n    @Test\n    fun `test readFrom throws CorruptionException on corrupted data`(): Unit = runBlocking {\n        val corruptedData = \"corruptedData\".toByteArray()\n        val testInputStream = ByteArrayInputStream(corruptedData)\n        assertThrows<CorruptionException> {\n            RememberUserPreferencesSerializer.readFrom(testInputStream)\n        }\n    }\n}\n"
  },
  {
    "path": "data/src/test/java/com/desarrollodroide/data/local/datastore/UserPreferencesSerializerTest.kt",
    "content": "package com.desarrollodroide.data.local.datastore\n\nimport androidx.datastore.core.CorruptionException\nimport com.desarrollodroide.data.UserPreferences\nimport kotlinx.coroutines.runBlocking\nimport org.junit.jupiter.api.Assertions.assertEquals\nimport org.junit.jupiter.api.Assertions.assertTrue\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.assertThrows\nimport java.io.ByteArrayInputStream\nimport java.io.ByteArrayOutputStream\n\nclass UserPreferencesSerializerTest {\n\n    private val testUserPreferences = UserPreferences.newBuilder()\n        .setId(1)\n        .setUsername(\"testUser\")\n        .setPassword(\"testPass\")\n        .setOwner(true)\n        .setSession(\"testSession\")\n        .setUrl(\"https://test.url\")\n        .setRememberPassword(true)\n        .setToken(\"testToken\")\n        .build()\n\n    @Test\n    fun `writeTo serializes UserPreferences correctly`() = runBlocking {\n        val outputStream = ByteArrayOutputStream()\n        UserPreferencesSerializer.writeTo(testUserPreferences, outputStream)\n        val serializedData = outputStream.toByteArray()\n        assertTrue(serializedData.isNotEmpty(), \"Serialized data should not be empty\")\n    }\n\n    @Test\n    fun `readFrom deserializes UserPreferences correctly`() = runBlocking {\n        val outputStream = ByteArrayOutputStream()\n        UserPreferencesSerializer.writeTo(testUserPreferences, outputStream)\n        val serializedData = outputStream.toByteArray()\n        val inputStream = ByteArrayInputStream(serializedData)\n        val deserializedPreferences = UserPreferencesSerializer.readFrom(inputStream)\n        assertEquals(testUserPreferences, deserializedPreferences, \"Deserialized object should match the original\")\n    }\n\n    @Test\n    fun `readFrom throws CorruptionException on corrupted data`(): Unit = runBlocking {\n        val corruptedData = \"corruptedData\".toByteArray()\n        val inputStream = ByteArrayInputStream(corruptedData)\n        assertThrows<CorruptionException> {\n            UserPreferencesSerializer.readFrom(inputStream)\n        }\n    }\n}\n"
  },
  {
    "path": "data/src/test/java/com/desarrollodroide/data/local/preferences/SettingsPreferencesDataSourceTest.kt",
    "content": "package com.desarrollodroide.data.local.preferences\n\nimport kotlinx.coroutines.ExperimentalCoroutinesApi\nimport kotlinx.coroutines.flow.flowOf\nimport androidx.datastore.core.DataStore\nimport androidx.datastore.preferences.core.Preferences\nimport androidx.datastore.preferences.core.booleanPreferencesKey\nimport androidx.datastore.preferences.core.mutablePreferencesOf\nimport androidx.datastore.preferences.core.preferencesOf\nimport com.desarrollodroide.data.UserPreferences\nimport com.desarrollodroide.data.helpers.ThemeMode\nimport kotlinx.coroutines.test.runTest\nimport org.junit.jupiter.api.BeforeEach\nimport org.junit.jupiter.api.Assertions.*\nimport org.junit.jupiter.api.Test\nimport org.mockito.kotlin.*\nimport androidx.datastore.preferences.core.stringPreferencesKey\nimport com.desarrollodroide.data.HideTag\nimport com.desarrollodroide.data.RememberUserPreferences\nimport com.desarrollodroide.data.SystemPreferences\nimport kotlinx.coroutines.flow.first\nimport app.cash.turbine.test\nimport com.desarrollodroide.model.Tag\nimport kotlinx.coroutines.flow.Flow\nimport org.mockito.Mockito.`when`\nimport java.time.ZoneId\nimport java.time.ZonedDateTime\n\n@ExperimentalCoroutinesApi\nclass SettingsPreferencesDataSourceImplTest {\n\n    private lateinit var settingsPreferencesDataSourceImpl: SettingsPreferencesDataSourceImpl\n    private var preferencesDataStore: DataStore<Preferences> = mock()\n    private val protoDataStoreMock: DataStore<UserPreferences> = mock()\n    private val systemPreferencesDataStoreMock: DataStore<SystemPreferences> = mock()\n    private val hideTagDataStoreMock: DataStore<HideTag> = mock()\n    private val rememberUserProtoDataStoreMock: DataStore<RememberUserPreferences> = mock()\n\n    private val THEME_MODE_KEY = stringPreferencesKey(\"theme_mode\")\n    private val CATEGORIES_VISIBLE_KEY = booleanPreferencesKey(\"categories_visible\")\n    private val USE_DYNAMIC_COLORS = booleanPreferencesKey(\"use_dynamic_colors\")\n\n    @BeforeEach\n    fun setUp() {\n        settingsPreferencesDataSourceImpl = SettingsPreferencesDataSourceImpl(\n            dataStore = preferencesDataStore,\n            protoDataStore = protoDataStoreMock,\n            systemPreferences = systemPreferencesDataStoreMock,\n            rememberUserProtoDataStore = rememberUserProtoDataStoreMock,\n            hideTagDataStore = hideTagDataStoreMock\n        )\n    }\n\n    // --- Dynamic Colors Tests ---\n\n    @Test\n    fun `getUseDynamicColors returns expected value when set`() = runTest {\n        val expectedValue = true\n        whenever(preferencesDataStore.data).thenReturn(flowOf(preferencesOf(USE_DYNAMIC_COLORS to expectedValue)))\n\n        val actualValue = settingsPreferencesDataSourceImpl.getUseDynamicColors()\n\n        assertEquals(expectedValue, actualValue)\n    }\n\n    @Test\n    fun `getUseDynamicColors returns false by default when not set`() = runTest {\n        whenever(preferencesDataStore.data).thenReturn(flowOf(preferencesOf()))\n\n        val actualValue = settingsPreferencesDataSourceImpl.getUseDynamicColors()\n\n        assertFalse(actualValue)\n    }\n\n    @Test\n    fun `setUseDynamicColors updates preference correctly`() = runTest {\n        val newValue = true\n\n        settingsPreferencesDataSourceImpl.setUseDynamicColors(newValue)\n\n        verifyPreferenceEdit(preferencesDataStore, USE_DYNAMIC_COLORS, newValue)\n    }\n\n    @Test\n    fun `setUseDynamicColors can disable dynamic colors`() = runTest {\n        val newValue = false\n\n        settingsPreferencesDataSourceImpl.setUseDynamicColors(newValue)\n\n        verifyPreferenceEdit(preferencesDataStore, USE_DYNAMIC_COLORS, newValue)\n    }\n\n    // --- Theme Mode Tests ---\n\n    @Test\n    fun `getThemeMode returns expected value`() = runTest {\n        val expectedThemeMode = ThemeMode.LIGHT\n        whenever(preferencesDataStore.data).thenReturn(flowOf(preferencesOf(THEME_MODE_KEY to expectedThemeMode.name)))\n        val actualThemeMode = settingsPreferencesDataSourceImpl.getThemeMode()\n        assertEquals(expectedThemeMode, actualThemeMode)\n    }\n\n    @Test\n    fun `setThemeMode updates theme mode to DARK`() = runTest {\n        val themeMode = ThemeMode.DARK\n        settingsPreferencesDataSourceImpl.setTheme(themeMode)\n        verifyPreferenceEdit(preferencesDataStore, THEME_MODE_KEY, themeMode.name)\n    }\n\n    @Test\n    fun `getThemeMode retrieves persisted theme mode correctly after app restart`() = runTest {\n        val expectedThemeMode = ThemeMode.DARK\n        whenever(preferencesDataStore.data).thenReturn(flowOf(preferencesOf(THEME_MODE_KEY to expectedThemeMode.name)))\n        val actualThemeMode = settingsPreferencesDataSourceImpl.getThemeMode()\n        assertEquals(expectedThemeMode, actualThemeMode)\n    }\n\n    @Test\n    fun `getThemeMode returns default theme mode when none is set`() = runTest {\n        val defaultThemeMode = ThemeMode.AUTO\n        whenever(preferencesDataStore.data).thenReturn(flowOf(preferencesOf()))\n        val actualThemeMode = settingsPreferencesDataSourceImpl.getThemeMode()\n        assertEquals(defaultThemeMode, actualThemeMode)\n    }\n\n    // --- Categories Visibility Tests ---\n\n    @Test\n    fun `getCategoriesVisible returns expected value`() = runTest {\n        val expectedValue = true\n        whenever(preferencesDataStore.data).thenReturn(flowOf(preferencesOf(CATEGORIES_VISIBLE_KEY to expectedValue)))\n        val actualValue = settingsPreferencesDataSourceImpl.getCategoriesVisible()\n        assertEquals(expectedValue, actualValue)\n    }\n\n    @Test\n    fun `setCategoriesVisible updates categories visible to false`() = runTest {\n        val categoriesVisible = false\n        settingsPreferencesDataSourceImpl.setCategoriesVisible(categoriesVisible)\n        verifyPreferenceEdit(preferencesDataStore, CATEGORIES_VISIBLE_KEY, categoriesVisible)\n    }\n\n    // --- Selected Categories Tests ---\n\n    @Test\n    fun `setSelectedCategories updates selected categories correctly`() = runTest {\n        val selectedCategories = listOf(\"1\", \"2\", \"3\")\n        val captor = argumentCaptor<suspend (SystemPreferences) -> SystemPreferences>()\n\n        settingsPreferencesDataSourceImpl.setSelectedCategories(selectedCategories)\n\n        verify(systemPreferencesDataStoreMock).updateData(captor.capture())\n        val testPreferences = SystemPreferences.getDefaultInstance()\n        val updatedPreferences = captor.firstValue.invoke(testPreferences)\n        assertEquals(selectedCategories, updatedPreferences.selectedCategoriesList)\n    }\n\n    @Test\n    fun `addSelectedCategory adds category correctly`() = runTest {\n        val newTag = Tag(id = 4, name = \"New Category\", selected = false, nBookmarks = 0)\n        val existingCategories = listOf(\"1\", \"2\", \"3\")\n        val captor = argumentCaptor<suspend (SystemPreferences) -> SystemPreferences>()\n\n        val initialPreferences = SystemPreferences.newBuilder()\n            .addAllSelectedCategories(existingCategories)\n            .build()\n\n        settingsPreferencesDataSourceImpl.addSelectedCategory(newTag)\n\n        verify(systemPreferencesDataStoreMock).updateData(captor.capture())\n        val updatedPreferences = captor.firstValue.invoke(initialPreferences)\n        assertEquals(existingCategories + \"4\", updatedPreferences.selectedCategoriesList)\n    }\n\n    @Test\n    fun `removeSelectedCategory removes category correctly`() = runTest {\n        val tagToRemove = Tag(id = 2, name = \"Category to Remove\", selected = false, nBookmarks = 0)\n        val existingCategories = listOf(\"1\", \"2\", \"3\")\n        val captor = argumentCaptor<suspend (SystemPreferences) -> SystemPreferences>()\n\n        val initialPreferences = SystemPreferences.newBuilder()\n            .addAllSelectedCategories(existingCategories)\n            .build()\n\n        settingsPreferencesDataSourceImpl.removeSelectedCategory(tagToRemove)\n\n        verify(systemPreferencesDataStoreMock).updateData(captor.capture())\n        val updatedPreferences = captor.firstValue.invoke(initialPreferences)\n        assertEquals(listOf(\"1\", \"3\"), updatedPreferences.selectedCategoriesList)\n    }\n\n    // --- UserPreferences Tests ---\n\n    @Test\n    fun `getUser returns User with correct data`() = runTest {\n        val expectedUser = UserPreferences.newBuilder()\n            .setId(1)\n            .setUsername(\"testUser\")\n            .setSession(\"session123\")\n            .setToken(\"tokenABC\")\n            .build()\n        whenever(protoDataStoreMock.data).thenReturn(flowOf(expectedUser))\n        val actualUser = settingsPreferencesDataSourceImpl.getUser().first()\n        assertEquals(expectedUser.username, actualUser.account.userName)\n        assertEquals(expectedUser.session, actualUser.session)\n        assertEquals(expectedUser.token, actualUser.token)\n    }\n\n    @Test\n    fun `saveUser updates UserPreferences correctly`() = runTest {\n        val userPreferences = UserPreferences.newBuilder().setId(1).build()\n        val serverUrl = \"https://example.com\"\n        val password = \"password123\"\n        settingsPreferencesDataSourceImpl.saveUser(userPreferences, serverUrl, password)\n        verify(protoDataStoreMock).updateData(any())\n    }\n\n    @Test\n    fun `resetUser resets user data correctly`() = runTest {\n        settingsPreferencesDataSourceImpl.resetData()\n        verify(protoDataStoreMock).updateData(any())\n    }\n\n    // --- RememberUserPreferences Tests ---\n\n    @Test\n    fun `resetRememberUser resets remembered user data correctly`() = runTest {\n        settingsPreferencesDataSourceImpl.resetRememberUser()\n        verify(rememberUserProtoDataStoreMock).updateData(any())\n    }\n\n    @Test\n    fun `getRememberUser returns Account with correct data`() = runTest {\n        val expectedAccount = RememberUserPreferences.newBuilder()\n            .setId(1)\n            .setUsername(\"rememberUser\")\n            .setPassword(\"password123\")\n            .setUrl(\"https://example-remember.com\")\n            .build()\n        whenever(rememberUserProtoDataStoreMock.data).thenReturn(flowOf(expectedAccount))\n        val actualAccount = settingsPreferencesDataSourceImpl.getRememberUser().first()\n        assertEquals(expectedAccount.username, actualAccount.userName)\n        assertEquals(expectedAccount.url, actualAccount.serverUrl)\n        assertEquals(expectedAccount.password, actualAccount.password)\n    }\n\n    @Test\n    fun `saveRememberUser updates RememberUserPreferences correctly`() = runTest {\n        val url = \"https://example-save.com\"\n        val userName = \"saveUser\"\n        val password = \"savePass123\"\n        settingsPreferencesDataSourceImpl.saveRememberUser(url, userName, password)\n        verify(rememberUserProtoDataStoreMock).updateData(any())\n    }\n\n    // --- System Preferences Tests ---\n\n    @Test\n    fun `setMakeArchivePublic updates preference correctly`() = runTest {\n        val newValue = true\n        val captor = argumentCaptor<suspend (SystemPreferences) -> SystemPreferences>()\n        settingsPreferencesDataSourceImpl.setMakeArchivePublic(newValue)\n        verify(systemPreferencesDataStoreMock).updateData(captor.capture())\n        val testPreferences = SystemPreferences.getDefaultInstance()\n        val updatedPreferences = captor.firstValue.invoke(testPreferences)\n        assertEquals(newValue, updatedPreferences.makeArchivePublic)\n    }\n\n    @Test\n    fun `setCreateEbook updates preference correctly`() = runTest {\n        val newValue = true\n        val captor = argumentCaptor<suspend (SystemPreferences) -> SystemPreferences>()\n        settingsPreferencesDataSourceImpl.setCreateEbook(newValue)\n        verify(systemPreferencesDataStoreMock).updateData(captor.capture())\n        val testPreferences = SystemPreferences.getDefaultInstance()\n        val updatedPreferences = captor.firstValue.invoke(testPreferences)\n        assertEquals(newValue, updatedPreferences.createEbook)\n    }\n\n    // --- Flow Tests ---\n\n    // --- CompactView Tests ---\n\n    @Test\n    fun `compactViewFlow emits correct value`() = runTest {\n        val mockSystemPreferences = SystemPreferences.newBuilder()\n            .setCompactView(true)\n            .build()\n        val mockSystemPreferencesFlow: Flow<SystemPreferences> = flowOf(mockSystemPreferences)\n        `when`(systemPreferencesDataStoreMock.data).thenReturn(mockSystemPreferencesFlow)\n\n        settingsPreferencesDataSourceImpl.compactViewFlow.test {\n            val emittedItem = awaitItem()\n            assertEquals(true, emittedItem)\n            cancelAndIgnoreRemainingEvents()\n        }\n    }\n\n    @Test\n    fun `setCompactView updates compact view preference correctly`() = runTest {\n        // Given\n        val newCompactViewValue = true\n        val captor = argumentCaptor<suspend (SystemPreferences) -> SystemPreferences>()\n\n        // When\n        settingsPreferencesDataSourceImpl.setCompactView(newCompactViewValue)\n\n        // Then\n        verify(systemPreferencesDataStoreMock).updateData(captor.capture())\n        val testPreferences = SystemPreferences.getDefaultInstance()\n        val updatedPreferences = captor.firstValue.invoke(testPreferences)\n        assertEquals(newCompactViewValue, updatedPreferences.compactView)\n    }\n\n    @Test\n    fun `setCompactView toggles from true to false correctly`() = runTest {\n        // Given\n        val initialPreferences = SystemPreferences.newBuilder()\n            .setCompactView(true)\n            .build()\n        val captor = argumentCaptor<suspend (SystemPreferences) -> SystemPreferences>()\n\n        // When\n        settingsPreferencesDataSourceImpl.setCompactView(false)\n\n        // Then\n        verify(systemPreferencesDataStoreMock).updateData(captor.capture())\n        val updatedPreferences = captor.firstValue.invoke(initialPreferences)\n        assertFalse(updatedPreferences.compactView)\n    }\n\n    @Test\n    fun `compact view state is correctly propagated through flow`() = runTest {\n        // Given\n        val initialPreferences = SystemPreferences.newBuilder()\n            .setCompactView(true)\n            .build()\n        val updatedPreferences = SystemPreferences.newBuilder()\n            .setCompactView(false)\n            .build()\n\n        // Create a flow that will emit both values\n        val preferencesFlow = flowOf(initialPreferences, updatedPreferences)\n        whenever(systemPreferencesDataStoreMock.data).thenReturn(preferencesFlow)\n\n        // Then\n        settingsPreferencesDataSourceImpl.compactViewFlow.test {\n            assertEquals(true, awaitItem()) // First emission\n            assertEquals(false, awaitItem()) // Second emission\n            cancelAndIgnoreRemainingEvents()\n        }\n    }\n\n    @Test\n    fun `makeArchivePublicFlow emits correct value`() = runTest {\n        val mockSystemPreferences = SystemPreferences.newBuilder()\n            .setMakeArchivePublic(true)\n            .build()\n        val mockSystemPreferencesFlow: Flow<SystemPreferences> = flowOf(mockSystemPreferences)\n        `when`(systemPreferencesDataStoreMock.data).thenReturn(mockSystemPreferencesFlow)\n\n        settingsPreferencesDataSourceImpl.makeArchivePublicFlow.test {\n            val emittedItem = awaitItem()\n            assertEquals(true, emittedItem)\n            cancelAndIgnoreRemainingEvents()\n        }\n    }\n\n    @Test\n    fun `createEbookFlow emits correct value`() = runTest {\n        val mockSystemPreferences = SystemPreferences.newBuilder()\n            .setCreateEbook(true)\n            .build()\n        val mockSystemPreferencesFlow: Flow<SystemPreferences> = flowOf(mockSystemPreferences)\n        `when`(systemPreferencesDataStoreMock.data).thenReturn(mockSystemPreferencesFlow)\n\n        settingsPreferencesDataSourceImpl.createEbookFlow.test {\n            val emittedItem = awaitItem()\n            assertEquals(true, emittedItem)\n            cancelAndIgnoreRemainingEvents()\n        }\n    }\n\n    // --- AutoAddBookmark Tests ---\n\n    @Test\n    fun `setAutoAddBookmark updates preference correctly`() = runTest {\n        // Given\n        val newValue = true\n        val captor = argumentCaptor<suspend (SystemPreferences) -> SystemPreferences>()\n\n        // When\n        settingsPreferencesDataSourceImpl.setAutoAddBookmark(newValue)\n\n        // Then\n        verify(systemPreferencesDataStoreMock).updateData(captor.capture())\n        val testPreferences = SystemPreferences.getDefaultInstance()\n        val updatedPreferences = captor.firstValue.invoke(testPreferences)\n        assertEquals(newValue, updatedPreferences.autoAddBookmark)\n    }\n\n    @Test\n    fun `setAutoAddBookmark can disable auto-add bookmark`() = runTest {\n        // Given\n        val initialPreferences = SystemPreferences.newBuilder()\n            .setAutoAddBookmark(true)\n            .build()\n        val captor = argumentCaptor<suspend (SystemPreferences) -> SystemPreferences>()\n\n        // When\n        settingsPreferencesDataSourceImpl.setAutoAddBookmark(false)\n\n        // Then\n        verify(systemPreferencesDataStoreMock).updateData(captor.capture())\n        val updatedPreferences = captor.firstValue.invoke(initialPreferences)\n        assertFalse(updatedPreferences.autoAddBookmark)\n    }\n\n    @Test\n    fun `autoAddBookmarkFlow emits correct values`() = runTest {\n        // Given\n        val mockSystemPreferences = SystemPreferences.newBuilder()\n            .setAutoAddBookmark(true)\n            .build()\n        val mockSystemPreferencesFlow: Flow<SystemPreferences> = flowOf(mockSystemPreferences)\n        whenever(systemPreferencesDataStoreMock.data).thenReturn(mockSystemPreferencesFlow)\n\n        // Then\n        settingsPreferencesDataSourceImpl.autoAddBookmarkFlow.test {\n            val emittedItem = awaitItem()\n            assertTrue(emittedItem)\n            cancelAndIgnoreRemainingEvents()\n        }\n    }\n\n    @Test\n    fun `autoAddBookmarkFlow emits updates when preference changes`() = runTest {\n        // Given\n        val initialPreferences = SystemPreferences.newBuilder()\n            .setAutoAddBookmark(false)\n            .build()\n        val updatedPreferences = SystemPreferences.newBuilder()\n            .setAutoAddBookmark(true)\n            .build()\n        val preferencesFlow = flowOf(initialPreferences, updatedPreferences)\n        whenever(systemPreferencesDataStoreMock.data).thenReturn(preferencesFlow)\n\n        // Then\n        settingsPreferencesDataSourceImpl.autoAddBookmarkFlow.test {\n            assertFalse(awaitItem()) // Initial value\n            assertTrue(awaitItem())  // Updated value\n            cancelAndIgnoreRemainingEvents()\n        }\n    }\n\n    // --- CreateArchive Tests ---\n\n    @Test\n    fun `setCreateArchive updates preference correctly`() = runTest {\n        // Given\n        val newValue = true\n        val captor = argumentCaptor<suspend (SystemPreferences) -> SystemPreferences>()\n\n        // When\n        settingsPreferencesDataSourceImpl.setCreateArchive(newValue)\n\n        // Then\n        verify(systemPreferencesDataStoreMock).updateData(captor.capture())\n        val testPreferences = SystemPreferences.getDefaultInstance()\n        val updatedPreferences = captor.firstValue.invoke(testPreferences)\n        assertEquals(newValue, updatedPreferences.createArchive)\n    }\n\n    @Test\n    fun `setCreateArchive can disable archive creation`() = runTest {\n        // Given\n        val initialPreferences = SystemPreferences.newBuilder()\n            .setCreateArchive(true)\n            .build()\n        val captor = argumentCaptor<suspend (SystemPreferences) -> SystemPreferences>()\n\n        // When\n        settingsPreferencesDataSourceImpl.setCreateArchive(false)\n\n        // Then\n        verify(systemPreferencesDataStoreMock).updateData(captor.capture())\n        val updatedPreferences = captor.firstValue.invoke(initialPreferences)\n        assertFalse(updatedPreferences.createArchive)\n    }\n\n    @Test\n    fun `createArchiveFlow emits initial value correctly`() = runTest {\n        // Given\n        val mockSystemPreferences = SystemPreferences.newBuilder()\n            .setCreateArchive(true)\n            .build()\n        val mockSystemPreferencesFlow: Flow<SystemPreferences> = flowOf(mockSystemPreferences)\n        whenever(systemPreferencesDataStoreMock.data).thenReturn(mockSystemPreferencesFlow)\n\n        // Then\n        settingsPreferencesDataSourceImpl.createArchiveFlow.test {\n            val emittedItem = awaitItem()\n            assertTrue(emittedItem)\n            cancelAndIgnoreRemainingEvents()\n        }\n    }\n\n    @Test\n    fun `createArchiveFlow reflects preference changes`() = runTest {\n        // Given\n        val initialPreferences = SystemPreferences.newBuilder()\n            .setCreateArchive(false)\n            .build()\n        val updatedPreferences = SystemPreferences.newBuilder()\n            .setCreateArchive(true)\n            .build()\n        val preferencesFlow = flowOf(initialPreferences, updatedPreferences)\n        whenever(systemPreferencesDataStoreMock.data).thenReturn(preferencesFlow)\n\n        // Then\n        settingsPreferencesDataSourceImpl.createArchiveFlow.test {\n            assertFalse(awaitItem()) // Initial value\n            assertTrue(awaitItem())  // Updated value\n            cancelAndIgnoreRemainingEvents()\n        }\n    }\n\n    // --- User Preferences Getters Tests ---\n\n    // Tests for getUrl()\n    @Test\n    fun `getUrl returns correct server url from user preferences`() = runTest {\n        // Given\n        val expectedUrl = \"https://example.com\"\n        val userPreferences = UserPreferences.newBuilder()\n            .setUrl(expectedUrl)\n            .build()\n        whenever(protoDataStoreMock.data).thenReturn(flowOf(userPreferences))\n\n        // When\n        val actualUrl = settingsPreferencesDataSourceImpl.getUrl()\n\n        // Then\n        assertEquals(expectedUrl, actualUrl)\n    }\n\n    @Test\n    fun `getUrl returns empty string when no url is set`() = runTest {\n        // Given\n        val userPreferences = UserPreferences.getDefaultInstance()\n        whenever(protoDataStoreMock.data).thenReturn(flowOf(userPreferences))\n\n        // When\n        val actualUrl = settingsPreferencesDataSourceImpl.getUrl()\n\n        // Then\n        assertEquals(\"\", actualUrl)\n    }\n\n    // Tests for getSession()\n    @Test\n    fun `getSession returns correct session from user preferences`() = runTest {\n        // Given\n        val expectedSession = \"session123\"\n        val userPreferences = UserPreferences.newBuilder()\n            .setSession(expectedSession)\n            .build()\n        whenever(protoDataStoreMock.data).thenReturn(flowOf(userPreferences))\n\n        // When\n        val actualSession = settingsPreferencesDataSourceImpl.getSession()\n\n        // Then\n        assertEquals(expectedSession, actualSession)\n    }\n\n    @Test\n    fun `getSession returns empty string when no session is set`() = runTest {\n        // Given\n        val userPreferences = UserPreferences.getDefaultInstance()\n        whenever(protoDataStoreMock.data).thenReturn(flowOf(userPreferences))\n\n        // When\n        val actualSession = settingsPreferencesDataSourceImpl.getSession()\n\n        // Then\n        assertEquals(\"\", actualSession)\n    }\n\n    // Tests for getToken()\n    @Test\n    fun `getToken returns correct token from user preferences`() = runTest {\n        // Given\n        val expectedToken = \"token123\"\n        val userPreferences = UserPreferences.newBuilder()\n            .setToken(expectedToken)\n            .build()\n        whenever(protoDataStoreMock.data).thenReturn(flowOf(userPreferences))\n\n        // When\n        val actualToken = settingsPreferencesDataSourceImpl.getToken()\n\n        // Then\n        assertEquals(expectedToken, actualToken)\n    }\n\n    @Test\n    fun `getToken returns empty string when no token is set`() = runTest {\n        // Given\n        val userPreferences = UserPreferences.getDefaultInstance()\n        whenever(protoDataStoreMock.data).thenReturn(flowOf(userPreferences))\n\n        // When\n        val actualToken = settingsPreferencesDataSourceImpl.getToken()\n\n        // Then\n        assertEquals(\"\", actualToken)\n    }\n\n    // --- Hide Tag Tests ---\n\n    // Tests for setHideTag()\n    @Test\n    fun `setHideTag updates tag correctly`() = runTest {\n        // Given\n        val tag = Tag(id = 1, name = \"TestTag\", selected = false, nBookmarks = 0)\n        val captor = argumentCaptor<suspend (HideTag) -> HideTag>()\n\n        // When\n        settingsPreferencesDataSourceImpl.setHideTag(tag)\n\n        // Then\n        verify(hideTagDataStoreMock).updateData(captor.capture())\n        val testHideTag = HideTag.getDefaultInstance()\n        val updatedHideTag = captor.firstValue.invoke(testHideTag)\n        assertEquals(tag.id, updatedHideTag.id)\n        assertEquals(tag.name, updatedHideTag.name)\n    }\n\n    @Test\n    fun `setHideTag handles null tag by returning default instance`() = runTest {\n        // Given\n        val captor = argumentCaptor<suspend (HideTag) -> HideTag>()\n\n        // When\n        settingsPreferencesDataSourceImpl.setHideTag(null)\n\n        // Then\n        verify(hideTagDataStoreMock).updateData(captor.capture())\n        val testHideTag = HideTag.getDefaultInstance()\n        val updatedHideTag = captor.firstValue.invoke(testHideTag)\n        assertEquals(HideTag.getDefaultInstance(), updatedHideTag)\n    }\n\n    // Tests for hideTagFlow\n    @Test\n    fun `hideTagFlow emits null when no tag is set`() = runTest {\n        // Given\n        val defaultHideTag = HideTag.getDefaultInstance()\n        whenever(hideTagDataStoreMock.data).thenReturn(flowOf(defaultHideTag))\n\n        // Then\n        settingsPreferencesDataSourceImpl.hideTagFlow.test {\n            val emittedItem = awaitItem()\n            assertNull(emittedItem)\n            cancelAndIgnoreRemainingEvents()\n        }\n    }\n\n    @Test\n    fun `hideTagFlow emits correct tag when set`() = runTest {\n        // Given\n        val expectedTag = HideTag.newBuilder()\n            .setId(1)\n            .setName(\"TestTag\")\n            .build()\n        whenever(hideTagDataStoreMock.data).thenReturn(flowOf(expectedTag))\n\n        // Then\n        settingsPreferencesDataSourceImpl.hideTagFlow.test {\n            val emittedItem = awaitItem()\n            assertNotNull(emittedItem)\n            assertEquals(expectedTag.id, emittedItem?.id)\n            assertEquals(expectedTag.name, emittedItem?.name)\n            assertEquals(false, emittedItem?.selected)\n            assertEquals(0, emittedItem?.nBookmarks)\n            cancelAndIgnoreRemainingEvents()\n        }\n    }\n\n    @Test\n    fun `hideTagFlow reflects changes in hide tag`() = runTest {\n        // Given\n        val initialTag = HideTag.getDefaultInstance()\n        val updatedTag = HideTag.newBuilder()\n            .setId(1)\n            .setName(\"UpdatedTag\")\n            .build()\n        val tagsFlow = flowOf(initialTag, updatedTag)\n        whenever(hideTagDataStoreMock.data).thenReturn(tagsFlow)\n\n        // Then\n        settingsPreferencesDataSourceImpl.hideTagFlow.test {\n            assertNull(awaitItem()) // Initial null value\n            val updatedItem = awaitItem()\n            assertNotNull(updatedItem)\n            assertEquals(updatedTag.id, updatedItem?.id)\n            assertEquals(updatedTag.name, updatedItem?.name)\n            cancelAndIgnoreRemainingEvents()\n        }\n    }\n\n    // --- Sync Timestamp Tests ---\n\n    // Tests for getLastSyncTimestamp()\n    @Test\n    fun `getLastSyncTimestamp returns correct timestamp`() = runTest {\n        // Given\n        val expectedTimestamp = 1234567890L\n        val systemPreferences = SystemPreferences.newBuilder()\n            .setLastSyncTimestamp(expectedTimestamp)\n            .build()\n        whenever(systemPreferencesDataStoreMock.data).thenReturn(flowOf(systemPreferences))\n\n        // When\n        val actualTimestamp = settingsPreferencesDataSourceImpl.getLastSyncTimestamp()\n\n        // Then\n        assertEquals(expectedTimestamp, actualTimestamp)\n    }\n\n    // Tests for setLastSyncTimestamp()\n    @Test\n    fun `setLastSyncTimestamp updates timestamp correctly`() = runTest {\n        // Given\n        val newTimestamp = 1234567890L\n        val captor = argumentCaptor<suspend (SystemPreferences) -> SystemPreferences>()\n\n        // When\n        settingsPreferencesDataSourceImpl.setLastSyncTimestamp(newTimestamp)\n\n        // Then\n        verify(systemPreferencesDataStoreMock).updateData(captor.capture())\n        val testPreferences = SystemPreferences.getDefaultInstance()\n        val updatedPreferences = captor.firstValue.invoke(testPreferences)\n        assertEquals(newTimestamp, updatedPreferences.lastSyncTimestamp)\n    }\n\n    // Tests for setCurrentTimeStamp()\n    @Test\n    fun `setCurrentTimeStamp updates timestamp with current time`() = runTest {\n        // Given\n        val captor = argumentCaptor<suspend (SystemPreferences) -> SystemPreferences>()\n\n        // When\n        settingsPreferencesDataSourceImpl.setCurrentTimeStamp()\n\n        // Then\n        verify(systemPreferencesDataStoreMock).updateData(captor.capture())\n        val testPreferences = SystemPreferences.getDefaultInstance()\n        val updatedPreferences = captor.firstValue.invoke(testPreferences)\n\n        // Verify timestamp is recent (within last minute)\n        val currentTime = ZonedDateTime.now(ZoneId.systemDefault()).toEpochSecond()\n        val timestampDiff = currentTime - updatedPreferences.lastSyncTimestamp\n        assertTrue(timestampDiff < 60) // Difference should be less than 60 seconds\n    }\n\n    // --- Selected Categories Flow Tests ---\n    // TODO\n\n}\n\nprivate suspend fun <T> verifyPreferenceEdit(\n    preferencesDataStore: DataStore<Preferences>,\n    key: Preferences.Key<T>,\n    expectedValue: T\n) {\n    val argumentCaptor = argumentCaptor<suspend (Preferences) -> Preferences>()\n    verify(preferencesDataStore).updateData(argumentCaptor.capture())\n\n    val preferences = mutablePreferencesOf()\n    val updatedPreferences = argumentCaptor.firstValue(preferences)\n    assertEquals(expectedValue, updatedPreferences[key])\n}"
  },
  {
    "path": "data/src/test/java/com/desarrollodroide/data/local/room/converters/TagsConverterTest.kt",
    "content": "package com.desarrollodroide.data.local.room.converters\n\nimport com.desarrollodroide.model.Tag\nimport org.junit.jupiter.api.Assertions.assertEquals\nimport org.junit.jupiter.api.Assertions.assertTrue\nimport org.junit.jupiter.api.Test\n\nclass TagsConverterTest {\n\n    private val converter = TagsConverter()\n\n    @Test\n    fun `fromTagsList converts list of tags to JSON string correctly`() {\n        val tags = listOf(\n            Tag(id = 1, name = \"Tech\", selected = true, nBookmarks = 10),\n            Tag(id = 2, name = \"Science\", selected = false, nBookmarks = 5)\n        )\n        val jsonResult = converter.fromTagsList(tags)\n        assertTrue(jsonResult.contains(\"Tech\") && jsonResult.contains(\"Science\"))\n    }\n\n    @Test\n    fun `toTagsList converts JSON string to list of tags correctly`() {\n        val json = \"\"\"\n            [\n                {\"id\":1,\"name\":\"Tech\"},\n                {\"id\":2,\"name\":\"Science\"}\n            ]\n        \"\"\".trimIndent()\n        val tagsList = converter.toTagsList(json)\n        assertEquals(2, tagsList.size)\n        assertEquals(\"Tech\", tagsList[0].name)\n        assertEquals(\"Science\", tagsList[1].name)\n    }\n\n    @Test\n    fun `toTagsList returns empty list on malformed JSON`() {\n        val malformedJson = \"this is not a valid json\"\n        val result = converter.toTagsList(malformedJson)\n        assertTrue(result.isEmpty())\n    }\n}\n"
  },
  {
    "path": "data/src/test/java/com/desarrollodroide/data/mapper/MapperTest.kt",
    "content": "package com.desarrollodroide.data.mapper\n\nimport com.desarrollodroide.data.local.room.entity.BookmarkEntity\nimport com.desarrollodroide.data.local.room.entity.TagEntity\nimport com.desarrollodroide.model.Account\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.model.Tag\nimport com.desarrollodroide.model.UpdateCachePayload\nimport org.junit.jupiter.api.Assertions.*\nimport com.desarrollodroide.network.model.AccountDTO\nimport com.desarrollodroide.network.model.BookmarkDTO\nimport com.desarrollodroide.network.model.BookmarksDTO\nimport com.desarrollodroide.network.model.LivenessResponseDTO\nimport com.desarrollodroide.network.model.LoginResponseDTO\nimport com.desarrollodroide.network.model.LoginResponseMessageDTO\nimport com.desarrollodroide.network.model.ReadableContentResponseDTO\nimport com.desarrollodroide.network.model.ReadableMessageDto\nimport com.desarrollodroide.network.model.ReleaseInfoDTO\nimport com.desarrollodroide.network.model.SessionDTO\nimport com.desarrollodroide.network.model.TagDTO\nimport org.junit.jupiter.api.Test\n\nclass MapperTest {\n\n    @Test\n    fun `SessionDTO toDomainModel maps correctly`() {\n        val accountDTO = AccountDTO(\n            id = 1,\n            userName = \"testUser\",\n            password = \"password\",\n            isOwner = true,\n            oldPassword = \"oldPass\",\n            newPassword = \"newPass\",\n        )\n        val sessionDTO = SessionDTO(\n            token = \"token123\",\n            session = \"session123\",\n            account = accountDTO\n        )\n\n        val user = sessionDTO.toDomainModel()\n\n        assertEquals(\"token123\", user.token)\n        assertEquals(\"session123\", user.session)\n        assertEquals(\"testUser\", user.account.userName)\n        assertEquals(\"password\", user.account.password)\n        assertEquals(true, user.account.owner)\n    }\n\n    @Test\n    fun `SessionDTO toProtoEntity maps correctly`() {\n        val accountDTO = AccountDTO(\n            id = 1,\n            userName = \"testUser\",\n            password = \"password\",\n            isOwner = true,\n            oldPassword = \"oldPass\",\n            newPassword = \"newPass\",\n        )\n        val sessionDTO = SessionDTO(\n            token = \"token123\",\n            session = \"session123\",\n            account = accountDTO\n        )\n\n        val userPreferences = sessionDTO.toProtoEntity()\n\n        assertEquals(1, userPreferences.id)\n        assertEquals(\"testUser\", userPreferences.username)\n        assertEquals(true, userPreferences.owner)\n        assertEquals(\"\", userPreferences.password)\n        assertEquals(\"session123\", userPreferences.session)\n        assertEquals(\"\", userPreferences.url)  // Assuming this is not set from DTO\n        assertEquals(false, userPreferences.rememberPassword)  // Assuming default value\n        assertEquals(\"\", userPreferences.token)\n    }\n\n    @Test\n    fun `AccountDTO toDomainModel maps correctly`() {\n        val accountDTO = AccountDTO(\n            id = 1,\n            userName = \"testUser\",\n            password = \"password\",\n            isOwner = true,\n            oldPassword = \"oldPass\",\n            newPassword = \"newPass\",\n        )\n\n        val account = accountDTO.toDomainModel()\n\n        assertEquals(\"testUser\", account.userName)\n        assertEquals(\"password\", account.password)\n        assertEquals(true, account.owner)\n    }\n\n    @Test\n    fun `BookmarkDTO toDomainModel maps correctly`() {\n        val tagDTO = TagDTO(\n            id = 1,\n            name = \"tag1\",\n            nBookmarks = 5\n        )\n\n        val bookmarkDTO = BookmarkDTO(\n            id = 1,\n            url = \"http://example.com\",\n            title = \"Example Title\",\n            excerpt = \"Example Excerpt\",\n            author = \"Author Name\",\n            public = 1,\n            modified = \"2023-06-18\",\n            createdAt = \"2023-06-19\",\n            imageURL = \"/image.jpg\",\n            hasContent = true,\n            hasArchive = true,\n            hasEbook = true,\n            tags = listOf(tagDTO),\n            createArchive = true,\n            createEbook = true\n        )\n\n        val serverUrl = \"http://example.com\"\n        val bookmark = bookmarkDTO.toDomainModel(serverUrl)\n\n        assertEquals(1, bookmark.id)\n        assertEquals(\"http://example.com\", bookmark.url)\n        assertEquals(\"Example Title\", bookmark.title)\n        assertEquals(\"Example Excerpt\", bookmark.excerpt)\n        assertEquals(\"Author Name\", bookmark.author)\n        assertEquals(1, bookmark.public)\n        assertEquals(\"2023-06-18\", bookmark.modified)\n        assertEquals(\"2023-06-19\", bookmark.createAt)\n        assertEquals(\"http://example.com/image.jpg\", bookmark.imageURL)\n        assertEquals(true, bookmark.hasContent)\n        assertEquals(true, bookmark.hasArchive)\n        assertEquals(true, bookmark.hasEbook)\n        assertEquals(1, bookmark.tags.size)\n        assertEquals(1, bookmark.tags[0].id)\n        assertEquals(\"tag1\", bookmark.tags[0].name)\n        assertEquals(5, bookmark.tags[0].nBookmarks)\n        assertEquals(true, bookmark.createArchive)\n        assertEquals(true, bookmark.createEbook)\n    }\n\n\n    @Test\n    fun `BookmarksDTO toDomainModel maps correctly`() {\n        val tagDTO = TagDTO(\n            id = 1,\n            name = \"tag1\",\n            nBookmarks = 5\n        )\n\n        val bookmarkDTO = BookmarkDTO(\n            id = 1,\n            url = \"http://example.com\",\n            title = \"Example Title\",\n            excerpt = \"Example Excerpt\",\n            author = \"Author Name\",\n            public = 1,\n            modified = \"2023-06-18\",\n            createdAt = \"2023-06-19\",\n            imageURL = \"/image.jpg\",\n            hasContent = true,\n            hasArchive = true,\n            hasEbook = true,\n            tags = listOf(tagDTO),\n            createArchive = true,\n            createEbook = true\n        )\n\n        val bookmarksDTO = BookmarksDTO(\n            page = 1,\n            maxPage = 10,\n            bookmarks = listOf(bookmarkDTO)\n        )\n\n        val serverUrl = \"http://example.com\"\n        val bookmarks = bookmarksDTO.toDomainModel(serverUrl)\n\n        assertEquals(1, bookmarks.page)\n        assertEquals(10, bookmarks.maxPage)\n        assertEquals(1, bookmarks.bookmarks.size)\n\n        val bookmark = bookmarks.bookmarks[0]\n        assertEquals(1, bookmark.id)\n        assertEquals(\"http://example.com\", bookmark.url)\n        assertEquals(\"Example Title\", bookmark.title)\n        assertEquals(\"Example Excerpt\", bookmark.excerpt)\n        assertEquals(\"Author Name\", bookmark.author)\n        assertEquals(1, bookmark.public)\n        assertEquals(\"2023-06-18\", bookmark.modified)\n        assertEquals(\"2023-06-19\", bookmark.createAt)\n        assertEquals(\"http://example.com/image.jpg\", bookmark.imageURL)\n        assertEquals(true, bookmark.hasContent)\n        assertEquals(true, bookmark.hasArchive)\n        assertEquals(true, bookmark.hasEbook)\n        assertEquals(1, bookmark.tags.size)\n        assertEquals(1, bookmark.tags[0].id)\n        assertEquals(\"tag1\", bookmark.tags[0].name)\n        assertEquals(5, bookmark.tags[0].nBookmarks)\n        assertEquals(true, bookmark.createArchive)\n        assertEquals(true, bookmark.createEbook)\n    }\n\n    @Test\n    fun `TagDTO toDomainModel maps correctly`() {\n        val tagDTO = TagDTO(\n            id = 1,\n            name = \"tag1\",\n            nBookmarks = 5\n        )\n\n        val tag = tagDTO.toDomainModel()\n\n        assertEquals(1, tag.id)\n        assertEquals(\"tag1\", tag.name)\n        assertEquals(false, tag.selected) // Assuming selected is always false in the domain model\n        assertEquals(5, tag.nBookmarks)\n    }\n\n    @Test\n    fun `TagDTO toDomainModel with null fields maps correctly`() {\n        val tagDTO = TagDTO(\n            id = null,\n            name = null,\n            nBookmarks = null\n        )\n\n        val tag = tagDTO.toDomainModel()\n\n        assertEquals(0, tag.id) // Default value for id\n        assertEquals(\"\", tag.name) // Default value for name\n        assertEquals(false, tag.selected) // Assuming selected is always false in the domain model\n        assertEquals(0, tag.nBookmarks) // Default value for nBookmarks\n    }\n\n    @Test\n    fun `TagDTO toEntityModel maps correctly`() {\n        val tagDTO = TagDTO(\n            id = 1,\n            name = \"tag1\",\n            nBookmarks = 5\n        )\n\n        val tagEntity = tagDTO.toEntityModel()\n\n        assertEquals(1, tagEntity.id)\n        assertEquals(\"tag1\", tagEntity.name)\n        assertEquals(5, tagEntity.nBookmarks)\n    }\n\n    @Test\n    fun `TagDTO toEntityModel with null fields maps correctly`() {\n        val tagDTO = TagDTO(\n            id = null,\n            name = null,\n            nBookmarks = null\n        )\n\n        val tagEntity = tagDTO.toEntityModel()\n\n        assertEquals(0, tagEntity.id) // Default value for id\n        assertEquals(\"\", tagEntity.name) // Default value for name\n        assertEquals(0, tagEntity.nBookmarks) // Default value for nBookmarks\n    }\n\n    @Test\n    fun `TagEntity toDomainModel maps correctly`() {\n        val tagEntity = TagEntity(\n            id = 1,\n            name = \"tag1\",\n            nBookmarks = 5\n        )\n\n        val tag = tagEntity.toDomainModel()\n\n        assertEquals(1, tag.id)\n        assertEquals(\"tag1\", tag.name)\n        assertEquals(false, tag.selected) // Assuming selected is always false in the domain model\n        assertEquals(5, tag.nBookmarks)\n    }\n\n    @Test\n    fun `Account toRequestBody maps correctly`() {\n        val account = Account(\n            id = 1,\n            userName = \"testUser\",\n            password = \"password\",\n            owner = true,\n            serverUrl = \"https://example.com\",\n        )\n\n        val loginRequestPayload = account.toRequestBody()\n\n        assertEquals(\"testUser\", loginRequestPayload.username)\n        assertEquals(\"password\", loginRequestPayload.password)\n    }\n\n    @Test\n    fun `BookmarkDTO toEntityModel maps correctly`() {\n        val tagDTO = TagDTO(\n            id = 1,\n            name = \"tag1\",\n            nBookmarks = 5\n        )\n\n        val bookmarkDTO = BookmarkDTO(\n            id = 1,\n            url = \"http://example.com\",\n            title = \"Example Title\",\n            excerpt = \"Example Excerpt\",\n            author = \"Author Name\",\n            public = 1,\n            modified = \"2023-06-18\",\n            createdAt = \"2023-06-19\",\n            imageURL = \"/image.jpg\",\n            hasContent = true,\n            hasArchive = true,\n            hasEbook = true,\n            tags = listOf(tagDTO),\n            createArchive = true,\n            createEbook = true\n        )\n\n        val bookmarkEntity = bookmarkDTO.toEntityModel()\n\n        assertEquals(1, bookmarkEntity.id)\n        assertEquals(\"http://example.com\", bookmarkEntity.url)\n        assertEquals(\"Example Title\", bookmarkEntity.title)\n        assertEquals(\"Example Excerpt\", bookmarkEntity.excerpt)\n        assertEquals(\"Author Name\", bookmarkEntity.author)\n        assertEquals(1, bookmarkEntity.isPublic)\n        assertEquals(\"2023-06-18\", bookmarkEntity.modified)\n        assertEquals(\"2023-06-19\", bookmarkEntity.createdAt)\n        assertEquals(\"/image.jpg\", bookmarkEntity.imageURL)\n        assertEquals(true, bookmarkEntity.hasContent)\n        assertEquals(true, bookmarkEntity.hasArchive)\n        assertEquals(true, bookmarkEntity.hasEbook)\n        assertEquals(1, bookmarkEntity.tags.size)\n        assertEquals(1, bookmarkEntity.tags[0].id)\n        assertEquals(\"tag1\", bookmarkEntity.tags[0].name)\n        assertEquals(5, bookmarkEntity.tags[0].nBookmarks)\n        assertEquals(true, bookmarkEntity.createArchive)\n        assertEquals(true, bookmarkEntity.createEbook)\n    }\n\n    @Test\n    fun `BookmarkDTO toEntityModel with null fields maps correctly`() {\n        val bookmarkDTO = BookmarkDTO(\n            id = null,\n            url = null,\n            title = null,\n            excerpt = null,\n            author = null,\n            public = null,\n            modified = null,\n            createdAt = null,\n            imageURL = null,\n            hasContent = null,\n            hasArchive = null,\n            hasEbook = null,\n            tags = null,\n            createArchive = null,\n            createEbook = null\n        )\n\n        val bookmarkEntity = bookmarkDTO.toEntityModel()\n\n        assertEquals(0, bookmarkEntity.id) // Default value for id\n        assertEquals(\"\", bookmarkEntity.url) // Default value for url\n        assertEquals(\"\", bookmarkEntity.title) // Default value for title\n        assertEquals(\"\", bookmarkEntity.excerpt) // Default value for excerpt\n        assertEquals(\"\", bookmarkEntity.author) // Default value for author\n        assertEquals(0, bookmarkEntity.isPublic) // Default value for isPublic\n        assertEquals(\"\", bookmarkEntity.modified) // Default value for modified\n        assertEquals(\"\", bookmarkEntity.createdAt) // Default value for createdAt\n        assertEquals(\"\", bookmarkEntity.imageURL) // Default value for imageURL\n        assertEquals(false, bookmarkEntity.hasContent) // Default value for hasContent\n        assertEquals(false, bookmarkEntity.hasArchive) // Default value for hasArchive\n        assertEquals(false, bookmarkEntity.hasEbook) // Default value for hasEbook\n        assertEquals(0, bookmarkEntity.tags.size) // Default empty list for tags\n        assertEquals(false, bookmarkEntity.createArchive) // Default value for createArchive\n        assertEquals(false, bookmarkEntity.createEbook) // Default value for createEbook\n    }\n\n    @Test\n    fun `BookmarkEntity toDomainModel maps correctly`() {\n        val tag = Tag(\n            id = 1,\n            name = \"tag1\",\n            selected = false,\n            nBookmarks = 5\n        )\n\n        val bookmarkEntity = BookmarkEntity(\n            id = 1,\n            url = \"http://example.com\",\n            title = \"Example Title\",\n            excerpt = \"Example Excerpt\",\n            author = \"Author Name\",\n            isPublic = 1,\n            modified = \"2023-06-18\",\n            createdAt = \"2023-06-19\",\n            imageURL = \"/image.jpg\",\n            hasContent = true,\n            hasArchive = true,\n            hasEbook = true,\n            tags = listOf(tag),\n            createArchive = true,\n            createEbook = true\n        )\n\n        val bookmark = bookmarkEntity.toDomainModel()\n\n        assertEquals(1, bookmark.id)\n        assertEquals(\"http://example.com\", bookmark.url)\n        assertEquals(\"Example Title\", bookmark.title)\n        assertEquals(\"Example Excerpt\", bookmark.excerpt)\n        assertEquals(\"Author Name\", bookmark.author)\n        assertEquals(1, bookmark.public)\n        assertEquals(\"2023-06-18\", bookmark.modified)\n        assertEquals(\"2023-06-19\", bookmark.createAt)\n        assertEquals(\"/image.jpg\", bookmark.imageURL)\n        assertEquals(true, bookmark.hasContent)\n        assertEquals(true, bookmark.hasArchive)\n        assertEquals(true, bookmark.hasEbook)\n        assertEquals(1, bookmark.tags.size)\n        assertEquals(1, bookmark.tags[0].id)\n        assertEquals(\"tag1\", bookmark.tags[0].name)\n        assertEquals(5, bookmark.tags[0].nBookmarks)\n        assertEquals(true, bookmark.createArchive)\n        assertEquals(true, bookmark.createEbook)\n    }\n\n    @Test\n    fun `BookmarkEntity toDomainModel with empty tags maps correctly`() {\n        val bookmarkEntity = BookmarkEntity(\n            id = 1,\n            url = \"http://example.com\",\n            title = \"Example Title\",\n            excerpt = \"Example Excerpt\",\n            author = \"Author Name\",\n            isPublic = 1,\n            modified = \"2023-06-18\",\n            createdAt = \"2023-06-19\",\n            imageURL = \"/image.jpg\",\n            hasContent = true,\n            hasArchive = true,\n            hasEbook = true,\n            tags = emptyList(),\n            createArchive = true,\n            createEbook = true\n        )\n\n        val bookmark = bookmarkEntity.toDomainModel()\n\n        assertEquals(1, bookmark.id)\n        assertEquals(\"http://example.com\", bookmark.url)\n        assertEquals(\"Example Title\", bookmark.title)\n        assertEquals(\"Example Excerpt\", bookmark.excerpt)\n        assertEquals(\"Author Name\", bookmark.author)\n        assertEquals(1, bookmark.public)\n        assertEquals(\"2023-06-18\", bookmark.modified)\n        assertEquals(\"2023-06-19\", bookmark.createAt)\n        assertEquals(\"/image.jpg\", bookmark.imageURL)\n        assertEquals(true, bookmark.hasContent)\n        assertEquals(true, bookmark.hasArchive)\n        assertEquals(true, bookmark.hasEbook)\n        assertEquals(0, bookmark.tags.size) // Ensure tags are empty\n        assertEquals(true, bookmark.createArchive)\n        assertEquals(true, bookmark.createEbook)\n    }\n\n    @Test\n    fun `UpdateCachePayload toDTO maps correctly`() {\n        val updateCachePayload = UpdateCachePayload(\n            createArchive = true,\n            createEbook = false,\n            ids = listOf(1, 2, 3),\n            keepMetadata = true,\n            skipExist = false\n        )\n\n        val updateCachePayloadDTO = updateCachePayload.toDTO()\n\n        assertEquals(true, updateCachePayloadDTO.createArchive)\n        assertEquals(false, updateCachePayloadDTO.createEbook)\n        assertEquals(listOf(1, 2, 3), updateCachePayloadDTO.ids)\n        assertEquals(true, updateCachePayloadDTO.keepMetadata)\n    }\n\n    @Test\n    fun `LivenessResponseDTO toDomainModel maps correctly`() {\n        val releaseInfoDTO = ReleaseInfoDTO(\n            version = \"1.0.0\",\n            date = \"2023-06-18\",\n            commit = \"abc123\"\n        )\n\n        val livenessResponseDTO = LivenessResponseDTO(\n            ok = true,\n            message = releaseInfoDTO\n        )\n\n        val livenessResponse = livenessResponseDTO.toDomainModel()\n\n        assertEquals(true, livenessResponse.ok)\n        assertEquals(\"1.0.0\", livenessResponse.message?.version)\n        assertEquals(\"2023-06-18\", livenessResponse.message?.date)\n        assertEquals(\"abc123\", livenessResponse.message?.commit)\n    }\n\n    @Test\n    fun `LivenessResponseDTO toDomainModel with null message maps correctly`() {\n        val livenessResponseDTO = LivenessResponseDTO(\n            ok = true,\n            message = null\n        )\n\n        val livenessResponse = livenessResponseDTO.toDomainModel()\n\n        assertEquals(true, livenessResponse.ok)\n        assertEquals(null, livenessResponse.message)\n    }\n\n    @Test\n    fun `LivenessResponseDTO toDomainModel with null ok maps correctly`() {\n        val releaseInfoDTO = ReleaseInfoDTO(\n            version = \"1.0.0\",\n            date = \"2023-06-18\",\n            commit = \"abc123\"\n        )\n\n        val livenessResponseDTO = LivenessResponseDTO(\n            ok = null,\n            message = releaseInfoDTO\n        )\n\n        val livenessResponse = livenessResponseDTO.toDomainModel()\n\n        assertEquals(false, livenessResponse.ok)\n        assertEquals(\"1.0.0\", livenessResponse.message?.version)\n        assertEquals(\"2023-06-18\", livenessResponse.message?.date)\n        assertEquals(\"abc123\", livenessResponse.message?.commit)\n    }\n\n    @Test\n    fun `ReleaseInfoDTO toDomainModel maps correctly`() {\n        val releaseInfoDTO = ReleaseInfoDTO(\n            version = \"1.0.0\",\n            date = \"2023-06-18\",\n            commit = \"abc123\"\n        )\n\n        val releaseInfo = releaseInfoDTO.toDomainModel()\n\n        assertEquals(\"1.0.0\", releaseInfo.version)\n        assertEquals(\"2023-06-18\", releaseInfo.date)\n        assertEquals(\"abc123\", releaseInfo.commit)\n    }\n\n    @Test\n    fun `ReleaseInfoDTO toDomainModel with null fields maps correctly`() {\n        val releaseInfoDTO = ReleaseInfoDTO(\n            version = null,\n            date = null,\n            commit = null\n        )\n\n        val releaseInfo = releaseInfoDTO.toDomainModel()\n\n        assertEquals(\"\", releaseInfo.version) // Default value for version\n        assertEquals(\"\", releaseInfo.date) // Default value for date\n        assertEquals(\"\", releaseInfo.commit) // Default value for commit\n    }\n\n    @Test\n    fun `LoginResponseDTO toProtoEntity maps correctly`() {\n        val loginResponseMessageDTO = LoginResponseMessageDTO(\n            expires = 3600,\n            session = \"session123\",\n            token = \"token123\"\n        )\n\n        val loginResponseDTO = LoginResponseDTO(\n            ok = true,\n            message = loginResponseMessageDTO,\n            error = null\n        )\n\n        val userPreferences = loginResponseDTO.toProtoEntity(userName = \"testUser\")\n\n        assertEquals(\"session123\", userPreferences.session)\n        assertEquals(\"testUser\", userPreferences.username)\n        assertEquals(\"token123\", userPreferences.token)\n    }\n\n    @Test\n    fun `LoginResponseDTO toProtoEntity with null message maps correctly`() {\n        val loginResponseDTO = LoginResponseDTO(\n            ok = true,\n            message = null,\n            error = null\n        )\n\n        val userPreferences = loginResponseDTO.toProtoEntity(userName = \"testUser\")\n\n        assertEquals(\"\", userPreferences.session) // Default value for session\n        assertEquals(\"testUser\", userPreferences.username)\n        assertEquals(\"\", userPreferences.token) // Default value for token\n    }\n\n    @Test\n    fun `ReadableContentResponseDTO toDomainModel maps correctly`() {\n        val readableMessageDto = ReadableMessageDto(\n            content = \"Sample Content\",\n            html = \"<p>Sample HTML</p>\"\n        )\n\n        val readableContentResponseDTO = ReadableContentResponseDTO(\n            ok = true,\n            message = readableMessageDto\n        )\n\n        val readableContent = readableContentResponseDTO.toDomainModel()\n\n        assertEquals(true, readableContent.ok)\n        assertEquals(\"Sample Content\", readableContent.message.content)\n        assertEquals(\"<p>Sample HTML</p>\", readableContent.message.html)\n    }\n\n    @Test\n    fun `ReadableContentResponseDTO toDomainModel with null fields maps correctly`() {\n        val readableContentResponseDTO = ReadableContentResponseDTO(\n            ok = null,\n            message = null\n        )\n\n        val readableContent = readableContentResponseDTO.toDomainModel()\n\n        assertEquals(false, readableContent.ok) // Default value for ok\n        assertEquals(\"\", readableContent.message.content) // Default value for content\n        assertEquals(\"\", readableContent.message.html) // Default value for html\n    }\n\n    @Test\n    fun `ReadableMessageDto toDomainModel maps correctly`() {\n        val readableMessageDto = ReadableMessageDto(\n            content = \"Sample Content\",\n            html = \"<p>Sample HTML</p>\"\n        )\n\n        val readableMessage = readableMessageDto.toDomainModel()\n\n        assertEquals(\"Sample Content\", readableMessage.content)\n        assertEquals(\"<p>Sample HTML</p>\", readableMessage.html)\n    }\n\n    @Test\n    fun `ReadableMessageDto toDomainModel with null fields maps correctly`() {\n        val readableMessageDto = ReadableMessageDto(\n            content = null,\n            html = null\n        )\n\n        val readableMessage = readableMessageDto.toDomainModel()\n\n        assertEquals(\"\", readableMessage.content) // Default value for content\n        assertEquals(\"\", readableMessage.html) // Default value for html\n    }\n\n    @Test\n    fun `toAddBookmarkDTO should map fields correctly`() {\n        // Given\n        val tags = listOf(Tag(id = 1, name = \"education\"), Tag(id = 2, name = \"reading\"))\n        val bookmark = Bookmark(\n            url = \"https://example.com\",\n            tags = tags,\n            public = 1,\n            createArchive = true,\n            createEbook = true,\n            title = \"Example Title\"\n        )\n\n        // When\n        val dto = bookmark.toAddBookmarkDTO()\n\n        // Then\n        assertNull(dto.id)\n        assertEquals(\"https://example.com\", dto.url)\n        assertEquals(\"Example Title\", dto.title)\n        assertEquals(\"\", dto.excerpt)\n        assertNull(dto.author)\n        assertEquals(1, dto.public)\n        assertNull(dto.createdAt)\n        assertNull(dto.modified)\n        assertNull(dto.imageURL)\n        assertNull(dto.hasContent)\n        assertNull(dto.hasArchive)\n        assertNull(dto.hasEbook)\n        assertEquals(2, dto.tags?.size)\n        assertEquals(\"education\", dto.tags?.get(0)?.name)\n        assertEquals(\"reading\", dto.tags?.get(1)?.name)\n        assertTrue(dto.createArchive == true)\n        assertTrue(dto.createEbook == true)\n    }\n\n    @Test\n    fun `toEditBookmarkDTO should map all fields correctly`() {\n        // Given\n        val tags = listOf(Tag(id = 1, name = \"education\"), Tag(id = 2, name = \"reading\"))\n        val bookmark = Bookmark(\n            id = 1,\n            url = \"https://example.com\",\n            title = \"Example Title\",\n            excerpt = \"An example excerpt\",\n            author = \"Author Name\",\n            public = 1,\n            createAt = \"2023-01-01T12:00:00\",\n            modified = \"2023-01-01T12:00:00\",\n            imageURL = \"https://example.com/image.jpg\",\n            hasContent = true,\n            hasArchive = false,\n            hasEbook = false,\n            tags = tags,\n            createArchive = true,\n            createEbook = false\n        )\n\n        // When\n        val dto = bookmark.toEditBookmarkDTO()\n\n        // Then\n        assertEquals(1, dto.id)\n        assertEquals(\"https://example.com\", dto.url)\n        assertEquals(\"Example Title\", dto.title)\n        assertEquals(\"An example excerpt\", dto.excerpt)\n        assertEquals(\"Author Name\", dto.author)\n        assertEquals(1, dto.public)\n        assertEquals(\"2023-01-01T12:00:00\", dto.createdAt)\n        assertEquals(\"2023-01-01T12:00:00\", dto.modified)\n        assertEquals(\"https://example.com/image.jpg\", dto.imageURL)\n        assertTrue(dto.hasContent == true)\n        assertFalse(dto.hasArchive == true)\n        assertFalse(dto.hasEbook == true)\n        assertEquals(2, dto.tags?.size)\n        assertEquals(\"education\", dto.tags?.get(0)?.name)\n        assertEquals(\"reading\", dto.tags?.get(1)?.name)\n        assertTrue(dto.createArchive == true)\n        assertFalse(dto.createEbook == true)\n    }\n\n    @Test\n    fun `toEditBookmarkJson should include all fields except hasEbook and createEbook`() {\n        // Given\n        val tags = listOf(TagDTO(id = 1, name = \"education\", nBookmarks = null), TagDTO(id = 2, name = \"reading\", nBookmarks = null))\n        val bookmarkDTO = BookmarkDTO(\n            id = 1,\n            url = \"https://example.com\",\n            title = \"Example Title\",\n            excerpt = \"An example excerpt\",\n            author = \"Author Name\",\n            public = 1,\n            createdAt = \"2023-01-01T12:00:00\",\n            modified = \"2023-01-01T12:00:00\",\n            imageURL = \"https://example.com/image.jpg\",\n            hasContent = true,\n            hasArchive = false,\n            hasEbook = true,\n            tags = tags,\n            createArchive = true,\n            createEbook = true\n        )\n\n        // When\n        val json = bookmarkDTO.toEditBookmarkJson()\n\n        // Then\n        assertTrue(json.contains(\"\\\"id\\\":1\"))\n        assertTrue(json.contains(\"\\\"url\\\":\\\"https://example.com\\\"\"))\n        assertTrue(json.contains(\"\\\"title\\\":\\\"Example Title\\\"\"))\n        assertTrue(json.contains(\"\\\"excerpt\\\":\\\"An example excerpt\\\"\"))\n        assertTrue(json.contains(\"\\\"author\\\":\\\"Author Name\\\"\"))\n        assertTrue(json.contains(\"\\\"public\\\":1\"))\n        assertTrue(json.contains(\"\\\"createdAt\\\":\\\"2023-01-01T12:00:00\\\"\"))\n        assertTrue(json.contains(\"\\\"modified\\\":\\\"2023-01-01T12:00:00\\\"\"))\n        assertTrue(json.contains(\"\\\"imageURL\\\":\\\"https://example.com/image.jpg\\\"\"))\n        assertTrue(json.contains(\"\\\"hasContent\\\":true\"))\n        assertTrue(json.contains(\"\\\"hasArchive\\\":false\"))\n        assertTrue(json.contains(\"\\\"tags\\\":[{\\\"name\\\":\\\"education\\\"},{\\\"name\\\":\\\"reading\\\"}]\"))\n        assertTrue(json.contains(\"\\\"create_archive\\\":true\"))\n        assertFalse(json.contains(\"\\\"hasEbook\\\":true\")) // Excluded in JSON\n        assertTrue(json.contains(\"\\\"create_archive\\\":true\"))\n    }\n\n}"
  },
  {
    "path": "data/src/test/java/com/desarrollodroide/data/repository/AuthRepositoryTest.kt",
    "content": "package com.desarrollodroide.data.repository\n\nimport com.desarrollodroide.common.result.ErrorHandler\nimport com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource\nimport com.desarrollodroide.model.Account\nimport com.desarrollodroide.model.User\nimport com.desarrollodroide.network.model.AccountDTO\nimport com.desarrollodroide.network.model.SessionDTO\nimport com.desarrollodroide.network.retrofit.RetrofitNetwork\nimport kotlinx.coroutines.ExperimentalCoroutinesApi\nimport kotlinx.coroutines.test.runTest\nimport org.junit.jupiter.api.BeforeEach\nimport org.junit.jupiter.api.Test\nimport org.mockito.Mock\nimport org.mockito.Mockito.`when`\nimport org.mockito.MockitoAnnotations\nimport kotlinx.coroutines.flow.flowOf\nimport kotlinx.coroutines.flow.toList\nimport org.junit.jupiter.api.Assertions.assertEquals\nimport org.junit.jupiter.api.Assertions.assertTrue\nimport org.mockito.Mockito.*\nimport retrofit2.Response\nimport org.mockito.kotlin.any\nimport org.mockito.kotlin.eq\nimport org.mockito.kotlin.check\nimport com.desarrollodroide.common.result.Result\nimport com.desarrollodroide.network.model.LoginResponseDTO\nimport com.desarrollodroide.network.model.LoginResponseMessageDTO\nimport okhttp3.MediaType.Companion.toMediaTypeOrNull\nimport okhttp3.ResponseBody.Companion.toResponseBody\nimport org.mockito.kotlin.anyOrNull\nimport java.io.IOException\n\n@ExperimentalCoroutinesApi\nclass AuthRepositoryImplTest {\n\n    @Mock\n    private lateinit var apiService: RetrofitNetwork\n\n    @Mock\n    private lateinit var settingsPreferenceDataSource: SettingsPreferenceDataSource\n\n    @Mock\n    private lateinit var errorHandler: ErrorHandler\n\n    private lateinit var authRepository: AuthRepositoryImpl\n\n    @BeforeEach\n    fun setup() {\n        MockitoAnnotations.openMocks(this)\n        authRepository = AuthRepositoryImpl(apiService, settingsPreferenceDataSource, errorHandler)\n    }\n\n    @Test\n    fun `sendLogin should emit Loading and Success states when API call is successful`() = runTest {\n        // Arrange\n        val username = \"testUser\"\n        val password = \"testPassword\"\n        val serverUrl = \"http://test.com\"\n        val sessionDTO = SessionDTO(\n            \"testSession\",\n            \"testToken\",\n            AccountDTO(1, username, isOwner = false)\n        )\n        val expectedUser =\n            User(\"testToken\", \"testSession\", Account(1, username, password, false, serverUrl))\n\n        `when`(apiService.sendLogin(anyString(), any())).thenReturn(Response.success(sessionDTO))\n        `when`(settingsPreferenceDataSource.getUser()).thenReturn(flowOf(expectedUser))\n\n        // Act\n        val results = authRepository.sendLogin(username, password, serverUrl).toList()\n\n        // Assert\n        assertEquals(3, results.size, \"Expected 3 emitted results\")\n        assertTrue(results[0] is Result.Loading && results[0].data == null)\n        assertTrue(results[1] is Result.Loading && results[1].data != null)\n        assertTrue(results[2] is Result.Success && results[2].data == expectedUser)\n\n        verify(settingsPreferenceDataSource).saveUser(any(), eq(serverUrl), eq(password))\n        verify(apiService).sendLogin(check { it.endsWith(\"/api/login\") }, any())\n    }\n\n    @Test\n    fun `sendLogin should emit Loading and Error states when API call fails`() = runTest {\n        // Arrange\n        val username = \"testUser\"\n        val password = \"testPassword\"\n        val serverUrl = \"http://test.com\"\n        val errorMessage = \"Invalid credentials\"\n        val errorResponseBody = errorMessage.toResponseBody(\"text/plain\".toMediaTypeOrNull())\n\n        `when`(apiService.sendLogin(anyString(), any())).thenReturn(Response.error(400, errorResponseBody))\n        `when`(errorHandler.getApiError(eq(400), anyOrNull(), eq(errorMessage))).thenReturn(Result.ErrorType.HttpError(statusCode = 400, message = errorMessage))\n        `when`(settingsPreferenceDataSource.getUser()).thenReturn(flowOf())  // Ensure a valid empty flow is returned\n\n        // Act\n        val results = authRepository.sendLogin(username, password, serverUrl).toList()\n\n        // Debugging: Print results\n        results.forEachIndexed { index, result ->\n            println(\"Result $index: $result\")\n            if (result is Result.Error) {\n                println(\"Result $index error: '${result.error?.message}'\")\n            } else if (result is Result.Loading) {\n                println(\"Result $index loading data: '${result.data}'\")\n            }\n        }\n\n        // Assert\n        assertEquals(3, results.size, \"Expected 3 emitted results\")\n        assertTrue(results[0] is Result.Loading && results[0].data == null)\n        assertTrue(results[1] is Result.Loading && results[1].data == null)\n        assertTrue(results[2] is Result.Error && (results[2] as Result.Error).error is Result.ErrorType.HttpError)\n        assertEquals((results[2] as Result.Error).error?.message, errorMessage)\n\n        verify(apiService).sendLogin(check { it.endsWith(\"/api/login\") }, any())\n    }\n\n    @Test\n    fun `sendLogin should emit Loading and Error states when network error occurs`() = runTest {\n        // Arrange\n        val username = \"testUser\"\n        val password = \"testPassword\"\n        val serverUrl = \"http://test.com\"\n        val networkErrorMessage = \"Network error\"\n        val ioException = IOException(networkErrorMessage)\n\n        `when`(apiService.sendLogin(anyString(), any())).thenAnswer { invocation ->\n            throw ioException\n        }\n\n        `when`(errorHandler.getError(ioException)).thenReturn(Result.ErrorType.IOError(ioException))\n        `when`(settingsPreferenceDataSource.getUser()).thenReturn(flowOf())  // Ensure a valid empty flow is returned\n\n        // Act\n        val results = authRepository.sendLogin(username, password, serverUrl).toList()\n\n        // Assert\n        assertEquals(3, results.size, \"Expected 3 emitted results\")\n        assertTrue(results[0] is Result.Loading && results[0].data == null)\n        assertTrue(results[1] is Result.Loading && results[1].data == null)\n        assertTrue(results[2] is Result.Error && (results[2] as Result.Error).error is Result.ErrorType.IOError)\n        assertEquals(networkErrorMessage, (results[2] as Result.Error).error?.throwable?.message)\n\n        verify(apiService).sendLogin(check { it.endsWith(\"/api/login\") }, any())\n    }\n\n    @Test\n    fun `sendLogin should not call saveUser when API call fails`() = runTest {\n        // Arrange\n        val username = \"testUser\"\n        val password = \"testPassword\"\n        val serverUrl = \"http://test.com\"\n        val errorMessage = \"Invalid credentials\"\n        val errorResponseBody = errorMessage.toResponseBody(\"text/plain\".toMediaTypeOrNull())\n\n        `when`(apiService.sendLogin(anyString(), any())).thenReturn(Response.error(400, errorResponseBody))\n        `when`(errorHandler.getApiError(eq(400), anyOrNull(), eq(errorMessage))).thenReturn(Result.ErrorType.HttpError(statusCode = 400, message = errorMessage))\n        `when`(settingsPreferenceDataSource.getUser()).thenReturn(flowOf())  // Ensure a valid empty flow is returned\n\n        // Act\n        val results = authRepository.sendLogin(username, password, serverUrl).toList()\n\n        // Assert\n        assertEquals(3, results.size, \"Expected 3 emitted results\")\n        assertTrue(results[0] is Result.Loading && results[0].data == null)\n        assertTrue(results[1] is Result.Loading && results[1].data == null)\n        assertTrue(results[2] is Result.Error && (results[2] as Result.Error).error is Result.ErrorType.HttpError)\n        assertEquals((results[2] as Result.Error).error?.message, errorMessage)\n\n        verify(apiService).sendLogin(check { it.endsWith(\"/api/login\") }, any())\n        verify(settingsPreferenceDataSource, never()).saveUser(any(), anyString(), anyString())\n    }\n\n    @Test\n    fun `sendLogout should emit Loading, Loading with data, and Success states when API call is successful`() = runTest {\n        // Arrange\n        val serverUrl = \"http://test.com\"\n        val xSession = \"testSession\"\n        val logoutResponse = \"Logout successful\" // La respuesta esperada del servidor\n\n        `when`(apiService.sendLogout(anyString(), anyString())).thenReturn(Response.success(logoutResponse))\n\n        // Act\n        val results = authRepository.sendLogout(serverUrl, xSession).toList()\n\n        // Assert\n        assertEquals(3, results.size, \"Expected 3 emitted results\")\n        assertTrue(results[0] is Result.Loading && results[0].data == null, \"First result should be Loading with null data\")\n        assertTrue(results[1] is Result.Loading && results[1].data == \"\", \"Second result should be Loading with empty data\")\n        assertTrue(results[2] is Result.Success && (results[2] as Result.Success).data == \"\") {\n            \"Expected third result to be Success with empty data after resetUser, but was '${(results[2] as Result.Success).data}'\"\n        }\n\n        verify(settingsPreferenceDataSource).resetData()\n        verify(apiService).sendLogout(check { it.endsWith(\"/api/logout\") }, eq(xSession))\n    }\n\n    @Test\n    fun `sendLogout should emit Loading and Error states when API call fails`() = runTest {\n        // Arrange\n        val serverUrl = \"http://test.com\"\n        val xSession = \"testSession\"\n        val errorMessage = \"Logout failed\"\n        val errorResponseBody = errorMessage.toResponseBody(\"text/plain\".toMediaTypeOrNull())\n\n        `when`(apiService.sendLogout(anyString(), anyString())).thenReturn(Response.error(400, errorResponseBody))\n        `when`(errorHandler.getApiError(eq(400), anyOrNull(), eq(errorMessage))).thenReturn(Result.ErrorType.HttpError(statusCode = 400, message = errorMessage))\n\n        // Act\n        val results = authRepository.sendLogout(serverUrl, xSession).toList()\n\n        // Assert\n        assertEquals(3, results.size, \"Expected 3 emitted results\")\n        assertTrue(results[0] is Result.Loading && results[0].data == null, \"First result should be Loading with null data\")\n        assertTrue(results[1] is Result.Loading && results[1].data == \"\", \"Second result should be Loading with empty string data\")\n        assertTrue(results[2] is Result.Error && (results[2] as Result.Error).error is Result.ErrorType.HttpError, \"Third result should be Error with HttpError type\")\n        assertEquals((results[2] as Result.Error).error?.message, errorMessage, \"Error message should match expected message\")\n\n        verify(apiService).sendLogout(check { it.endsWith(\"/api/logout\") }, eq(xSession))\n    }\n\n    @Test\n    fun `sendLogout should emit Loading and Error states when network error occurs`() = runTest {\n        // Arrange\n        val serverUrl = \"http://test.com\"\n        val xSession = \"testSession\"\n        val networkErrorMessage = \"Network error\"\n        val ioException = IOException(networkErrorMessage)\n\n        `when`(apiService.sendLogout(anyString(), anyString())).thenAnswer { invocation ->\n            throw ioException\n        }\n\n        `when`(errorHandler.getError(ioException)).thenReturn(Result.ErrorType.IOError(ioException))\n\n        // Act\n        val results = authRepository.sendLogout(serverUrl, xSession).toList()\n\n        // Assert\n        assertEquals(3, results.size, \"Expected 3 emitted results\")\n        assertTrue(results[0] is Result.Loading && results[0].data == null)\n        assertTrue(results[1] is Result.Loading && results[1].data == \"\")\n        assertTrue(results[2] is Result.Error && (results[2] as Result.Error).error is Result.ErrorType.IOError)\n        assertEquals(networkErrorMessage, (results[2] as Result.Error).error?.throwable?.message)\n\n        verify(apiService).sendLogout(check { it.endsWith(\"/api/logout\") }, eq(xSession))\n    }\n\n    @Test\n    fun `sendLoginV1 should emit Loading and Success states when API call is successful`() = runTest {\n        // Arrange\n        val username = \"testUser\"\n        val password = \"testPassword\"\n        val serverUrl = \"http://test.com\"\n        val loginResponseMessageDTO = LoginResponseMessageDTO(\n            expires = null,\n            session = null,\n            token = \"testToken\"\n        )\n        val loginResponseDTO = LoginResponseDTO(\n            ok = true,\n            message = loginResponseMessageDTO,\n            error = null\n        )\n        val expectedUser =\n            User(\"testToken\", \"testSession\", Account(1, username, password, false, serverUrl))\n\n        `when`(apiService.sendLoginV1(anyString(), any())).thenReturn(Response.success(loginResponseDTO))\n        `when`(settingsPreferenceDataSource.getUser()).thenReturn(flowOf(expectedUser))\n\n        // Act\n        val results = authRepository.sendLoginV1(username, password, serverUrl).toList()\n\n        // Assert\n        assertEquals(3, results.size, \"Expected 3 emitted results\")\n        assertTrue(results[0] is Result.Loading && results[0].data == null)\n        assertTrue(results[1] is Result.Loading && results[1].data != null)\n        assertTrue(results[2] is Result.Success && results[2].data == expectedUser)\n\n        verify(settingsPreferenceDataSource).saveUser(any(), eq(serverUrl), eq(password))\n        verify(apiService).sendLoginV1(check { it.endsWith(\"/api/v1/auth/login\") }, any())\n    }\n}\n\n"
  },
  {
    "path": "data/src/test/java/com/desarrollodroide/data/repository/BookmarksRepositoryTest.kt",
    "content": "package com.desarrollodroide.data.repository\n\nimport androidx.paging.Pager\nimport androidx.paging.PagingConfig\nimport androidx.paging.PagingSource\nimport androidx.paging.map\nimport com.desarrollodroide.common.result.ErrorHandler\nimport com.desarrollodroide.network.retrofit.RetrofitNetwork\nimport kotlinx.coroutines.ExperimentalCoroutinesApi\nimport kotlinx.coroutines.test.runTest\nimport org.junit.jupiter.api.BeforeEach\nimport org.junit.jupiter.api.Test\nimport org.mockito.Mock\nimport org.mockito.Mockito.`when`\nimport org.mockito.MockitoAnnotations\nimport kotlinx.coroutines.flow.flowOf\nimport kotlinx.coroutines.flow.toList\nimport org.junit.jupiter.api.Assertions.assertEquals\nimport org.junit.jupiter.api.Assertions.assertTrue\nimport org.mockito.Mockito.*\nimport retrofit2.Response\nimport org.mockito.kotlin.eq\nimport org.mockito.kotlin.check\nimport com.desarrollodroide.common.result.Result\nimport com.desarrollodroide.data.local.room.dao.BookmarksDao\nimport com.desarrollodroide.data.local.room.entity.BookmarkEntity\nimport com.desarrollodroide.data.mapper.toDomainModel\nimport com.desarrollodroide.data.repository.paging.BookmarkPagingSource\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.model.Tag\nimport com.desarrollodroide.network.model.BookmarkDTO\nimport com.desarrollodroide.network.model.BookmarksDTO\nimport kotlinx.coroutines.flow.collectLatest\nimport kotlinx.coroutines.flow.first\nimport okhttp3.MediaType.Companion.toMediaTypeOrNull\nimport okhttp3.ResponseBody.Companion.toResponseBody\nimport org.mockito.kotlin.anyOrNull\nimport java.io.IOException\n\n@ExperimentalCoroutinesApi\nclass BookmarksRepositoryTest {\n\n    @Mock\n    private lateinit var apiService: RetrofitNetwork\n\n    @Mock\n    private lateinit var bookmarksDao: BookmarksDao\n\n    @Mock\n    private lateinit var errorHandler: ErrorHandler\n\n    private lateinit var bookmarksRepository: BookmarksRepositoryImpl\n\n    @BeforeEach\n    fun setup() {\n        MockitoAnnotations.openMocks(this)\n        bookmarksRepository = BookmarksRepositoryImpl(apiService, bookmarksDao, errorHandler)\n    }\n\n    @Test\n    fun `getBookmarks should emit Loading and Success states when API call is successful`() = runTest {\n        // Arrange\n        val xSessionId = \"testSessionId\"\n        val serverUrl = \"http://test.com\"\n        val bookmarksDTO = BookmarksDTO(\n            maxPage = 1,\n            page = 1,\n            bookmarks = listOf(\n                BookmarkDTO(1, \"http://bookmark1.com\", \"Bookmark 1\", \"Excerpt 1\", \"Author 1\", 1, \"2023-01-01\",\"2023-01-02\",  \"http://image1.com\", true, true, true, listOf(), true, true),\n                BookmarkDTO(2, \"http://bookmark2.com\", \"Bookmark 2\", \"Excerpt 2\", \"Author 2\", 1, \"2023-01-02\", \"2023-01-02\",\"http://image2.com\", true, true, true, listOf(), true, true)\n            )\n        )\n        val bookmarkEntities = listOf(\n            BookmarkEntity(1, \"http://bookmark1.com\", \"Bookmark 1\", \"Excerpt 1\", \"Author 1\", 1, \"2023-01-01\", \"2023-01-02\",\"http://image1.com\", true, true, true, listOf(), true, true),\n            BookmarkEntity(2, \"http://bookmark2.com\", \"Bookmark 2\", \"Excerpt 2\", \"Author 2\", 1, \"2023-01-02\", \"2023-01-02\",\"http://image2.com\", true, true, true, listOf(), true, true)\n        )\n        val expectedBookmarks = bookmarkEntities.map { it.toDomainModel() }\n\n        `when`(apiService.getBookmarks(eq(xSessionId), anyString())).thenReturn(Response.success(bookmarksDTO))\n        `when`(bookmarksDao.getAll()).thenReturn(flowOf(bookmarkEntities))\n\n        // Act\n        val results = bookmarksRepository.getBookmarks(xSessionId, serverUrl).toList()\n\n        // Assert\n        assertEquals(3, results.size, \"Expected 3 emitted results\")\n        assertTrue(results[0] is Result.Loading && results[0].data == null)\n        assertTrue(results[1] is Result.Loading && results[1].data != null)\n        assertTrue(results[2] is Result.Success && results[2].data == expectedBookmarks)\n\n        verify(bookmarksDao).deleteAll()\n        verify(bookmarksDao).insertAll(bookmarkEntities)\n        verify(apiService).getBookmarks(eq(xSessionId), check { it.endsWith(\"/api/bookmarks\") })\n    }\n\n    @Test\n    fun `getBookmarks should emit Loading and Error states when API call fails`() = runTest {\n        // Arrange\n        val xSessionId = \"testSessionId\"\n        val serverUrl = \"http://test.com\"\n        val errorMessage = \"Error fetching bookmarks\"\n        val errorResponseBody = errorMessage.toResponseBody(\"text/plain\".toMediaTypeOrNull())\n\n        `when`(apiService.getBookmarks(eq(xSessionId), anyString())).thenReturn(Response.error(400, errorResponseBody))\n        `when`(errorHandler.getApiError(eq(400), anyOrNull(), eq(errorMessage))).thenReturn(Result.ErrorType.HttpError(statusCode = 400, message = errorMessage))\n        `when`(bookmarksDao.getAll()).thenReturn(flowOf(emptyList()))  // Ensure a valid empty flow is returned\n\n        // Act\n        val results = bookmarksRepository.getBookmarks(xSessionId, serverUrl).toList()\n\n        // Assert\n        assertEquals(3, results.size, \"Expected 3 emitted results\")\n        assertTrue(results[0] is Result.Loading && results[0].data == null)\n        assertTrue(results[1] is Result.Loading && results[1].data == emptyList<Bookmark>())\n        assertTrue(results[2] is Result.Error && (results[2] as Result.Error).error is Result.ErrorType.HttpError)\n        assertEquals((results[2] as Result.Error).error?.message, errorMessage)\n\n        verify(apiService).getBookmarks(eq(xSessionId), check { it.endsWith(\"/api/bookmarks\") })\n    }\n\n    @Test\n    fun `getBookmarks should emit Loading and Error states when network error occurs`() = runTest {\n        // Arrange\n        val xSessionId = \"testSessionId\"\n        val serverUrl = \"http://test.com\"\n        val networkErrorMessage = \"Network error\"\n        val ioException = IOException(networkErrorMessage)\n\n        `when`(apiService.getBookmarks(eq(xSessionId), anyString())).thenAnswer { throw ioException }\n        `when`(errorHandler.getError(ioException)).thenReturn(Result.ErrorType.IOError(ioException))\n        `when`(bookmarksDao.getAll()).thenReturn(flowOf(emptyList()))  // Ensure a valid empty flow is returned\n\n        // Act\n        val results = bookmarksRepository.getBookmarks(xSessionId, serverUrl).toList()\n\n        // Assert\n        assertEquals(3, results.size, \"Expected 3 emitted results\")\n        assertTrue(results[0] is Result.Loading && results[0].data == null)\n        assertTrue(results[1] is Result.Loading && results[1].data == emptyList<Bookmark>())\n        assertTrue(results[2] is Result.Error && (results[2] as Result.Error).error is Result.ErrorType.IOError)\n        assertEquals(networkErrorMessage, (results[2] as Result.Error).error?.throwable?.message)\n\n        verify(apiService).getBookmarks(eq(xSessionId), check { it.endsWith(\"/api/bookmarks\") })\n    }\n\n    @Test\n    fun `getBookmarks should emit Loading and Error states when API call fails with HTTP error`() = runTest {\n        // Arrange\n        val xSessionId = \"testSessionId\"\n        val serverUrl = \"http://test.com\"\n        val errorMessage = \"HTTP error\"\n        val errorResponseBody = errorMessage.toResponseBody(\"text/plain\".toMediaTypeOrNull())\n\n        `when`(apiService.getBookmarks(eq(xSessionId), anyString())).thenReturn(Response.error(400, errorResponseBody))\n        `when`(errorHandler.getApiError(eq(400), anyOrNull(), eq(errorMessage))).thenReturn(Result.ErrorType.HttpError(statusCode = 400, message = errorMessage))\n        `when`(bookmarksDao.getAll()).thenReturn(flowOf(emptyList()))  // Ensure a valid empty flow is returned\n\n        // Act\n        val results = bookmarksRepository.getBookmarks(xSessionId, serverUrl).toList()\n\n        // Assert\n        assertEquals(3, results.size, \"Expected 3 emitted results\")\n        assertTrue(results[0] is Result.Loading && results[0].data == null)\n        assertTrue(results[1] is Result.Loading && results[1].data == emptyList<Bookmark>())\n        assertTrue(results[2] is Result.Error && (results[2] as Result.Error).error is Result.ErrorType.HttpError)\n        assertEquals((results[2] as Result.Error).error?.message, errorMessage)\n\n        verify(apiService).getBookmarks(eq(xSessionId), check { it.endsWith(\"/api/bookmarks\") })\n    }\n\n    @Test\n    fun `getPagingBookmarks should return paginated data when API call is successful`() = runTest {\n        // Arrange\n        val xSessionId = \"testSessionId\"\n        val serverUrl = \"http://test.com\"\n        val searchText = \"test\"\n        val tags = listOf<Tag>()\n        val saveToLocal = true\n        val bookmarksDTO = BookmarksDTO(\n            maxPage = 1,\n            page = 1,\n            bookmarks = listOf(\n                BookmarkDTO(1, \"http://bookmark1.com\", \"Bookmark 1\", \"Excerpt 1\", \"Author 1\", 1, \"2023-01-01\", \"\", \"http://image1.com\", true, true, true, listOf(), true, true),\n                BookmarkDTO(2, \"http://bookmark2.com\", \"Bookmark 2\", \"Excerpt 2\", \"Author 2\", 1, \"2023-01-02\", \"\", \"http://image2.com\", true, true, true, listOf(), true, true)\n            )\n        )\n        val expectedBookmarks = bookmarksDTO.bookmarks?.map { it.toDomainModel() }\n\n        `when`(apiService.getPagingBookmarks(eq(xSessionId), anyString())).thenReturn(Response.success(bookmarksDTO))\n        `when`(bookmarksDao.getAll()).thenReturn(flowOf(emptyList()))\n\n        // Act\n        val pagingSource = BookmarkPagingSource(\n            remoteDataSource = apiService,\n            bookmarksDao = bookmarksDao,\n            serverUrl = serverUrl,\n            xSessionId = xSessionId,\n            searchText = searchText,\n            tags = tags,\n            saveToLocal = saveToLocal\n        )\n\n        val loadResult = pagingSource.load(\n            PagingSource.LoadParams.Refresh(\n                key = null,\n                loadSize = 20,\n                placeholdersEnabled = false\n            )\n        )\n\n        // Assert\n        assertTrue(loadResult is PagingSource.LoadResult.Page)\n        loadResult as PagingSource.LoadResult.Page\n        assertEquals(expectedBookmarks, loadResult.data)\n    }\n}"
  },
  {
    "path": "domain/.gitignore",
    "content": "/build"
  },
  {
    "path": "domain/build.gradle.kts",
    "content": "plugins {\n    id(\"com.android.library\")\n    id (\"org.jetbrains.kotlin.android\")\n}\n\nandroid {\n    namespace = \"com.desarrollodroide.domain\"\n    compileSdk = (findProperty(\"compileSdkVersion\") as String).toInt()\n\n    defaultConfig {\n        minSdk = (findProperty(\"minSdkVersion\") as String).toInt()\n        targetSdk = (findProperty(\"targetSdkVersion\") as String).toInt()\n\n        testInstrumentationRunner = \"androidx.test.runner.AndroidJUnitRunner\"\n        vectorDrawables {\n            useSupportLibrary = true\n        }\n    }\n\n    buildTypes {\n        release {\n            isMinifyEnabled = false\n            proguardFiles(\n                getDefaultProguardFile(\"proguard-android-optimize.txt\"),\n                \"proguard-rules.pro\"\n            )\n        }\n    }\n    compileOptions {\n        sourceCompatibility = JavaVersion.VERSION_21\n        targetCompatibility = JavaVersion.VERSION_21\n    }\n    kotlinOptions {\n        jvmTarget = \"21\"\n    }\n}\n\n\ndependencies {\n\n    implementation(project(\":data\"))\n    implementation(project(\":model\"))\n    implementation(project(\":common\"))\n\n    // coroutines\n    implementation (libs.kotlinx.coroutines.android)\n    implementation (libs.androidx.paging.compose)\n    testImplementation (libs.kotlinx.coroutines.android)\n    testImplementation (libs.kotlin.coroutines.test)\n}\n\njava {\n    toolchain {\n        languageVersion = JavaLanguageVersion.of(21)\n    }\n}\n"
  },
  {
    "path": "domain/consumer-rules.pro",
    "content": ""
  },
  {
    "path": "domain/proguard-rules.pro",
    "content": "# Add project specific ProGuard rules here.\n# You can control the set of applied configuration files using the\n# proguardFiles setting in build.gradle.kts.\n#\n# For more details, see\n#   http://developer.android.com/guide/developing/tools/proguard.html\n\n# If your project uses WebView with JS, uncomment the following\n# and specify the fully qualified class name to the JavaScript interface\n# class:\n#-keepclassmembers class fqcn.of.javascript.interface.for.webview {\n#   public *;\n#}\n\n# Uncomment this to preserve the line number information for\n# debugging stack traces.\n#-keepattributes SourceFile,LineNumberTable\n\n# If you keep the line number information, uncomment this to\n# hide the original source file name.\n#-renamesourcefileattribute SourceFile"
  },
  {
    "path": "domain/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n</manifest>"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/AddBookmarkUseCase.kt",
    "content": "package com.desarrollodroide.domain.usecase\n\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.data.local.room.dao.BookmarksDao\nimport com.desarrollodroide.data.mapper.toEntityModel\nimport com.desarrollodroide.data.repository.SyncWorks\nimport com.desarrollodroide.model.SyncOperationType\n\nclass AddBookmarkUseCase(\n    private val bookmarksDao: BookmarksDao,\n    private val syncManager: SyncWorks,\n) {\n    suspend operator fun invoke(\n        bookmark: Bookmark\n    ) {\n        // Insert the bookmark locally with a timestamp as a temporary ID\n        val timestampId = (System.currentTimeMillis() / 1000).toInt()\n        val bookmarkWithTempId = bookmark.copy(id = timestampId)\n        bookmarksDao.insertBookmark(bookmarkWithTempId.toEntityModel())\n        // Schedule the sync work and wait for it to complete\n        syncManager.scheduleSyncWork(SyncOperationType.CREATE, bookmark)\n    }\n}"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/DeleteBookmarkUseCase.kt",
    "content": "package com.desarrollodroide.domain.usecase\n\nimport com.desarrollodroide.data.local.room.dao.BookmarksDao\nimport com.desarrollodroide.data.repository.SyncWorks\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.model.SyncOperationType\n\nclass DeleteBookmarkUseCase(\n    private val bookmarksDao: BookmarksDao,\n    private val syncManager: SyncWorks\n) {\n    suspend operator fun invoke(bookmark: Bookmark) {\n        bookmarksDao.deleteBookmarkById(bookmark.id)\n        syncManager.scheduleSyncWork(SyncOperationType.DELETE, bookmark)\n    }\n}"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/DeleteLocalBookmarkUseCase.kt",
    "content": "package com.desarrollodroide.domain.usecase\n\nimport com.desarrollodroide.data.local.room.dao.BookmarksDao\nimport com.desarrollodroide.model.Bookmark\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.flow\nimport kotlinx.coroutines.flow.flowOn\nimport com.desarrollodroide.common.result.Result\n\nclass DeleteLocalBookmarkUseCase(\n    private val bookmarksDao: BookmarksDao\n) {\n    operator fun invoke(bookmark: Bookmark): Flow<Result<Int>> = flow {\n        emit(Result.Loading())\n        try {\n            val result = bookmarksDao.deleteBookmarkById(bookmark.id)\n            if (result > 0) {\n                emit(Result.Success(result))\n            } else {\n                emit(Result.Error(Result.ErrorType.DatabaseError(BookmarkNotFoundException())))\n            }\n        } catch (e: Exception) {\n            emit(Result.Error(Result.ErrorType.DatabaseError(e)))\n        }\n    }.flowOn(Dispatchers.IO)\n}\n\nclass BookmarkNotFoundException : Exception(\"Bookmark not found in local database\")"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/DownloadFileUseCase.kt",
    "content": "package com.desarrollodroide.domain.usecase\n\nimport com.desarrollodroide.data.repository.FileRepository\nimport java.io.File\n\nclass DownloadFileUseCase(\n    private val fileRepository: FileRepository\n) {\n    suspend fun execute(\n        url: String,\n        fileName: String,\n        sessionId: String,\n    ): File {\n        return fileRepository.downloadFile(\n            url = url,\n            fileName = fileName,\n            sessionId = sessionId,\n        )\n    }\n}"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/EditBookmarkUseCase.kt",
    "content": "package com.desarrollodroide.domain.usecase\n\nimport android.os.Build\nimport androidx.annotation.RequiresApi\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.data.local.room.dao.BookmarksDao\nimport com.desarrollodroide.data.local.room.dao.TagDao\nimport com.desarrollodroide.data.mapper.toEntityModel\nimport com.desarrollodroide.data.repository.SyncWorks\nimport com.desarrollodroide.model.SyncOperationType\nimport java.time.LocalDateTime\nimport java.time.format.DateTimeFormatter\n\nclass EditBookmarkUseCase(\n    private val bookmarksDao: BookmarksDao,\n    private val tagsDao: TagDao,\n    private val syncManager: SyncWorks\n) {\n    @RequiresApi(Build.VERSION_CODES.O)\n    suspend operator fun invoke(\n        bookmark: Bookmark\n    ) {\n        val updatedBookmark = bookmark.copy(\n            modified = LocalDateTime.now().format(DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm:ss\"))\n        )\n        updatedBookmark.tags.forEach { tag ->\n            tagsDao.insertTag(tag.toEntityModel())\n        }\n        bookmarksDao.updateBookmarkWithTags(updatedBookmark.toEntityModel())\n        syncManager.scheduleSyncWork(SyncOperationType.UPDATE, updatedBookmark)\n    }\n}"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/GetAllRemoteBookmarksUseCase.kt",
    "content": "package com.desarrollodroide.domain.usecase\n\nimport android.annotation.SuppressLint\nimport android.util.Log\nimport com.desarrollodroide.data.repository.BookmarksRepository\nimport com.desarrollodroide.data.repository.SyncStatus\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.map\nimport kotlinx.coroutines.flow.flowOn\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.catch\n\nclass GetAllRemoteBookmarksUseCase(\n    private val bookmarksRepository: BookmarksRepository,\n) {\n    private val TAG = \"SyncInitialBookmarksUseCase\"\n\n    @SuppressLint(\"LongLogTag\")\n    suspend operator fun invoke(\n        serverUrl: String,\n        xSession: String,\n    ): Flow<Result<SyncStatus>> {\n        Log.d(TAG, \"Invoking sync use case\")\n        return bookmarksRepository.syncAllBookmarks(\n            xSession = xSession,\n            serverUrl = serverUrl\n        )\n            .map { status ->\n                Log.d(TAG, \"Mapping sync status: $status\")\n                Result.success(status)\n            }\n            .catch { e ->\n                Log.e(TAG, \"Error caught in use case\", e)\n                emit(Result.failure(e))\n            }\n            .flowOn(Dispatchers.IO)\n    }\n}\n\n\n"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/GetBookmarkReadableContentUseCase.kt",
    "content": "package com.desarrollodroide.domain.usecase\n\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.flowOn\nimport com.desarrollodroide.common.result.Result\nimport com.desarrollodroide.data.repository.BookmarksRepository\nimport com.desarrollodroide.model.ReadableContent\n\nclass GetBookmarkReadableContentUseCase(\n    private val bookmarksRepository: BookmarksRepository\n) {\n    operator fun invoke(\n        serverUrl: String,\n        token: String,\n        bookmarkId: Int\n    ): Flow<Result<ReadableContent>> {\n        return bookmarksRepository.getBookmarkReadableContent(\n            token = token,\n            serverUrl = serverUrl,\n            bookmarkId = bookmarkId\n        ).flowOn(Dispatchers.IO)\n    }\n}"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/GetBookmarkUseCase.kt",
    "content": "package com.desarrollodroide.domain.usecase\n\nimport com.desarrollodroide.data.repository.BookmarksRepository\nimport com.desarrollodroide.model.Bookmark\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.flowOn\nimport com.desarrollodroide.common.result.Result\n\nclass GetBookmarkByIdUseCase(\n    private val bookmarksRepository: BookmarksRepository,\n) {\n    operator fun invoke(\n        serverUrl: String,\n        token: String,\n        bookmarkId: Int\n    ): Flow<Result<Bookmark?>> {\n        return bookmarksRepository.getBookmarkById(\n            token = token,\n            serverUrl = serverUrl,\n            bookmarkId = bookmarkId\n        ).flowOn(Dispatchers.IO)\n    }\n}"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/GetBookmarksUseCase.kt",
    "content": "package com.desarrollodroide.domain.usecase\n\nimport com.desarrollodroide.data.repository.BookmarksRepository\nimport com.desarrollodroide.model.Bookmark\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.flowOn\nimport com.desarrollodroide.common.result.Result\n\nclass GetBookmarksUseCase(\n    private val bookmarksRepository: BookmarksRepository,\n) {\n    operator fun invoke(\n        serverUrl: String,\n        xSession: String,\n    ): Flow<Result<List<Bookmark>?>> {\n        return bookmarksRepository.getBookmarks(\n            xSession = xSession,\n            serverUrl = serverUrl\n        ).flowOn(Dispatchers.IO)\n    }\n}"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/GetLocalPagingBookmarksUseCase.kt",
    "content": "package com.desarrollodroide.domain.usecase\n\nimport androidx.paging.PagingData\nimport androidx.paging.filter\nimport com.desarrollodroide.data.repository.BookmarksRepository\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.model.Tag\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.map\n\nclass GetLocalPagingBookmarksUseCase(\n    private val bookmarksRepository: BookmarksRepository,\n) {\n    operator fun invoke(\n        serverUrl: String,\n        xSession: String,\n        searchText: String = \"\",\n        tags: List<Tag>,\n        showOnlyHiddenTag: Boolean = false,\n        tagToHide: Tag? = null\n    ): Flow<PagingData<Bookmark>> {\n        return bookmarksRepository.getLocalPagingBookmarks(tags, searchText)\n            .map { pagingData ->\n                pagingData.filter { bookmark ->\n                    when {\n                        showOnlyHiddenTag -> tagToHide?.let { bookmark.tags.any { tag -> tag.id == it.id } } ?: false\n                        else -> {\n                            if (tags.isEmpty()) {\n                                !bookmark.tags.any { it.id == tagToHide?.id }\n                            } else {\n                                bookmark.tags.any { tags.any { t -> t.id == it.id } }\n                            }\n                        }\n                    }\n                }\n            }\n    }\n}\n\n"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/GetTagsUseCase.kt",
    "content": "package com.desarrollodroide.domain.usecase\n\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.flowOn\nimport com.desarrollodroide.common.result.Result\nimport com.desarrollodroide.data.repository.TagsRepository\nimport com.desarrollodroide.model.Tag\n\nclass GetTagsUseCase(\n    private val tagsRepository: TagsRepository\n) {\n    operator fun invoke(\n        serverUrl: String,\n        token: String,\n    ): Flow<Result<List<Tag>?>> {\n        return tagsRepository.getTags(\n            token = token,\n            serverUrl = serverUrl\n        ).flowOn(Dispatchers.IO)\n    }\n\n    fun getLocalTags(): Flow<List<Tag>> {\n        return tagsRepository.getLocalTags()\n            .flowOn(Dispatchers.IO)\n    }\n}"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/SendLoginUseCase.kt",
    "content": "package com.desarrollodroide.domain.usecase\n\nimport com.desarrollodroide.data.repository.AuthRepository\nimport com.desarrollodroide.model.User\nimport kotlinx.coroutines.flow.Flow\nimport com.desarrollodroide.common.result.Result\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.flowOn\n\nclass SendLoginUseCase(\n    private val authRepository: AuthRepository,\n) {\n    operator fun invoke(\n        username: String,\n        password: String,\n        serverUrl: String,\n    ): Flow<Result<User?>> {\n        return authRepository.sendLoginV1(username, password, serverUrl).flowOn(Dispatchers.IO)\n    }\n}"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/SendLogoutUseCase.kt",
    "content": "package com.desarrollodroide.domain.usecase\n\nimport com.desarrollodroide.data.repository.AuthRepository\nimport kotlinx.coroutines.flow.Flow\nimport com.desarrollodroide.common.result.Result\nimport com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource\nimport com.desarrollodroide.data.repository.BookmarksRepository\nimport com.desarrollodroide.data.repository.SyncWorks\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.flow\nimport kotlinx.coroutines.flow.flowOn\n\nclass SendLogoutUseCase(\n    private val authRepository: AuthRepository,\n    private val syncManager: SyncWorks,\n    private val settingsPreferenceDataSource: SettingsPreferenceDataSource,\n    private val bookmarksRepository: BookmarksRepository\n) {\n    operator fun invoke(\n        serverUrl: String,\n        xSession: String,\n    ): Flow<Result<String?>> = flow {\n        authRepository.sendLogout(\n            serverUrl = serverUrl,\n            xSession = xSession\n        ).collect { result ->\n            when (result) {\n                is Result.Success -> {\n                    performCleanup()\n                    emit(Result.Success(result.data))\n                }\n                is Result.Error -> {\n                    performCleanup()\n                    emit(Result.Error(result.error, result.data))\n                }\n                is Result.Loading -> {\n                    emit(result)\n                }\n            }\n        }\n    }.flowOn(Dispatchers.IO)\n\n    private suspend fun performCleanup() {\n        syncManager.cancelAllSyncWorkers()\n        settingsPreferenceDataSource.resetData()\n        bookmarksRepository.deleteAllLocalBookmarks()\n    }\n}\n"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/SuspendUseCase.kt",
    "content": "package com.desarrollodroide.domain.usecase\n\ninterface SuspendUseCase<in Params, out T> {\n    fun execute(params: Params) : T\n}"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/SyncBookmarksUseCase.kt",
    "content": "package com.desarrollodroide.domain.usecase\n\nimport android.os.Build\nimport android.util.Log\nimport androidx.annotation.RequiresApi\nimport com.desarrollodroide.data.repository.BookmarksRepository\nimport com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource\nimport com.desarrollodroide.model.SyncBookmarksRequestPayload\nimport com.desarrollodroide.model.SyncBookmarksResponse\nimport kotlinx.coroutines.flow.Flow\nimport com.desarrollodroide.common.result.Result\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.flowOn\nimport com.desarrollodroide.data.local.room.dao.BookmarksDao\nimport kotlinx.coroutines.flow.collect\nimport kotlinx.coroutines.launch\nimport kotlinx.coroutines.CoroutineScope\nimport com.desarrollodroide.data.mapper.toEntityModel\nimport java.time.ZoneId\nimport java.time.ZonedDateTime\n\nclass SyncBookmarksUseCase(\n    private val bookmarksRepository: BookmarksRepository,\n    private val bookmarkDatabase: BookmarksDao,\n    private val settingsPreferenceDataSource: SettingsPreferenceDataSource,\n    ) {\n    operator fun invoke(\n        token: String,\n        serverUrl: String,\n        syncBookmarksRequestPayload: SyncBookmarksRequestPayload\n    ): Flow<Result<SyncBookmarksResponse>> {\n        return bookmarksRepository.syncBookmarks(\n            token = token,\n            serverUrl = serverUrl,\n            syncBookmarksRequestPayload = syncBookmarksRequestPayload\n        ).flowOn(Dispatchers.IO)\n    }\n\n    @RequiresApi(Build.VERSION_CODES.O)\n    fun handleSuccessfulSync(\n        syncResponse: SyncBookmarksResponse,\n        currentLastSync: Long\n    ) {\n        CoroutineScope(Dispatchers.IO).launch {\n            try {\n                // Handle deleted bookmarks\n                syncResponse.deleted.forEach { id ->\n                    bookmarkDatabase.deleteBookmarkById(id)\n                }\n\n                // Handle new and modified bookmarks\n                val bookmarkEntities = syncResponse.modified.bookmarks.map { remoteBookmark ->\n                    val localBookmark = bookmarkDatabase.getBookmarkById(remoteBookmark.id)\n                    Log.d(\"TAG\", \"Processing bookmark ID: ${remoteBookmark.id}, Local Modified: ${localBookmark?.modified}, Remote Modified: ${remoteBookmark.modified}\")\n                    remoteBookmark.toEntityModel()\n                }\n\n                if (bookmarkEntities.isNotEmpty()) {\n                    bookmarkEntities.forEach { bookmark ->\n                        val existingBookmark = bookmarkDatabase.getBookmarkById(bookmark.id)\n                        if (existingBookmark == null) {\n                            // New bookmark, insert it\n                            bookmarkDatabase.insertBookmark(bookmark)\n                        } else {\n                            // Existing bookmark, update it\n                            bookmarkDatabase.updateBookmarkWithTags(bookmark)\n                        }\n                    }\n                }\n\n                // Check if there are more pages to sync\n                val currentPage = syncResponse.modified.page\n                val maxPage = syncResponse.modified.maxPage\n\n                if (currentPage < maxPage) {\n                    // Get updated list of all local bookmark IDs for the next page\n                    val updatedLocalBookmarkIds = bookmarkDatabase.getAllBookmarkIds()\n                    invoke(\n                        token = settingsPreferenceDataSource.getToken(),\n                        serverUrl = settingsPreferenceDataSource.getUrl(),\n                        syncBookmarksRequestPayload = SyncBookmarksRequestPayload(\n                            ids = updatedLocalBookmarkIds,\n                            last_sync = currentLastSync,\n                            page = currentPage + 1\n                        )\n                    ).collect { result ->\n                        if (result is Result.Success) {\n                            result.data?.let {\n                                handleSuccessfulSync(it, currentLastSync)\n                            }\n                        }\n                    }\n                } else {\n                    // Sync is complete, update last sync timestamp\n                    val newLastSync = ZonedDateTime.now(ZoneId.systemDefault()).toEpochSecond() // Convert to seconds\n                    settingsPreferenceDataSource.setLastSyncTimestamp(newLastSync)\n                }\n            } catch (e: Exception) {\n                Log.e(\"SyncBookmarksUseCase\", \"Error handling sync response: ${e.message}\")\n            }\n        }\n    }\n}"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/SystemLivenessUseCase.kt",
    "content": "package com.desarrollodroide.domain.usecase\n\nimport kotlinx.coroutines.flow.Flow\nimport com.desarrollodroide.common.result.Result\nimport com.desarrollodroide.data.repository.SystemRepository\nimport com.desarrollodroide.model.LivenessResponse\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.flowOn\n\nclass SystemLivenessUseCase(\n    private val systemRepository: SystemRepository,\n) {\n    operator fun invoke(\n        serverUrl: String\n    ): Flow<Result<LivenessResponse?>> {\n        return systemRepository.liveness(serverUrl).flowOn(Dispatchers.IO)\n    }\n}"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/UpdateBookmarkCacheUseCase.kt",
    "content": "package com.desarrollodroide.domain.usecase\n\nimport android.os.Build\nimport androidx.annotation.RequiresApi\nimport com.desarrollodroide.data.local.room.dao.BookmarksDao\nimport com.desarrollodroide.data.mapper.toEntityModel\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.data.repository.SyncWorks\nimport com.desarrollodroide.model.SyncOperationType\nimport com.desarrollodroide.model.UpdateCachePayload\nimport java.time.LocalDateTime\nimport java.time.format.DateTimeFormatter\n\nclass UpdateBookmarkCacheUseCase(\n    private val bookmarksDao: BookmarksDao,\n    private val syncManager: SyncWorks\n) {\n    @RequiresApi(Build.VERSION_CODES.O)\n    suspend operator fun invoke(\n        updateCachePayload: UpdateCachePayload,\n        bookmark: Bookmark\n    ) {\n        val updatedBookmark = bookmark.copy(\n            modified = LocalDateTime.now().format(DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm:ss\"))\n        )\n        bookmarksDao.updateBookmark(updatedBookmark.toEntityModel())\n        syncManager.scheduleSyncWork(\n            operationType = SyncOperationType.CACHE,\n            bookmark = updatedBookmark,\n            updateCachePayload = updateCachePayload\n        )\n    }\n}"
  },
  {
    "path": "fastlane/metadata/android/de/full_description.txt",
    "content": "Entdecken Sie mit <b>Pagekeeper</b> eine neue Möglichkeit, Ihre Lieblingswebseiten zu speichern, zu organisieren und darauf zuzugreifen. Unsere App basiert auf der renommierten <b>Shiori</b>-Plattform und bringt die Lesezeichenverwaltung auf die nächste Ebene.\n\nShiori ist eine innovative Anwendung zur Verwaltung von Lesezeichen, die die Art und Weise revolutioniert, wie Benutzer ihre Lieblingswebseiten speichern, organisieren und darauf zugreifen. Basierend auf der robusten Shiori-Plattform bietet Shiori ein nahtloses Erlebnis auf allen Geräten.\n\n<b>Hauptmerkmale von Pagekeeper:</b>\n\n* <b>Seiten einfach speichern</b>: Erfassen Sie Webseiten, die Sie sofort entdecken, und greifen Sie jederzeit darauf zu, auch offline.\n* <b>Überlegene Organisation</b>: Sortieren Sie Ihre Lesezeichen mit benutzerdefinierten Beschriftungen, Beschreibungen und Miniaturansichten zum schnellen Abrufen.\n* <b>Cloud-Synchronisierung</b>: Halten Sie Ihre Lesezeichen auf allen Ihren Geräten synchronisiert, damit Sie nie eine wichtige Seite verlieren.\n* <b>Intuitive Benutzeroberfläche</b>: Navigieren Sie durch Ihre Lesezeichen mit einer übersichtlichen und benutzerfreundlichen Oberfläche, die für ein nahtloses Benutzererlebnis konzipiert ist.\n* <b>Teilen und Entdecken</b>: Teilen Sie Ihre Lieblingsseiten mit Freunden und entdecken Sie neue Seiten über die Pagekeeper-Community.\n"
  },
  {
    "path": "fastlane/metadata/android/de/short_description.txt",
    "content": "Lesezeichen-Manager"
  },
  {
    "path": "fastlane/metadata/android/en-US/changelogs/default.txt",
    "content": ""
  },
  {
    "path": "fastlane/metadata/android/en-US/full_description.txt",
    "content": "Discover a new way to save, organize, and access your favorite web pages with <b>Pagekeeper</b>. Built on the renowned <b>Shiori</b> platform, our app takes bookmark management to the next level.\n\nShiori is an innovative bookmark management application that revolutionizes the way users save, organize, and access their favorite web pages. Built upon the robust Shiori platform, Shiori offers a seamless experience across all devices.\n\n<b>Pagekeeper Key Features:</b>\n\n* <b>Save Pages Easily</b>: Capture web pages you discover in an instant and access them anytime, even offline.\n* <b>Superior Organization</b>: Sort your bookmarks with custom labels, descriptions, and thumbnails for quick retrieval.\n* <b>Cloud Synchronization</b>: Keep your bookmarks synced across all your devices, so you never lose an important page.\n* <b>Intuitive Interface</b>: Navigate through your bookmarks with a clean and user-friendly interface, designed for a seamless user experience.\n* <b>Share and Discover</b>: Share your favorite pages with friends and discover new pages through the Pagekeeper community."
  },
  {
    "path": "fastlane/metadata/android/en-US/short_description.txt",
    "content": "Android client for the Shiori Bookmark Manager"
  },
  {
    "path": "fastlane/metadata/android/en-US/title.txt",
    "content": "Shiori"
  },
  {
    "path": "gradle/libs.versions.toml",
    "content": "[versions]\n\ndatastorePreferences = \"1.0.0\"\njunitJupiter = \"5.8.1\"\njunitPlatformSuiteApi = \"1.8.1\"\nkoinAndroidxCompose = \"3.4.2\"\nmockitoCore = \"3.9.0\"\nmockitoKotlin = \"3.2.0\"\ncompose = \"1.7.0\"\ncomposeMaterial3 = \"1.2.1\"\n# gradlePlugin and lint need to be updated together\ngradlePlugin = \"7.3.1\"\nkotlin = \"2.0.0\"\ncoroutines = \"1.8.1\"\npagingCompose = \"3.3.2\"\nprotobuf = \"3.21.9\"\ncoil = \"2.7.0\"\nkoin = \"3.3.3\"\nroom = \"2.6.1\"\nwork = \"2.9.1\"\n\nandroidxnavigation = \"2.7.7\"\nandroidxLifecycle = \"2.7.0\"\n\n[libraries]\ncompose-ui-ui = { module = \"androidx.compose.ui:ui\", version.ref = \"compose\" }\ncompose-ui-tooling = { module = \"androidx.compose.ui:ui-tooling\", version.ref = \"compose\" }\ncompose-ui-tooling-preview = { module = \"androidx.compose.ui:ui-tooling-preview\", version.ref = \"compose\" }\ncompose-material-iconsext = { module = \"androidx.compose.material:material-icons-extended\", version.ref = \"compose\" }\ncompose-material3-material3 = { module = \"androidx.compose.material3:material3\", version.ref = \"composeMaterial3\" }\ncompose-runtime-livedata = { module = \"androidx.compose.runtime:runtime-livedata\", version.ref = \"compose\" }\nkotlin-coroutines-test = { module = \"org.jetbrains.kotlinx:kotlinx-coroutines-test\", version.ref = \"coroutines\" }\nkotlin-test-junit5 = { module = \"org.jetbrains.kotlin:kotlin-test-junit5\", version.ref = \"kotlin\" }\n\nkotlinx-coroutines-android = { module = \"org.jetbrains.kotlinx:kotlinx-coroutines-android\", version.ref = \"coroutines\" }\nprotobuf-protoc = { group = \"com.google.protobuf\", name = \"protoc\", version.ref = \"protobuf\" }\nprotobuf-kotlin-lite = { group = \"com.google.protobuf\", name = \"protobuf-kotlin-lite\", version.ref = \"protobuf\" }\ncoil-compose = { module = \"io.coil-kt:coil-compose\", version.ref = \"coil\" }\n\nandroidx-core = \"androidx.core:core-ktx:1.12.0\"\nandroidx-activity-compose = \"androidx.activity:activity-compose:1.8.2\"\nandroidx-lifecycle-runtime = { module = \"androidx.lifecycle:lifecycle-runtime-ktx\", version.ref = \"androidxLifecycle\" }\nandroidx-lifecycle-viewmodel-compose = { module = \"androidx.lifecycle:lifecycle-viewmodel-compose\", version.ref = \"androidxLifecycle\" }\nandroidx-lifecycle-runtimeCompose = { module = \"androidx.lifecycle:lifecycle-runtime-compose\", version.ref = \"androidxLifecycle\" }\nandroidx-preference = \"androidx.preference:preference-ktx:1.2.0\"\nandroidx-room = { module = \"androidx.room:room-ktx\", version.ref = \"room\" }\nandroidx-room-compiler = { module = \"androidx.room:room-compiler\", version.ref = \"room\" }\nandroidx-room-testing = { module = \"androidx.room:room-testing\", version.ref = \"room\" }\nandroidx-room-paging = { module = \"androidx.room:room-paging\", version.ref = \"room\" }\nandroidx-datastore-core = { module = \"androidx.datastore:datastore-core\", version.ref = \"datastorePreferences\" }\nandroidx-datastore-preferences = { module = \"androidx.datastore:datastore-preferences\", version.ref = \"datastorePreferences\" }\nandroidx-navigation-compose = { module = \"androidx.navigation:navigation-compose\", version.ref = \"androidxnavigation\" }\nandroidx-paging-compose = { module = \"androidx.paging:paging-compose\", version.ref = \"pagingCompose\" }\nandroidx-paging-common = { module = \"androidx.paging:paging-common\", version.ref = \"pagingCompose\" }\nandroidx-work = { module = \"androidx.work:work-runtime-ktx\", version.ref = \"work\" }\n\njunit-jupiter = { module = \"org.junit.jupiter:junit-jupiter\", version.ref = \"junitJupiter\" }\njunit-jupiter-engine = { module = \"org.junit.jupiter:junit-jupiter-engine\", version.ref = \"junitJupiter\" }\njunit-jupiter-api = { module = \"org.junit.jupiter:junit-jupiter-api\", version.ref = \"junitJupiter\" }\njunit-platform-suite-api = { module = \"org.junit.platform:junit-platform-suite-api\", version.ref = \"junitPlatformSuiteApi\" }\nmockito-core = { module = \"org.mockito:mockito-core\", version.ref = \"mockitoCore\" }\nmockito-kotlin = { module = \"org.mockito.kotlin:mockito-kotlin\", version.ref = \"mockitoKotlin\" }\naccompanist-permissions = \"com.google.accompanist:accompanist-permissions:0.20.3\"\nkoin-core = { module = \"io.insert-koin:koin-core\", version.ref = \"koin\" }\nkoin-android = { module = \"io.insert-koin:koin-android\", version.ref = \"koin\" }\nkoin-core-ext = { module = \"io.insert-koin:koin-core-ext\", version.ref = \"koin\" }\nkoin-androidx-compose = { module = \"io.insert-koin:koin-androidx-compose\", version.ref = \"koinAndroidxCompose\" }\nokhttp3-logging-interceptor = \"com.squareup.okhttp3:logging-interceptor:4.9.1\"\nretrofit2-retrofit = \"com.squareup.retrofit2:retrofit:2.9.0\"\nretrofit2-converter-gson = \"com.squareup.retrofit2:converter-gson:2.9.0\"\nretrofit2-converter-scalars = \"com.squareup.retrofit2:converter-scalars:2.1.0\"\n\n[bundles]\nkoin = [\"koin-core\", \"koin-android\", \"koin-core-ext\", \"koin-androidx-compose\"]\nretrofit = [\"okhttp3-logging-interceptor\", \"retrofit2-retrofit\", \"retrofit2-converter-gson\", \"retrofit2-converter-scalars\"]\n"
  },
  {
    "path": "gradle/wrapper/gradle-wrapper.properties",
    "content": "#Mon Mar 25 12:51:42 CET 2024\ndistributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-8.7-bin.zip\nzipStoreBase=GRADLE_USER_HOME\nzipStorePath=wrapper/dists\n"
  },
  {
    "path": "gradle.properties",
    "content": "# Project-wide Gradle settings.\n# IDE (e.g. Android Studio) users:\n# Gradle settings configured through the IDE *will override*\n# any settings specified in this file.\n# For more details on how to configure your build environment visit\n# http://www.gradle.org/docs/current/userguide/build_environment.html\n# Specifies the JVM arguments used for the daemon process.\n# The setting is particularly useful for tweaking memory settings.\norg.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8\n# When configured, Gradle will run in incubating parallel mode.\n# This option should only be used with decoupled projects. More details, visit\n# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects\n# org.gradle.parallel=true\n# AndroidX package structure to make it clearer which packages are bundled with the\n# Android operating system, and which are packaged with your app's APK\n# https://developer.android.com/topic/libraries/support-library/androidx-rn\nandroid.useAndroidX=true\n# Kotlin code style for this project: \"official\" or \"obsolete\":\nkotlin.code.style=official\n# Enables namespacing of each library's R class so that its R class includes only the\n# resources declared in the library itself and none from the library's dependencies,\n# thereby reducing the size of the R class for that library\nandroid.nonTransitiveRClass=true\n\ncompileSdkVersion=35\nminSdkVersion=26\ntargetSdkVersion=35\nversionCode=53\nversionName=1.51.01\n\nandroid.defaults.buildfeatures.buildconfig=true\nandroid.nonFinalResIds=false\n"
  },
  {
    "path": "gradlew",
    "content": "#!/usr/bin/env sh\n\n#\n# Copyright 2015 the original author or authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##############################################################################\n##\n##  Gradle start up script for UN*X\n##\n##############################################################################\n\n# Attempt to set APP_HOME\n# Resolve links: $0 may be a link\nPRG=\"$0\"\n# Need this for relative symlinks.\nwhile [ -h \"$PRG\" ] ; do\n    ls=`ls -ld \"$PRG\"`\n    link=`expr \"$ls\" : '.*-> \\(.*\\)$'`\n    if expr \"$link\" : '/.*' > /dev/null; then\n        PRG=\"$link\"\n    else\n        PRG=`dirname \"$PRG\"`\"/$link\"\n    fi\ndone\nSAVED=\"`pwd`\"\ncd \"`dirname \\\"$PRG\\\"`/\" >/dev/null\nAPP_HOME=\"`pwd -P`\"\ncd \"$SAVED\" >/dev/null\n\nAPP_NAME=\"Gradle\"\nAPP_BASE_NAME=`basename \"$0\"`\n\n# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\nDEFAULT_JVM_OPTS='\"-Xmx64m\" \"-Xms64m\"'\n\n# Use the maximum available, or set MAX_FD != -1 to use that value.\nMAX_FD=\"maximum\"\n\nwarn () {\n    echo \"$*\"\n}\n\ndie () {\n    echo\n    echo \"$*\"\n    echo\n    exit 1\n}\n\n# OS specific support (must be 'true' or 'false').\ncygwin=false\nmsys=false\ndarwin=false\nnonstop=false\ncase \"`uname`\" in\n  CYGWIN* )\n    cygwin=true\n    ;;\n  Darwin* )\n    darwin=true\n    ;;\n  MINGW* )\n    msys=true\n    ;;\n  NONSTOP* )\n    nonstop=true\n    ;;\nesac\n\nCLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar\n\n\n# Determine the Java command to use to start the JVM.\nif [ -n \"$JAVA_HOME\" ] ; then\n    if [ -x \"$JAVA_HOME/jre/sh/java\" ] ; then\n        # IBM's JDK on AIX uses strange locations for the executables\n        JAVACMD=\"$JAVA_HOME/jre/sh/java\"\n    else\n        JAVACMD=\"$JAVA_HOME/bin/java\"\n    fi\n    if [ ! -x \"$JAVACMD\" ] ; then\n        die \"ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nelse\n    JAVACMD=\"java\"\n    which java >/dev/null 2>&1 || die \"ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\nfi\n\n# Increase the maximum file descriptors if we can.\nif [ \"$cygwin\" = \"false\" -a \"$darwin\" = \"false\" -a \"$nonstop\" = \"false\" ] ; then\n    MAX_FD_LIMIT=`ulimit -H -n`\n    if [ $? -eq 0 ] ; then\n        if [ \"$MAX_FD\" = \"maximum\" -o \"$MAX_FD\" = \"max\" ] ; then\n            MAX_FD=\"$MAX_FD_LIMIT\"\n        fi\n        ulimit -n $MAX_FD\n        if [ $? -ne 0 ] ; then\n            warn \"Could not set maximum file descriptor limit: $MAX_FD\"\n        fi\n    else\n        warn \"Could not query maximum file descriptor limit: $MAX_FD_LIMIT\"\n    fi\nfi\n\n# For Darwin, add options to specify how the application appears in the dock\nif $darwin; then\n    GRADLE_OPTS=\"$GRADLE_OPTS \\\"-Xdock:name=$APP_NAME\\\" \\\"-Xdock:icon=$APP_HOME/media/gradle.icns\\\"\"\nfi\n\n# For Cygwin or MSYS, switch paths to Windows format before running java\nif [ \"$cygwin\" = \"true\" -o \"$msys\" = \"true\" ] ; then\n    APP_HOME=`cygpath --path --mixed \"$APP_HOME\"`\n    CLASSPATH=`cygpath --path --mixed \"$CLASSPATH\"`\n\n    JAVACMD=`cygpath --unix \"$JAVACMD\"`\n\n    # We build the pattern for arguments to be converted via cygpath\n    ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`\n    SEP=\"\"\n    for dir in $ROOTDIRSRAW ; do\n        ROOTDIRS=\"$ROOTDIRS$SEP$dir\"\n        SEP=\"|\"\n    done\n    OURCYGPATTERN=\"(^($ROOTDIRS))\"\n    # Add a user-defined pattern to the cygpath arguments\n    if [ \"$GRADLE_CYGPATTERN\" != \"\" ] ; then\n        OURCYGPATTERN=\"$OURCYGPATTERN|($GRADLE_CYGPATTERN)\"\n    fi\n    # Now convert the arguments - kludge to limit ourselves to /bin/sh\n    i=0\n    for arg in \"$@\" ; do\n        CHECK=`echo \"$arg\"|egrep -c \"$OURCYGPATTERN\" -`\n        CHECK2=`echo \"$arg\"|egrep -c \"^-\"`                                 ### Determine if an option\n\n        if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then                    ### Added a condition\n            eval `echo args$i`=`cygpath --path --ignore --mixed \"$arg\"`\n        else\n            eval `echo args$i`=\"\\\"$arg\\\"\"\n        fi\n        i=`expr $i + 1`\n    done\n    case $i in\n        0) set -- ;;\n        1) set -- \"$args0\" ;;\n        2) set -- \"$args0\" \"$args1\" ;;\n        3) set -- \"$args0\" \"$args1\" \"$args2\" ;;\n        4) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" ;;\n        5) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" ;;\n        6) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" \"$args5\" ;;\n        7) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" \"$args5\" \"$args6\" ;;\n        8) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" \"$args5\" \"$args6\" \"$args7\" ;;\n        9) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" \"$args5\" \"$args6\" \"$args7\" \"$args8\" ;;\n    esac\nfi\n\n# Escape application args\nsave () {\n    for i do printf %s\\\\n \"$i\" | sed \"s/'/'\\\\\\\\''/g;1s/^/'/;\\$s/\\$/' \\\\\\\\/\" ; done\n    echo \" \"\n}\nAPP_ARGS=`save \"$@\"`\n\n# Collect all arguments for the java command, following the shell quoting and substitution rules\neval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS \"\\\"-Dorg.gradle.appname=$APP_BASE_NAME\\\"\" -classpath \"\\\"$CLASSPATH\\\"\" org.gradle.wrapper.GradleWrapperMain \"$APP_ARGS\"\n\nexec \"$JAVACMD\" \"$@\"\n"
  },
  {
    "path": "gradlew.bat",
    "content": "@rem\n@rem Copyright 2015 the original author or authors.\n@rem\n@rem Licensed under the Apache License, Version 2.0 (the \"License\");\n@rem you may not use this file except in compliance with the License.\n@rem You may obtain a copy of the License at\n@rem\n@rem      https://www.apache.org/licenses/LICENSE-2.0\n@rem\n@rem Unless required by applicable law or agreed to in writing, software\n@rem distributed under the License is distributed on an \"AS IS\" BASIS,\n@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n@rem See the License for the specific language governing permissions and\n@rem limitations under the License.\n@rem\n\n@if \"%DEBUG%\" == \"\" @echo off\n@rem ##########################################################################\n@rem\n@rem  Gradle startup script for Windows\n@rem\n@rem ##########################################################################\n\n@rem Set local scope for the variables with windows NT shell\nif \"%OS%\"==\"Windows_NT\" setlocal\n\nset DIRNAME=%~dp0\nif \"%DIRNAME%\" == \"\" set DIRNAME=.\nset APP_BASE_NAME=%~n0\nset APP_HOME=%DIRNAME%\n\n@rem Resolve any \".\" and \"..\" in APP_HOME to make it shorter.\nfor %%i in (\"%APP_HOME%\") do set APP_HOME=%%~fi\n\n@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\nset DEFAULT_JVM_OPTS=\"-Xmx64m\" \"-Xms64m\"\n\n@rem Find java.exe\nif defined JAVA_HOME goto findJavaFromJavaHome\n\nset JAVA_EXE=java.exe\n%JAVA_EXE% -version >NUL 2>&1\nif \"%ERRORLEVEL%\" == \"0\" goto execute\n\necho.\necho ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\necho.\necho Please set the JAVA_HOME variable in your environment to match the\necho location of your Java installation.\n\ngoto fail\n\n:findJavaFromJavaHome\nset JAVA_HOME=%JAVA_HOME:\"=%\nset JAVA_EXE=%JAVA_HOME%/bin/java.exe\n\nif exist \"%JAVA_EXE%\" goto execute\n\necho.\necho ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%\necho.\necho Please set the JAVA_HOME variable in your environment to match the\necho location of your Java installation.\n\ngoto fail\n\n:execute\n@rem Setup the command line\n\nset CLASSPATH=%APP_HOME%\\gradle\\wrapper\\gradle-wrapper.jar\n\n\n@rem Execute Gradle\n\"%JAVA_EXE%\" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% \"-Dorg.gradle.appname=%APP_BASE_NAME%\" -classpath \"%CLASSPATH%\" org.gradle.wrapper.GradleWrapperMain %*\n\n:end\n@rem End local scope for the variables with windows NT shell\nif \"%ERRORLEVEL%\"==\"0\" goto mainEnd\n\n:fail\nrem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of\nrem the _cmd.exe /c_ return code!\nif  not \"\" == \"%GRADLE_EXIT_CONSOLE%\" exit 1\nexit /b 1\n\n:mainEnd\nif \"%OS%\"==\"Windows_NT\" endlocal\n\n:omega\n"
  },
  {
    "path": "model/.gitignore",
    "content": "/build"
  },
  {
    "path": "model/build.gradle.kts",
    "content": "plugins {\n    id(\"com.android.library\")\n    id (\"org.jetbrains.kotlin.android\")\n}\n\nandroid {\n    namespace = \"com.desarrollodroide.model\"\n    compileSdk = (findProperty(\"compileSdkVersion\") as String).toInt()\n\n    defaultConfig {\n        minSdk = (findProperty(\"minSdkVersion\") as String).toInt()\n        targetSdk = (findProperty(\"targetSdkVersion\") as String).toInt()\n    }\n\n    compileOptions {\n        sourceCompatibility = JavaVersion.VERSION_21\n        targetCompatibility = JavaVersion.VERSION_21\n    }\n    kotlinOptions {\n        jvmTarget = \"21\"\n    }\n}\n\njava {\n    toolchain {\n        languageVersion = JavaLanguageVersion.of(21)\n    }\n}\n"
  },
  {
    "path": "model/proguard-rules.pro",
    "content": "# Add project specific ProGuard rules here.\n# You can control the set of applied configuration files using the\n# proguardFiles setting in build.gradle.\n#\n# For more details, see\n#   http://developer.android.com/guide/developing/tools/proguard.html\n\n# If your project uses WebView with JS, uncomment the following\n# and specify the fully qualified class name to the JavaScript interface\n# class:\n#-keepclassmembers class fqcn.of.javascript.interface.for.webview {\n#   public *;\n#}\n\n# Uncomment this to preserve the line number information for\n# debugging stack traces.\n#-keepattributes SourceFile,LineNumberTable\n\n# If you keep the line number information, uncomment this to\n# hide the original source file name.\n#-renamesourcefileattribute SourceFile"
  },
  {
    "path": "model/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n</manifest>"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/Account.kt",
    "content": "package com.desarrollodroide.model\n\nclass Account(\n    val id: Int = -1,\n    val userName: String,\n    val password: String,\n    val owner: Boolean ,\n    val serverUrl: String,\n) {\n    constructor() : this(\n        id = -1,\n        userName = \"\",\n        password = \"\",\n        owner = false,\n        serverUrl = \"\",\n    )\n\n    companion object {\n        val mock = Account(\n            id = 1,\n            userName = \"user@example.com\",\n            password = \"securePassword123\",\n            owner = true,\n            serverUrl = \"https://api.example.com\",\n        )\n    }\n}"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/Bookmark.kt",
    "content": "package com.desarrollodroide.model\n\nimport android.webkit.URLUtil\nimport java.time.LocalDateTime\nimport java.time.format.DateTimeFormatter\n\ndata class Bookmark (\n    val id: Int,\n    val url: String,\n    val title: String,\n    val excerpt: String,\n    val author: String,\n    val public: Int,\n    val createAt: String,\n    val modified: String,\n    val imageURL: String,\n    val hasContent: Boolean,\n    val hasArchive: Boolean,\n    val hasEbook: Boolean,\n    val tags: List<Tag>,\n    val createArchive: Boolean,\n    val createEbook: Boolean,\n){\n    /**\n     * A bookmark is considered pending when it hasn't been fully processed by the server yet.\n     * This covers two cases:\n     * - The bookmark hasn't been sent to the server yet (temporary timestamp ID)\n     * - The server received it but hasn't finished processing content (no content, no image, no excerpt)\n     */\n    val isPendingServerProcessing: Boolean\n        get() = isTemporaryId ||\n                (!hasContent && imageURL.isEmpty() && excerpt.isEmpty()) ||\n                (title.isNotEmpty() && URLUtil.isValidUrl(title))\n\n    /**\n     * Temporary IDs are generated from System.currentTimeMillis() / 1000 (epoch seconds),\n     * producing values like 1,700,000,000+. Real server IDs are sequential (1, 2, 3...),\n     * so any ID over 1 million is clearly a temporary local ID.\n     */\n    private val isTemporaryId: Boolean\n        get() = id > 1_000_000\n\n    constructor(\n        url: String,\n        tags: List<Tag>,\n        public: Int,\n        createArchive: Boolean,\n        createEbook: Boolean,\n        title: String ,\n    ) : this(\n        id = (System.currentTimeMillis() / 1000).toInt(),\n        url= url,\n        title = title,\n        excerpt = \"\",\n        author = \"\",\n        public = public,\n        createAt = LocalDateTime.now().format(DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm:ss\")),\n        modified = LocalDateTime.now().format(DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm:ss\")),\n        imageURL = \"\",\n        hasContent = false,\n        hasArchive = false,\n        hasEbook = false,\n        tags = tags,\n        createArchive = createArchive,\n        createEbook = createEbook,\n    )\n\n    companion object {\n        fun mock() = Bookmark(\n            id = -1,\n            url = \"url\",\n            title = \"Bookmark title\",\n            excerpt = \"A detailed description of the bookmark, explaining its significance, context, and why it was saved.\",\n            author = \"John Doe\",\n            public = 1,\n            createAt = \"2024-09-25 05:53:11\",\n            modified = \"2024-03-19 15:44:40\",\n            imageURL = \"https://fastly.picsum.photos/id/12/2500/1667.jpg?hmac=Pe3284luVre9ZqNzv1jMFpLihFI6lwq7TPgMSsNXw2w\",\n            hasContent = true,\n            hasArchive = true,\n            hasEbook = false,\n            createArchive = true,\n            createEbook = true,\n            tags = listOf(Tag(id = 1 ,name = \"tag1\"), Tag(id = 2, name = \"tag2\")),\n        )\n    }\n}\n"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/Bookmarks.kt",
    "content": "package com.desarrollodroide.model\n\ndata class Bookmarks (\n    val error: String,\n    var maxPage: Int,\n    var page: Int,\n    var bookmarks: List<Bookmark>,\n) {\n    constructor(error: String): this(\n        error = error,\n        maxPage = 0,\n        page = 0,\n        bookmarks = emptyList()\n    )\n}\n"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/LivenessResponse.kt",
    "content": "package com.desarrollodroide.model\n\nclass LivenessResponse (\n    val ok: Boolean,\n    val message: ReleaseInfo?\n)"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/LoginResponseMessage.kt",
    "content": "package com.desarrollodroide.model\n\ndata class LoginResponseMessage(\n    val expires: Int,\n    val session: String,\n    val token: String\n)"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/ModifiedBookmarks.kt",
    "content": "package com.desarrollodroide.model\n\ndata class ModifiedBookmarks(\n    val bookmarks: List<Bookmark>,\n    val maxPage: Int,\n    val page: Int\n)"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/PendingJob.kt",
    "content": "package com.desarrollodroide.model\n\ndata class PendingJob(\n    val operationType: SyncOperationType,\n    val state: String,\n    val bookmarkId: Int,\n    val bookmarkTitle: String\n)\n\nenum class SyncOperationType {\n    CREATE, UPDATE, DELETE, CACHE;\n\n    companion object {\n        fun fromString(value: String): SyncOperationType? =\n            entries.find { it.name == value.uppercase() }\n    }\n}\n"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/ReadableContent.kt",
    "content": "package com.desarrollodroide.model\n\ndata class ReadableContent(\n    val ok: Boolean,\n    val message: ReadableMessage,\n)"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/ReadableMessage.kt",
    "content": "package com.desarrollodroide.model\n\ndata class ReadableMessage(\n    val content: String,\n    val html: String\n)"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/ReleaseInfo.kt",
    "content": "package com.desarrollodroide.model\n\ndata class ReleaseInfo(\n    val version: String,\n    val commit: String,\n    val date: String\n)"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/SyncBookmarksRequestPayload.kt",
    "content": "package com.desarrollodroide.model\n\ndata class SyncBookmarksRequestPayload(\n    val ids: List<Int>,\n    val last_sync: Long,\n    val page: Int\n)"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/SyncBookmarksResponse.kt",
    "content": "package com.desarrollodroide.model\n\ndata class SyncBookmarksResponse(\n    val deleted: List<Int>,\n    val modified: ModifiedBookmarks\n)"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/Tag.kt",
    "content": "package com.desarrollodroide.model\n\n\ndata class Tag (\n    val id: Int,\n    val name: String,\n    var selected: Boolean,\n    val nBookmarks: Int\n){\n    constructor(\n        id: Int,\n        name: String\n    ) : this(id, name, false, 0)\n\n}\n"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/UpdateCachePayload.kt",
    "content": "package com.desarrollodroide.model\n\n\ndata class UpdateCachePayload(\n    val createArchive : Boolean,\n    val createEbook : Boolean,\n    val ids: List<Int>,\n    val keepMetadata : Boolean,\n    val skipExist: Boolean\n)"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/User.kt",
    "content": "package com.desarrollodroide.model\n\ndata class User(\n    val session: String,\n    val token: String,\n    val account: Account,\n    val error: String = \"\"\n) {\n    fun hasSession() = session.isNotEmpty()\n\n    constructor(error: String) : this(\n        token = \"\",\n        session = \"\",\n        account = Account(),\n        error = error\n    )\n\n    companion object {\n        val mock = User(\n            session = \"session123\",\n            token = \"token456\",\n            account = Account.mock,\n            error = \"\"\n        )\n\n        val errorMock = User(\n            error = \"Error occurred\"\n        )\n    }\n}"
  },
  {
    "path": "network/.gitignore",
    "content": "/build"
  },
  {
    "path": "network/README.md",
    "content": "# :core:network module\n\n![Dependency graph](../../docs/images/graphs/dep_graph_core_network.png)\n"
  },
  {
    "path": "network/build.gradle.kts",
    "content": "plugins {\n    id (\"com.android.library\")\n    id (\"org.jetbrains.kotlin.android\")\n}\n\n\nandroid {\n    namespace = \"com.desarrollodroide.network\"\n    compileSdk = (findProperty(\"compileSdkVersion\") as String).toInt()\n\n    defaultConfig {\n        minSdk = (findProperty(\"minSdkVersion\") as String).toInt()\n        targetSdk = (findProperty(\"targetSdkVersion\") as String).toInt()\n\n        testInstrumentationRunner = \"androidx.test.runner.AndroidJUnitRunner\"\n        vectorDrawables {\n            useSupportLibrary = true\n        }\n    }\n\n    buildTypes {\n        release {\n            isMinifyEnabled = false\n            proguardFiles(\n                getDefaultProguardFile(\"proguard-android-optimize.txt\"),\n                \"proguard-rules.pro\"\n            )\n        }\n    }\n    compileOptions {\n        sourceCompatibility = JavaVersion.VERSION_21\n        targetCompatibility = JavaVersion.VERSION_21\n    }\n    kotlinOptions {\n        jvmTarget = \"21\"\n    }\n}\n\njava {\n    toolchain {\n        languageVersion = JavaLanguageVersion.of(21)\n    }\n}\n\ndependencies {\n\n    implementation(project(\":common\"))\n    implementation (libs.bundles.retrofit)\n    implementation (libs.koin.androidx.compose)\n\n}\n"
  },
  {
    "path": "network/lint.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<lint>\n    <!--\n    Lint crashes when it tries to analyse a file without a package name:\n    java.lang.IllegalStateException: () -> kotlin.String at org.jetbrains.kotlin.asJava.classes.KtLightClassForFacadeImpl$Companion.createForFacadeNoCache\n    -->\n    <issue id=\"LintError\">\n        <ignore path=\"**/JvmUnitTestFakeAssetManager.kt\" />\n    </issue>\n</lint>\n"
  },
  {
    "path": "network/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <uses-permission android:name=\"android.permission.INTERNET\" />\n\n</manifest>"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/di/NetworkingModule.kt",
    "content": "package com.desarrollodroide.network.di\n\nimport com.desarrollodroide.network.retrofit.NetworkLoggerInterceptor\nimport com.desarrollodroide.network.retrofit.RetrofitNetwork\nimport okhttp3.OkHttpClient\nimport okhttp3.logging.HttpLoggingInterceptor\nimport org.koin.dsl.module\nimport retrofit2.Retrofit\nimport retrofit2.converter.gson.GsonConverterFactory\nimport retrofit2.converter.scalars.ScalarsConverterFactory\nimport java.util.concurrent.TimeUnit\n\nfun networkingModule() = module {\n\n    single { NetworkLoggerInterceptor() }\n\n    single {\n        OkHttpClient.Builder()\n            .readTimeout(30, TimeUnit.SECONDS)\n            .connectTimeout(30, TimeUnit.SECONDS)\n            .addInterceptor { chain ->\n                val request = chain.request()\n                val sessionHeader = request.header(\"X-Session-Id\")\n                if (sessionHeader != null && sessionHeader.isNotEmpty()) {\n                    val newRequest = request.newBuilder()\n                        .removeHeader(\"X-Session-Id\")\n                        .addHeader(\"Authorization\", \"Bearer $sessionHeader\")\n                        .build()\n                    chain.proceed(newRequest)\n                } else {\n                    chain.proceed(request)\n                }\n            }\n            .addInterceptor(get<NetworkLoggerInterceptor>())\n            .addInterceptor(HttpLoggingInterceptor().apply {\n                level = HttpLoggingInterceptor.Level.BODY\n            })\n            .build()\n    } // client\n\n    single {\n        Retrofit.Builder()\n            .addConverterFactory(ScalarsConverterFactory.create())\n            .addConverterFactory(GsonConverterFactory.create())\n            .baseUrl(\"https://google.com\") //generic url\n            .client(get())\n            .build()\n    } // retrofit\n\n    single { get<Retrofit>().create(RetrofitNetwork::class.java) } // api service\n\n}"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/AccountDTO.kt",
    "content": "package com.desarrollodroide.network.model\n\nimport com.google.gson.annotations.SerializedName\n\ndata class AccountDTO(\n\n    @SerializedName(\"id\")\n    val id: Int? = -1,\n\n    @SerializedName(\"username\")\n    val userName: String? = null,\n\n    @SerializedName(\"password\")\n    val password: String? = null,\n\n    @SerializedName(\"owner\")\n    val isOwner: Boolean? = null,\n\n    @SerializedName(\"oldPassword\")\n    val oldPassword: String? = null,\n\n    @SerializedName(\"newPassword\")\n    val newPassword: String? = null,\n\n)"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/ApiResponse.kt",
    "content": "package com.desarrollodroide.network.model\n\n\ndata class ApiResponse<T>(\n    val success: Boolean,\n    val data: T? = null,\n    val error: String? = null\n)\n\n\n"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/BookmarkDTO.kt",
    "content": "package com.desarrollodroide.network.model\n\nimport com.google.gson.annotations.SerializedName\n\ndata class BookmarkDTO (\n    val id: Int?,\n    val url: String?,\n    val title: String?,\n    val excerpt: String?,\n    val author: String?,\n    val public: Int?,\n    val createdAt: String?,\n    @SerializedName(value = \"modified\", alternate = [\"modifiedAt\"])\n    val modified: String?,\n    val imageURL: String?,\n    val hasContent: Boolean?,\n    val hasArchive: Boolean?,\n    val hasEbook: Boolean?,\n    val tags: List<TagDTO>?,\n    @SerializedName(\"create_archive\")\n    val createArchive: Boolean?,\n    @SerializedName(\"create_ebook\")\n    val createEbook: Boolean?,\n)\n"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/BookmarkResponseDTO.kt",
    "content": "package com.desarrollodroide.network.model\n\ndata class BookmarkResponseDTO (\n    val ok: Boolean?,\n    val message: List<BookmarkDTO>?,\n)\n\n/**\n * Wrapper for single bookmark responses from Shiori v1.8.0+.\n * The MessageResponseMiddleware wraps all responses in {\"ok\":bool,\"message\":data}.\n * For addBookmark/editBookmark, the legacy handler returns a single BookmarkDTO,\n * which gets wrapped as {\"ok\":true,\"message\":{id:...,url:...,...}}.\n *\n * Also handles legacy format (pre-middleware) where the BookmarkDTO is the root object.\n */\ndata class SingleBookmarkResponseDTO(\n    val ok: Boolean? = null,\n    val message: BookmarkDTO? = null,\n    // Legacy fallback fields (when response is not wrapped)\n    val id: Int? = null,\n    val url: String? = null,\n    val title: String? = null,\n    val excerpt: String? = null,\n    val author: String? = null,\n    val public: Int? = null,\n    val createdAt: String? = null,\n    val modified: String? = null,\n    val imageURL: String? = null,\n    val hasContent: Boolean? = null,\n    val hasArchive: Boolean? = null,\n    val hasEbook: Boolean? = null,\n    val tags: List<TagDTO>? = null,\n) {\n    fun resolvedBookmark(): BookmarkDTO? =\n        message ?: if (id != null) BookmarkDTO(\n            id = id, url = url, title = title, excerpt = excerpt,\n            author = author, public = public, createdAt = createdAt,\n            modified = modified, imageURL = imageURL, hasContent = hasContent,\n            hasArchive = hasArchive, hasEbook = hasEbook, tags = tags,\n            createArchive = null, createEbook = null\n        ) else null\n}"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/BookmarksDTO.kt",
    "content": "package com.desarrollodroide.network.model\n\ndata class BookmarksDTO (\n    val ok: Boolean? = null,\n    val message: BookmarksMessageDTO? = null,\n    val maxPage: Int? = null,\n    val page: Int? = null,\n    val bookmarks: List<BookmarkDTO>? = null,\n) {\n    /** Resolves bookmarks from either v1.8+ (wrapped in message) or legacy format */\n    fun resolvedBookmarks(): List<BookmarkDTO>? = bookmarks ?: message?.bookmarks\n    fun resolvedPage(): Int? = page ?: message?.page\n    fun resolvedMaxPage(): Int? = maxPage ?: message?.maxPage\n}\n\ndata class BookmarksMessageDTO(\n    val bookmarks: List<BookmarkDTO>?,\n    val maxPage: Int?,\n    val page: Int?,\n)\n"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/LivenessResponseDTO.kt",
    "content": "package com.desarrollodroide.network.model\n\ndata class LivenessResponseDTO (\n    val ok: Boolean?,\n    val message: ReleaseInfoDTO?\n)"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/LoginRequestPayload.kt",
    "content": "package com.desarrollodroide.network.model\n\nimport com.google.gson.annotations.SerializedName\n\ndata class LoginRequestPayload(\n    val username: String,\n    val password: String,\n    @SerializedName(\"remember_me\")\n    val rememberMe: Boolean = true\n)"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/LoginResponseDTO.kt",
    "content": "package com.desarrollodroide.network.model\n\ndata class LoginResponseDTO (\n    val ok: Boolean?,\n    val message: LoginResponseMessageDTO?,\n    val error: String?\n)"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/LoginResponseMessageDTO.kt",
    "content": "package com.desarrollodroide.network.model\n\ndata class LoginResponseMessageDTO (\n    val expires: Int?,    // Deprecated, used only for legacy APIs\n    val session: String?, // Deprecated, used only for legacy APIs\n    val token: String?,\n)"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/ModifiedBookmarksDTO.kt",
    "content": "package com.desarrollodroide.network.model\n\ndata class ModifiedBookmarksDTO(\n    val bookmarks: List<BookmarkDTO>?,\n    val maxPage: Int?,\n    val page: Int?\n)"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/ReadableContentResponseDTO.kt",
    "content": "package com.desarrollodroide.network.model\n\ndata class ReadableContentResponseDTO (\n    val ok: Boolean?,\n    val message: ReadableMessageDto?,\n    // v1.8.0 returns content and html at root level (no wrapper)\n    val content: String? = null,\n    val html: String? = null,\n) {\n    fun resolvedMessage(): ReadableMessageDto? =\n        message ?: if (content != null || html != null) ReadableMessageDto(content, html) else null\n}\n\n"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/ReadableMessageDto.kt",
    "content": "package com.desarrollodroide.network.model\n\ndata class ReadableMessageDto(\n    val content: String?,\n    val html: String?\n)"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/ReleaseInfoDTO.kt",
    "content": "package com.desarrollodroide.network.model\n\ndata class ReleaseInfoDTO (\n    val version: String?,\n    val commit: String?,\n    val date: String?\n)"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/SessionDTO.kt",
    "content": "package com.desarrollodroide.network.model\n\nimport com.google.gson.annotations.SerializedName\n//import com.shiori.domain.model.Account\n\ndata class SessionDTO (\n    @SerializedName(\"session\")\n    val session: String?,\n\n    @SerializedName(\"token\")\n    val token: String?,\n\n    @SerializedName(\"account\")\n    val account: AccountDTO?\n)"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/SyncBookmarksMessageDTO.kt",
    "content": "package com.desarrollodroide.network.model\n\ndata class SyncBookmarksMessageDTO(\n    val deleted: List<Int>?,\n    val modified: ModifiedBookmarksDTO?\n)"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/SyncBookmarksResponseDTO.kt",
    "content": "package com.desarrollodroide.network.model\n\ndata class SyncBookmarksResponseDTO(\n    val deleted: List<Int>?,\n    val message: SyncBookmarksMessageDTO\n)"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/TagDTO.kt",
    "content": "package com.desarrollodroide.network.model\n\nimport com.google.gson.annotations.SerializedName\n\ndata class TagDTO (\n    @SerializedName(\"id\")\n    val id: Int?,\n\n    @SerializedName(\"name\")\n    val name: String?,\n\n    @SerializedName(value = \"nBookmarks\", alternate = [\"bookmark_count\"])\n    val nBookmarks: Int?,\n    )"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/TagsDTO.kt",
    "content": "package com.desarrollodroide.network.model\n\nclass TagsDTO (\n    val ok: Boolean?,\n    val message: List<TagDTO>?\n)"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/UpdateCachePayloadDTO.kt",
    "content": "package com.desarrollodroide.network.model\n\nimport com.google.gson.annotations.SerializedName\n\ndata class UpdateCachePayloadDTO(\n    @SerializedName(\"createArchive\")\n    val createArchive : Boolean,\n    @SerializedName(\"createEbook\")\n    val createEbook : Boolean?,\n    val ids: List<Int>,\n    @SerializedName(\"keepMetadata\")\n    val keepMetadata : Boolean,\n)"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/UpdateCachePayloadV1DTO.kt",
    "content": "package com.desarrollodroide.network.model\n\nimport com.google.gson.annotations.SerializedName\n\n\ndata class UpdateCachePayloadV1DTO(\n    @SerializedName(\"create_archive\")\n    val createArchive : Boolean,\n    @SerializedName(\"create_ebook\")\n    val createEbook : Boolean,\n    val ids: List<Int>,\n    @SerializedName(\"keep_metadata\")\n    val keepMetadata : Boolean,\n    @SerializedName(\"skip_exist\")\n    val skipExist : Boolean\n)"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/util/NetworkChangeList.kt",
    "content": "package com.desarrollodroide.network.model.util\n\n/**\n * Network representation of a change list for a model.\n *\n * Change lists are a representation of a server-side map like data structure of model ids to\n * metadata about that model. In a single change list, a given model id can only show up once.\n */\ndata class NetworkChangeList(\n    /**\n     * The id of the model that was changed\n     */\n    val id: String,\n    /**\n     * Unique consecutive, monotonically increasing version number in the collection describing\n     * the relative point of change between models in the collection\n     */\n    val changeListVersion: Int,\n    /**\n     * Summarizes the update to the model; whether it was deleted or updated.\n     * Updates include creations.\n     */\n    val isDelete: Boolean,\n)\n"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/retrofit/FileRemoteDataSource.kt",
    "content": "package com.desarrollodroide.network.retrofit\n\nimport android.content.Context\nimport okhttp3.OkHttpClient\nimport okhttp3.Request\nimport java.io.File\n\nclass FileRemoteDataSource {\n    fun downloadFile(\n        context: Context,\n        url: String,\n        fileName: String,\n        sessionId: String\n    ): File {\n        val client = OkHttpClient.Builder().build()\n        val request = Request.Builder()\n            .url(url)\n            .addHeader(\"X-Session-Id\", sessionId)\n            .build()\n\n        val response = client.newCall(request).execute()\n        val directory = context.getExternalFilesDir(null)\n        val downloadedFile = File(directory, \"${cleanFileName(fileName)}.epub\")\n\n        response.body?.byteStream().use { input ->\n            downloadedFile.outputStream().use { output ->\n                input?.copyTo(output)\n            }\n        }\n        return downloadedFile\n    }\n\n    private fun cleanFileName(fileName: String): String {\n        return fileName.replace(Regex(\"[^a-zA-Z0-9.,\\\\-\\\\s_\\u0600-\\u06FF]\"), \"_\")\n    }\n}\n\n"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/retrofit/NetworkBoundResource.kt",
    "content": "package com.desarrollodroide.network.retrofit\n\nimport android.util.Log\nimport androidx.annotation.MainThread\nimport androidx.annotation.WorkerThread\nimport com.desarrollodroide.common.result.ErrorHandler\nimport kotlinx.coroutines.flow.*\nimport retrofit2.Response\nimport com.desarrollodroide.common.result.Result\nimport kotlin.coroutines.cancellation.CancellationException\n\n/**\n * A generic class that can provide a resource backed by both the sqlite database and the network.\n *\n * Adapted from: Guide to app architecture\n * https://developer.android.com/jetpack/guide\n *\n * @param <ResultType> Represents the domain model\n * @param <RequestType> Represents the (converted) network > database model\n */\nabstract class NetworkBoundResource<RequestType, ResultType>(\n    private val errorHandler: ErrorHandler,\n) {\n    fun asFlow() = flow {\n        emit(Result.Loading(null)) // start loading state immediately\n        val cachedData = fetchFromLocal().firstOrNull()\n\n        try {\n            if (shouldFetch(cachedData)) {\n                emit(Result.Loading(cachedData)) // update loading state with cached data\n\n                val apiResponse = fetchFromRemote()\n                val remoteResponse = apiResponse.body()\n\n                if (apiResponse.isSuccessful && remoteResponse != null) {\n                    saveRemoteData(remoteResponse)\n                    // Always fetch from local (Source of truth)\n                    emitAll(fetchFromLocal().map {\n                        Result.Success(it)\n                    })\n                } else {\n                    emit(Result.Error(errorHandler.getApiError(\n                            statusCode = apiResponse.code(),\n                            throwable = null,\n                            message = apiResponse.errorBody()?.string())))\n                }\n            } else {\n                emit(Result.Success(cachedData))\n            }\n        } catch (e: Exception) {\n            if (e !is CancellationException) {\n                println(\"NetworkBoundResource: Error: ${e.message}\")\n                emit(Result.Error(errorHandler.getError(e)))\n            }\n        }\n    }\n\n    @WorkerThread\n    protected abstract suspend fun saveRemoteData(response: RequestType)\n\n    @MainThread\n    protected abstract fun fetchFromLocal(): Flow<ResultType>\n\n    @MainThread\n    protected abstract suspend fun fetchFromRemote(): Response<RequestType>\n\n    @MainThread\n    protected abstract fun shouldFetch(data: ResultType?): Boolean\n}\n\nabstract class NetworkNoCacheResource<RequestType, ResultType>(\n    private val errorHandler: ErrorHandler,\n) {\n    fun asFlow() = flow {\n        emit(Result.Loading(null)) // start loading state immediately\n        try {\n            val apiResponse = fetchFromRemote()\n            val remoteResponse = apiResponse.body()\n            if (apiResponse.isSuccessful && remoteResponse != null) {\n                emitAll(fetchResult(remoteResponse).map { Result.Success(it) })\n            } else {\n                emit(Result.Error(errorHandler.getApiError(\n                    statusCode = apiResponse.code(),\n                    throwable = null,\n                    message = apiResponse.errorBody()?.string())))\n            }\n        } catch (e: Exception) {\n            Log.v(\"NetworkNoCacheResource\", \"Error: ${e.message}\")\n            emit(Result.Error(errorHandler.getError(e), null))\n        }\n    }\n\n    @MainThread\n    protected abstract suspend fun fetchFromRemote(): Response<RequestType>\n    @MainThread\n    protected abstract fun fetchResult(data: RequestType): Flow<ResultType>\n}"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/retrofit/NetworkLoggerInterceptor.kt",
    "content": "package com.desarrollodroide.network.retrofit\n\nimport com.desarrollodroide.common.result.NetworkLogEntry\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.update\nimport okhttp3.Interceptor\nimport okhttp3.Response\nimport java.text.SimpleDateFormat\nimport java.util.Locale\nimport kotlinx.coroutines.flow.asStateFlow\n\nclass NetworkLoggerInterceptor : Interceptor {\n    private val _logs = MutableStateFlow<List<NetworkLogEntry>>(emptyList())\n    val logs: StateFlow<List<NetworkLogEntry>> = _logs.asStateFlow()\n\n    fun clearLogs() {\n        _logs.value = emptyList()\n    }\n\n    private fun addLog(entry: NetworkLogEntry) {\n        _logs.update { currentLogs -> currentLogs + entry }\n    }\n\n    override fun intercept(chain: Interceptor.Chain): Response {\n        val request = chain.request()\n        val startTime = System.currentTimeMillis()\n        val timestamp = SimpleDateFormat(\"MM-dd HH:mm:ss.SSS\", Locale.getDefault())\n            .format(startTime)\n\n        // Log request\n        addLog(\n            NetworkLogEntry(\n                timestamp = timestamp,\n                priority = \"I\",\n                url = request.url.toString(),\n                message = \"${request.method} ${request.url.encodedPath}\"\n            )\n        )\n\n        return try {\n            chain.proceed(request).also { response ->\n                val endTime = System.currentTimeMillis()\n                val duration = endTime - startTime\n\n                // Log response\n                addLog(\n                    NetworkLogEntry(\n                        timestamp = SimpleDateFormat(\"MM-dd HH:mm:ss.SSS\", Locale.getDefault())\n                            .format(endTime),\n                        priority = if (response.isSuccessful) \"S\" else \"E\",\n                        url = request.url.toString(),\n                        message = \"HTTP ${response.code} (${duration}ms)\\n\" +\n                                response.peekBody(1024).string()\n                    )\n                )\n            }\n        } catch (e: Exception) {\n            addLog(\n                NetworkLogEntry(\n                    timestamp = SimpleDateFormat(\"MM-dd HH:mm:ss.SSS\", Locale.getDefault())\n                        .format(System.currentTimeMillis()),\n                    priority = \"E\",\n                    url = request.url.toString(),\n                    message = e.message ?: \"Unknown error\"\n                )\n            )\n            throw e\n        }\n    }\n}"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/retrofit/RetrofitNetwork.kt",
    "content": "package com.desarrollodroide.network.retrofit\n\nimport com.desarrollodroide.network.model.AccountDTO\nimport com.desarrollodroide.network.model.BookmarkDTO\nimport com.desarrollodroide.network.model.BookmarkResponseDTO\nimport com.desarrollodroide.network.model.SingleBookmarkResponseDTO\nimport com.desarrollodroide.network.model.BookmarksDTO\nimport com.desarrollodroide.network.model.LivenessResponseDTO\nimport com.desarrollodroide.network.model.LoginResponseDTO\nimport com.desarrollodroide.network.model.ReadableContentResponseDTO\nimport com.desarrollodroide.network.model.SessionDTO\nimport com.desarrollodroide.network.model.SyncBookmarksResponseDTO\nimport com.desarrollodroide.network.model.TagDTO\nimport com.desarrollodroide.network.model.TagsDTO\nimport retrofit2.Response\nimport retrofit2.http.*\n\ninterface RetrofitNetwork {\n\n    @GET()\n    suspend fun getBookmarks(\n        @Header(\"X-Session-Id\") xSessionId: String,\n        @Url url: String\n    ): Response<BookmarksDTO>\n\n    @GET()\n    suspend fun getPagingBookmarks(\n        @Header(\"X-Session-Id\") xSessionId: String,\n        @Url url: String\n    ): Response<BookmarksDTO>\n\n    @Headers(\"Content-Type: application/json\")\n    @POST()\n    suspend fun sendLogin(\n        @Url url: String,\n        @Body jsonData: String\n    ): Response<SessionDTO>\n\n    @Headers(\"Content-Type: application/json\")\n    @POST()\n    suspend fun sendLoginV1(\n        @Url url: String,\n        @Body jsonData: String\n    ): Response<LoginResponseDTO>\n\n    @POST()\n    suspend fun sendLogout(\n        @Url url: String,\n        @Header(\"X-Session-Id\") xSessionId: String,\n    ): Response<String>\n\n    @HTTP(method = \"DELETE\", hasBody = true)\n    suspend fun deleteBookmarks(\n        @Url url: String,\n        @Header(\"X-Session-Id\") xSessionId: String,\n        @Body bookmarkIds: List<Int>\n    ): Response<Unit>\n\n    // Add Bookmark\n    @Headers(\"Content-Type: application/json\")\n    @POST\n    suspend fun addBookmark(\n        @Url url: String,\n        @Header(\"X-Session-Id\") xSessionId: String,\n        @Body body: String\n    ): Response<SingleBookmarkResponseDTO>\n\n    @Headers(\"Content-Type: application/json\")\n    @PUT()\n    suspend fun editBookmark(\n        @Url url: String,\n        @Header(\"X-Session-Id\") xSessionId: String,\n        @Body body: String\n    ): Response<SingleBookmarkResponseDTO>\n\n    @Headers(\"Content-Type: application/json\")\n    @PUT()\n    suspend fun updateBookmarksCache(\n        @Url url: String,\n        @Header(\"X-Session-Id\") xSessionId: String,\n        @Body body: String\n    ): Response<List<BookmarkDTO>>\n\n    @Headers(\"Content-Type: application/json\")\n    @PUT()\n    suspend fun updateBookmarksCacheV1(\n        @Url url: String,\n        @Header(\"Authorization\") authorization: String,\n        @Body body: String\n    ): Response<BookmarkResponseDTO>\n\n    // Get tags\n    @GET()\n    suspend fun getTags(\n        @Url url: String,\n        @Header(\"Authorization\") authorization: String,\n    ): Response<TagsDTO>\n\n    // Rename tag\n    @PUT(\"/api/tags\")\n    suspend fun renameTag(\n        @Header(\"X-Session-Id\") xSessionId: String,\n        @Body tag: TagDTO\n    ): Response<TagDTO>\n\n    // List accounts\n    @GET(\"/api/accounts\")\n    suspend fun listAccounts(\n        @Header(\"X-Session-Id\") xSessionId: String\n    ): Response<List<AccountDTO>>\n\n    // Create account\n    @POST(\"/api/accounts\")\n    suspend fun createAccount(\n        @Header(\"X-Session-Id\") xSessionId: String,\n        @Body account: AccountDTO\n    ): Response<AccountDTO>\n\n    // Edit account\n    @PUT(\"/api/accounts\")\n    suspend fun editAccount(\n        @Header(\"X-Session-Id\") xSessionId: String,\n        @Body account: AccountDTO\n    ): Response<AccountDTO>\n\n    // Delete accounts\n    @HTTP(method = \"DELETE\", path = \"/api/accounts\", hasBody = true)\n    suspend fun deleteAccounts(\n        @Header(\"X-Session-Id\") xSessionId: String,\n        @Body accountNames: List<String>\n    ): Response<Unit>\n\n    // Test system liveness\n    @GET()\n    suspend fun systemLiveness(\n        @Url url: String\n    ): Response<LivenessResponseDTO>\n\n    @GET()\n    suspend fun getBookmarkReadableContent(\n        @Url url: String,\n        @Header(\"Authorization\") authorization: String,\n    ): Response<ReadableContentResponseDTO>\n\n    @Headers(\"Content-Type: application/json\")\n    @POST()\n    suspend fun syncBookmarks(\n        @Url url: String,\n        @Header(\"Authorization\") authorization: String,\n        @Body body: String\n    ): Response<SyncBookmarksResponseDTO>\n\n    @GET\n    suspend fun getBookmark(\n        @Url url: String,\n        @Header(\"Authorization\") authorization: String,\n    ): Response<SingleBookmarkResponseDTO>\n\n}\n"
  },
  {
    "path": "presentation/.gitignore",
    "content": "/build"
  },
  {
    "path": "presentation/build.gradle.kts",
    "content": "plugins {\n    id(\"com.android.application\")\n    id(\"org.jetbrains.kotlin.android\")\n    id(\"de.mannodermaus.android-junit5\")\n    id(\"org.jetbrains.kotlin.plugin.compose\")\n}\n\nandroid {\n    namespace = \"com.desarrollodroide.pagekeeper\"\n    compileSdk = (findProperty(\"compileSdkVersion\") as String).toInt()\n\n    defaultConfig {\n        applicationId = \"com.desarrollodroide.pagekeeper\"\n        minSdk = (findProperty(\"minSdkVersion\") as String).toInt()\n        targetSdk = (findProperty(\"targetSdkVersion\") as String).toInt()\n        versionCode = (findProperty(\"versionCode\") as String).toInt()\n        versionName = findProperty(\"versionName\") as String\n\n        testInstrumentationRunner = \"androidx.test.runner.AndroidJUnitRunner\"\n        vectorDrawables {\n            useSupportLibrary = true\n        }\n    }\n\n    signingConfigs {\n        create(\"production\") {\n            keyAlias = System.getenv(\"RELEASE_KEY_ALIAS\")\n            keyPassword = System.getenv(\"RELEASE_KEY_PASSWORD\")\n            storeFile = file(\"${System.getenv(\"GITHUB_WORKSPACE\")}/key_store.jks\")\n            storePassword = System.getenv(\"RELEASE_STORE_PASSWORD\")\n        }\n        create(\"staging\") {\n            keyAlias = System.getenv(\"RELEASE_KEY_ALIAS\")\n            keyPassword = System.getenv(\"RELEASE_KEY_PASSWORD\")\n            storeFile = file(\"${System.getenv(\"GITHUB_WORKSPACE\")}/key_store.jks\")\n            storePassword = System.getenv(\"RELEASE_STORE_PASSWORD\")\n        }\n    }\n\n    buildTypes {\n        release {\n            isMinifyEnabled = false\n        }\n        debug {\n            isDebuggable = true\n        }\n    }\n\n    flavorDimensions += \"version\"\n    productFlavors {\n        create(\"production\") {\n            dimension = \"version\"\n            signingConfig = signingConfigs.getByName(\"production\")\n        }\n        create(\"staging\") {\n            dimension = \"version\"\n            applicationId = \"com.desarrollodroide.pagekeeper.staging\"\n            signingConfig = signingConfigs.getByName(\"staging\")\n            versionNameSuffix = \"-staging\"\n            resValue(\"string\", \"app_name\", \"Shiori-dev\")\n        }\n    }\n\n    compileOptions {\n        sourceCompatibility = JavaVersion.VERSION_21\n        targetCompatibility = JavaVersion.VERSION_21\n    }\n    kotlinOptions {\n        jvmTarget = \"21\"\n    }\n    buildFeatures {\n        compose = true\n    }\n    composeOptions {\n        kotlinCompilerExtensionVersion = \"1.5.11\"\n    }\n    packagingOptions {\n        resources {\n            excludes += \"/META-INF/{AL2.0,LGPL2.1}\"\n        }\n    }\n\n    applicationVariants.configureEach {\n        outputs.configureEach {\n            val output = this as? com.android.build.gradle.internal.api.BaseVariantOutputImpl\n            output?.outputFileName = \"Shiori v$versionName.apk\"\n        }\n    }\n\n\n    dependenciesInfo {\n        includeInApk = false\n        includeInBundle = false\n    }\n}\n\ndependencies {\n\n    implementation(project(\":data\"))\n    implementation(project(\":domain\"))\n    implementation(project(\":model\"))\n    implementation(project(\":network\"))\n    implementation(project(\":common\"))\n\n    implementation (libs.androidx.core)\n    implementation (libs.androidx.lifecycle.runtime )\n    implementation (libs.androidx.activity.compose)\n    implementation (libs.androidx.navigation.compose)\n    implementation (libs.androidx.lifecycle.viewmodel.compose)\n    implementation (libs.androidx.lifecycle.runtimeCompose)\n    implementation (libs.androidx.preference)\n    implementation (libs.androidx.paging.compose)\n    implementation (\"androidx.paging:paging-common-ktx:3.3.2\")\n\n    implementation (libs.compose.ui.ui)\n    implementation (libs.compose.ui.tooling.preview)\n    implementation (libs.compose.ui.tooling)\n    implementation (libs.compose.material3.material3)\n    implementation (libs.compose.material.iconsext)\n    implementation (libs.compose.runtime.livedata)\n\n    implementation (libs.bundles.retrofit)\n    implementation (libs.accompanist.permissions)\n\n    implementation (libs.koin.androidx.compose)\n    implementation (libs.androidx.datastore.preferences)\n    implementation (libs.coil.compose)\n\n    // Testing libraries\n    testImplementation(libs.junit.jupiter) // JUnit Jupiter for unit testing with JUnit 5.\n    testRuntimeOnly(libs.junit.jupiter.engine) // JUnit Jupiter Engine for running JUnit 5 tests.\n    testImplementation(libs.junit.jupiter.api) // JUnit Jupiter API for writing tests and extensions in JUnit 5.\n    testImplementation(libs.mockito.core) // Mockito for mocking objects in tests.\n    testImplementation(libs.mockito.kotlin) // Kotlin extension for Mockito to better support Kotlin features.\n    testImplementation(libs.kotlin.coroutines.test) // Coroutines Test library for testing Kotlin coroutines.\n    testImplementation(libs.kotlin.test.junit5) // Kotlin Test library for JUnit 5 support.\n\n}\n\ncomposeCompiler {\n    enableStrongSkippingMode = true\n}\n\njava {\n    toolchain {\n        languageVersion = JavaLanguageVersion.of(21)\n    }\n}\n"
  },
  {
    "path": "presentation/proguard-rules.pro",
    "content": "# Add project specific ProGuard rules here.\n# You can control the set of applied configuration files using the\n# proguardFiles setting in build.gradle.kts.\n#\n# For more details, see\n#   http://developer.android.com/guide/developing/tools/proguard.html\n\n# If your project uses WebView with JS, uncomment the following\n# and specify the fully qualified class name to the JavaScript interface\n# class:\n#-keepclassmembers class fqcn.of.javascript.interface.for.webview {\n#   public *;\n#}\n\n# Uncomment this to preserve the line number information for\n# debugging stack traces.\n#-keepattributes SourceFile,LineNumberTable\n\n# If you keep the line number information, uncomment this to\n# hide the original source file name.\n#-renamesourcefileattribute SourceFile"
  },
  {
    "path": "presentation/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    xmlns:tools=\"http://schemas.android.com/tools\">\n\n    <uses-permission android:name=\"android.permission.INTERNET\" />\n\n    <application\n        android:allowBackup=\"true\"\n        android:icon=\"@mipmap/ic_launcher\"\n        android:label=\"@string/app_name\"\n        android:usesCleartextTraffic=\"true\"\n        android:name=\".ShioriApplication\"\n        android:roundIcon=\"@mipmap/ic_launcher_round\"\n        android:supportsRtl=\"true\"\n        android:theme=\"@style/Theme.Shiori\"\n        tools:targetApi=\"31\">\n        <activity\n            android:name=\".MainActivity\"\n            android:exported=\"true\"\n            android:screenOrientation=\"portrait\"\n            android:launchMode=\"singleTask\"\n            android:theme=\"@style/Theme.Shiori\"\n            tools:ignore=\"DiscouragedApi,LockedOrientationActivity\">\n            <intent-filter>\n                <action android:name=\"android.intent.action.MAIN\" />\n\n                <category android:name=\"android.intent.category.LAUNCHER\" />\n            </intent-filter>\n\n            <meta-data\n                android:name=\"android.app.lib_name\"\n                android:value=\"\" />\n        </activity>\n        <activity android:name=\".ui.bookmarkeditor.BookmarkEditorActivity\"\n            android:label=\"Save in Shiori\"\n            android:screenOrientation=\"portrait\"\n            android:exported=\"true\"\n            tools:ignore=\"DiscouragedApi,LockedOrientationActivity\">\n            <intent-filter>\n                <action android:name=\"android.intent.action.SEND\" />\n                <category android:name=\"android.intent.category.DEFAULT\" />\n                <data android:mimeType=\"text/plain\" />\n            </intent-filter>\n        </activity>\n\n        <provider\n            android:name=\"androidx.core.content.FileProvider\"\n            android:authorities=\"${applicationId}.provider\"\n            android:exported=\"false\"\n            android:grantUriPermissions=\"true\">\n            <meta-data\n                android:name=\"android.support.FILE_PROVIDER_PATHS\"\n                android:resource=\"@xml/file_paths\" />\n        </provider>\n    </application>\n\n    <queries>\n        <intent>\n            <action android:name=\"android.intent.action.VIEW\" />\n            <category android:name=\"android.intent.category.BROWSABLE\" />\n            <data android:scheme=\"https\" />\n        </intent>\n    </queries>\n\n</manifest>"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ComposeSetup.kt",
    "content": "package com.desarrollodroide.pagekeeper\n\nimport androidx.compose.foundation.isSystemInDarkTheme\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Surface\nimport androidx.compose.runtime.*\nimport androidx.compose.ui.Modifier\nimport com.desarrollodroide.data.helpers.ThemeMode\nimport com.desarrollodroide.pagekeeper.helpers.ThemeManager\nimport com.desarrollodroide.pagekeeper.ui.theme.ShioriTheme\n\n@Composable\nfun ComposeSetup(\n    themeManager: ThemeManager,\n    content: @Composable () -> Unit\n) {\n    val isDarkTheme = when (themeManager.themeMode.value) {\n        ThemeMode.DARK -> true\n        ThemeMode.LIGHT -> false\n        ThemeMode.AUTO -> isSystemInDarkTheme()\n    }\n    ShioriTheme(\n        dynamicColor = themeManager.useDynamicColors.value,\n        darkTheme = isDarkTheme\n    ) {\n        Surface(\n            modifier = Modifier.fillMaxSize(),\n            color = MaterialTheme.colorScheme.background\n        ) {\n            content()\n        }\n    }\n}\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/MainActivity.kt",
    "content": "package com.desarrollodroide.pagekeeper\n\nimport android.content.Context\nimport android.os.Build\nimport android.os.Bundle\nimport android.util.Log\nimport androidx.activity.ComponentActivity\nimport androidx.activity.compose.setContent\nimport androidx.annotation.RequiresApi\nimport androidx.compose.foundation.ExperimentalFoundationApi\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.material3.Surface\nimport androidx.compose.ui.Modifier\nimport coil.ImageLoader\nimport com.desarrollodroide.pagekeeper.extensions.logCacheDetails\nimport com.desarrollodroide.pagekeeper.extensions.openUrlInBrowser\nimport com.desarrollodroide.pagekeeper.helpers.ThemeManager\nimport com.desarrollodroide.pagekeeper.navigation.Navigation\nimport org.koin.android.ext.android.inject\nimport com.desarrollodroide.pagekeeper.extensions.shareEpubFile\nimport com.desarrollodroide.pagekeeper.extensions.shareText\nimport com.desarrollodroide.pagekeeper.ui.bookmarkeditor.BookmarkEditorActivity\nimport java.util.Locale\n\nclass MainActivity : ComponentActivity() {\n\n    private val themeManager: ThemeManager by inject()\n\n    @RequiresApi(Build.VERSION_CODES.N)\n    @OptIn(ExperimentalFoundationApi::class)\n    override fun onCreate(savedInstanceState: Bundle?) {\n        super.onCreate(savedInstanceState)\n\n        //val context = this.updateLocale(Locale(\"iw\"))\n        setContent {\n            ComposeSetup(themeManager = themeManager) {\n                Surface(\n                    modifier = Modifier\n                        .fillMaxSize()\n                ){\n                    Navigation(\n                        onFinish = {\n                            finish()\n                        },\n                        openUrlInBrowser = {\n                            openUrlInBrowser(it)\n                        },\n                        shareEpubFile = {\n                            shareEpubFile(it)\n                        },\n                        shareText = {\n                            shareText(it)\n                        },\n                        onAddManuallyClick = {\n                            startActivity(BookmarkEditorActivity.createManualIntent(this))\n                        }\n                    )\n                }\n            }\n        }\n    }\n\n    override fun onResume() {\n        super.onResume()\n        Log.v(\"MainActivity\", \"onResume\")\n        // TODO: sync when endpoint is available\n    }\n}\n\nfun Context.updateLocale(locale: Locale): Context {\n    Locale.setDefault(locale)\n    val resources = this.resources\n    val config = resources.configuration\n    config.setLocale(locale)\n    return this.createConfigurationContext(config)\n}\n\n\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ShioriApp.kt",
    "content": "package com.desarrollodroide.pagekeeper\n\nimport android.app.Application\nimport coil.ImageLoader\nimport com.desarrollodroide.pagekeeper.di.presenterModule\nimport com.desarrollodroide.pagekeeper.di.appModule\nimport com.desarrollodroide.data.di.dataModule\nimport com.desarrollodroide.data.di.databaseModule\nimport com.desarrollodroide.data.helpers.CrashHandler\nimport com.desarrollodroide.network.di.networkingModule\nimport com.desarrollodroide.pagekeeper.extensions.logCacheDetails\nimport org.koin.android.ext.android.inject\nimport org.koin.android.ext.koin.androidContext\nimport org.koin.core.context.startKoin\n\nclass ShioriApplication : Application() {\n    override fun onCreate() {\n        super.onCreate()\n        startKoin {\n            androidContext(this@ShioriApplication)\n            modules(\n                listOf(\n                    networkingModule(),\n                    appModule(),\n                    presenterModule(),\n                    dataModule(),\n                    databaseModule()\n                )\n            )\n        }\n        // Show log disk cache statistics for debugging\n        val imageLoader: ImageLoader by inject()\n        imageLoader.logCacheDetails()\n\n        val crashHandler: CrashHandler by inject()\n        crashHandler.initialize()\n\n    }\n}\n\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/di/AppModule.kt",
    "content": "package com.desarrollodroide.pagekeeper.di\n\nimport android.content.Context\nimport android.util.Log\nimport coil.ImageLoader\nimport coil.disk.DiskCache\nimport com.desarrollodroide.pagekeeper.helpers.ThemeManager\nimport com.desarrollodroide.pagekeeper.helpers.ThemeManagerImpl\nimport com.desarrollodroide.data.repository.BookmarksRepository\nimport com.desarrollodroide.data.repository.BookmarksRepositoryImpl\nimport com.desarrollodroide.domain.usecase.AddBookmarkUseCase\nimport com.desarrollodroide.domain.usecase.DeleteBookmarkUseCase\nimport com.desarrollodroide.domain.usecase.DeleteLocalBookmarkUseCase\nimport com.desarrollodroide.domain.usecase.DownloadFileUseCase\nimport com.desarrollodroide.domain.usecase.EditBookmarkUseCase\nimport com.desarrollodroide.domain.usecase.GetBookmarkReadableContentUseCase\nimport com.desarrollodroide.domain.usecase.GetBookmarksUseCase\nimport com.desarrollodroide.domain.usecase.GetLocalPagingBookmarksUseCase\nimport com.desarrollodroide.domain.usecase.GetTagsUseCase\nimport com.desarrollodroide.domain.usecase.SendLoginUseCase\nimport com.desarrollodroide.domain.usecase.SendLogoutUseCase\nimport com.desarrollodroide.domain.usecase.SyncBookmarksUseCase\nimport com.desarrollodroide.domain.usecase.GetAllRemoteBookmarksUseCase\nimport com.desarrollodroide.domain.usecase.SystemLivenessUseCase\nimport com.desarrollodroide.domain.usecase.UpdateBookmarkCacheUseCase\nimport com.desarrollodroide.domain.usecase.GetBookmarkByIdUseCase\nimport okhttp3.OkHttpClient\nimport org.koin.dsl.module\n\nfun appModule() = module {\n\n    single {\n        BookmarksRepositoryImpl(\n            apiService = get(),\n            bookmarksDao = get(),\n            errorHandler = get(),\n        ) as BookmarksRepository\n    }\n\n    single {\n        GetBookmarksUseCase(\n            bookmarksRepository = get()\n        )\n    }\n\n    single {\n        GetLocalPagingBookmarksUseCase(\n            bookmarksRepository = get()\n        )\n    }\n\n    single {\n        DeleteBookmarkUseCase(\n            bookmarksDao = get(),\n            syncManager = get()\n        )\n    }\n\n    single {\n        DeleteLocalBookmarkUseCase(\n            bookmarksDao = get()\n        )\n    }\n\n    single {\n        SendLoginUseCase(\n            authRepository = get()\n        )\n    }\n\n    single {\n        SendLogoutUseCase(\n            authRepository = get(),\n            syncManager = get(),\n            settingsPreferenceDataSource = get(),\n            bookmarksRepository = get()\n        )\n    }\n\n    single {\n        AddBookmarkUseCase(\n            bookmarksDao = get(),\n            syncManager = get()\n        )\n    }\n\n    single {\n        EditBookmarkUseCase(\n            bookmarksDao = get(),\n            tagsDao = get(),\n            syncManager = get()\n        )\n    }\n\n    single {\n        UpdateBookmarkCacheUseCase(\n            bookmarksDao = get(),\n            syncManager = get()\n        )\n    }\n\n    single {\n        DownloadFileUseCase(\n            fileRepository = get()\n        )\n    }\n\n    single {\n        SystemLivenessUseCase(\n            systemRepository = get()\n        )\n    }\n\n    single {\n        GetTagsUseCase(\n            tagsRepository = get()\n        )\n    }\n\n    single {\n        GetBookmarkReadableContentUseCase(\n            bookmarksRepository = get()\n        )\n    }\n\n    single {\n        GetBookmarkByIdUseCase(\n            bookmarksRepository = get()\n        )\n    }\n\n    single {\n        GetAllRemoteBookmarksUseCase(\n            bookmarksRepository = get()\n        )\n    }\n\n    single {\n        SyncBookmarksUseCase(\n            bookmarksRepository = get(),\n            settingsPreferenceDataSource = get(),\n            bookmarkDatabase = get()\n        )\n    }\n\n    single { ThemeManagerImpl(get()) as ThemeManager }\n\n    single {\n        ImageLoader.Builder(get<Context>())\n            .okHttpClient {\n                OkHttpClient.Builder()\n                    .retryOnConnectionFailure(true)\n                    .addInterceptor { chain ->\n                        val request = chain.request()\n                        val response = chain.proceed(request)\n                        if (!response.isSuccessful) {\n                            Log.e(\"BookmarkImageView\", \"HTTP error: ${response.code}\")\n                        }\n                        val newCacheControl = \"public, max-age=31536000\"\n                        response.newBuilder()\n                            .header(\"Cache-Control\", newCacheControl)\n                            .build()\n                    }\n                    .build()\n            }\n            .diskCache {\n                DiskCache.Builder()\n                    .directory(get<Context>().cacheDir.resolve(\"image_cache\"))\n                    .maxSizeBytes(250L * 1024 * 1024) // 250MB\n                    .build()\n            }\n            .build()\n    }\n\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/di/PresenterModule.kt",
    "content": "package com.desarrollodroide.pagekeeper.di\n\nimport com.desarrollodroide.pagekeeper.ui.feed.FeedViewModel\nimport com.desarrollodroide.pagekeeper.ui.login.LoginViewModel\nimport com.desarrollodroide.pagekeeper.ui.bookmarkeditor.BookmarkViewModel\nimport com.desarrollodroide.pagekeeper.ui.feed.SearchViewModel\nimport com.desarrollodroide.pagekeeper.ui.readablecontent.ReadableContentViewModel\nimport com.desarrollodroide.pagekeeper.ui.settings.SettingsViewModel\nimport com.desarrollodroide.pagekeeper.ui.settings.crash.CrashLogViewModel\nimport com.desarrollodroide.pagekeeper.ui.settings.logcat.NetworkLogViewModel\nimport org.koin.dsl.module\nimport org.koin.androidx.viewmodel.dsl.viewModel\n\nfun presenterModule() = module {\n\n    viewModel {\n        LoginViewModel(\n            loginUseCase = get(),\n            settingsPreferenceDataSource = get(),\n            livenessUseCase = get(),\n        )\n    }\n\n    viewModel {\n        FeedViewModel(\n            bookmarkDatabase = get(),\n            settingsPreferenceDataSource = get(),\n            getTagsUseCase = get(),\n            deleteBookmarkUseCase = get(),\n            updateBookmarkCacheUseCase = get(),\n            getLocalPagingBookmarksUseCase = get(),\n            downloadFileUseCase = get(),\n            getAllRemoteBookmarksUseCase = get(),\n            deleteLocalBookmarkUseCase = get(),\n            syncBookmarksUseCase = get(),\n            syncManager = get(),\n        )\n    }\n\n    viewModel {\n        SettingsViewModel(\n            settingsPreferenceDataSource = get(),\n            bookmarksRepository = get(),\n            sendLogoutUseCase = get(),\n            themeManager = get(),\n            getTagsUseCase = get(),\n            imageLoader = get(),\n        )\n    }\n\n    viewModel {\n        BookmarkViewModel(\n            bookmarkDatabase = get(),\n            bookmarksRepository = get(),\n            bookmarkAdditionUseCase = get(),\n            editBookmarkUseCase = get(),\n            userPreferences = get(),\n            settingsPreferenceDataSource =  get(),\n        )\n    }\n\n    viewModel {\n        SearchViewModel(\n            getPagingBookmarksUseCase = get(),\n            settingsPreferenceDataSource = get(),\n        )\n    }\n\n    viewModel {\n        ReadableContentViewModel(\n            getBookmarkReadableContentUseCase = get(),\n            settingsPreferenceDataSource = get(),\n            bookmarksDao = get(),\n            bookmarkHtmlDao = get(),\n        )\n    }\n\n    viewModel {\n        NetworkLogViewModel(\n            logger = get(),\n        )\n    }\n\n    viewModel {\n        CrashLogViewModel(\n            settingsPreferenceDataSource = get(),\n        )\n    }\n\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/extensions/ContextExtensions.kt",
    "content": "package com.desarrollodroide.pagekeeper.extensions\n\nimport android.content.Context\nimport android.content.Intent\nimport android.net.Uri\nimport androidx.core.content.FileProvider\nimport java.io.File\n\nfun Context.shareText(text: String) {\n    val shareIntent = Intent().apply {\n        action = Intent.ACTION_SEND\n        putExtra(Intent.EXTRA_TEXT, text)\n        type = \"text/plain\"\n    }\n    startActivity(Intent.createChooser(shareIntent, null))\n}\n\nfun Context.openUrlInBrowser(url: String) {\n    val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))\n    val chooser = Intent.createChooser(intent, \"Open with\")\n    startActivity(chooser)\n}\n\n\nfun Context.shareEpubFile(file: File) {\n    val uri = FileProvider.getUriForFile(this, \"${applicationContext.packageName}.provider\", file)\n\n    val intent = Intent().apply {\n        action = Intent.ACTION_SEND\n        putExtra(Intent.EXTRA_STREAM, uri)\n        type = \"application/epub+zip\"\n        addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)\n    }\n    startActivity(Intent.createChooser(intent, \"Share EPUB\"))\n}\n\nfun Context.sendFeedbackEmail() {\n    val emailIntent = Intent(Intent.ACTION_SENDTO).apply {\n        data = Uri.parse(\"mailto:\")\n        putExtra(Intent.EXTRA_EMAIL, arrayOf(\"desarrollodroide@gmail.com\"))\n    }\n    val chooserIntent = Intent.createChooser(emailIntent, \"Choose an email app:\")\n    startActivity(chooserIntent)\n}\n\n\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/extensions/ImageLoaderExtensions.kt",
    "content": "package com.desarrollodroide.pagekeeper.extensions\n\nimport android.util.Log\nimport coil.ImageLoader\nimport coil.annotation.ExperimentalCoilApi\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.withContext\n\n@OptIn(ExperimentalCoilApi::class)\nfun ImageLoader.logCacheDetails() {\n    diskCache?.let { cache ->\n        val imageCount = cache.directory.toFile().listFiles()?.size ?: 0\n        Log.d(\"CoilCacheInfo\", \"Total images in disk cache: $imageCount\")\n    } ?: Log.d(\"CoilCacheInfo\", \"No disk cache configured\")\n}\n\n@OptIn(ExperimentalCoilApi::class)\nsuspend fun ImageLoader.clearCache() = withContext(Dispatchers.IO) {\n    memoryCache?.clear()\n    diskCache?.clear()\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/extensions/LongExtensions.kt",
    "content": "package com.desarrollodroide.pagekeeper.extensions\n\nfun Long.bytesToDisplaySize(): String {\n    val kb = this / 1024.0\n    val mb = kb / 1024.0\n    val gb = mb / 1024.0\n    return when {\n        gb >= 1.0 -> String.format(\"%.2f GB\", gb)\n        mb >= 1.0 -> String.format(\"%.2f MB\", mb)\n        kb >= 1.0 -> String.format(\"%.2f KB\", kb)\n        else -> \"$this bytes\"\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/extensions/StringExtensions.kt",
    "content": "package com.desarrollodroide.pagekeeper.extensions\n\n/**\n * Determines if a string contains more than half Arabic characters.\n *\n * @return True if the string contains more than half Arabic characters, false otherwise.\n */\nfun String.isRTLText(): Boolean {\n    // Take the first 20 characters of the string\n    val textSample = this.take(100)\n\n    // Count the number of Arabic characters in the sample\n    val arabicCount = textSample.count { char ->\n        // Check if the character is within the Arabic Unicode range\n        char in '\\u0600'..'\\u06FF' ||\n                char in '\\u0750'..'\\u077F' ||\n                char in '\\u08A0'..'\\u08FF' ||\n                char in '\\uFB50'..'\\uFDFF' ||\n                char in '\\uFE70'..'\\uFEFF'\n    }\n\n    // Return true if the Arabic character count is greater than half the length of the sample\n    return arabicCount > textSample.length / 2\n}\n\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/helpers/ThemeManager.kt",
    "content": "package com.desarrollodroide.pagekeeper.helpers\n\nimport androidx.compose.runtime.MutableState\nimport com.desarrollodroide.data.helpers.ThemeMode\n\ninterface ThemeManager {\n    var themeMode: MutableState<ThemeMode>\n    var useDynamicColors: MutableState<Boolean>\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/helpers/ThemeManagerImpl.kt",
    "content": "package com.desarrollodroide.pagekeeper.helpers\n\nimport androidx.compose.runtime.mutableStateOf\nimport com.desarrollodroide.data.repository.SettingsRepository\n\nclass ThemeManagerImpl(\n    settingsRepository: SettingsRepository,\n) : ThemeManager {\n      override var themeMode = mutableStateOf(settingsRepository.getThemeMode())\n      override var useDynamicColors = mutableStateOf(settingsRepository.getUseDynamicColors())\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/navigation/NavItem.kt",
    "content": "package com.desarrollodroide.pagekeeper.navigation\n\nimport android.net.Uri\nimport androidx.navigation.NavType\nimport androidx.navigation.navArgument\n\nsealed class NavItem(\n    internal val baseRoute: String,\n    private val navArgs: List<NavArgs> = emptyList()\n) {\n    data object LoginNavItem : NavItem(\"login\")\n    data object HomeNavItem : NavItem(\"home\")\n    data object SettingsNavItem : NavItem(\"settings\")\n    data object TermsOfUseNavItem : NavItem(\"termsOfUse\")\n    data object PrivacyPolicyNavItem : NavItem(\"privacyPolicy\")\n    data object ReadableContentNavItem : NavItem(\"readable_content/{bookmarkId}\") {\n        fun createRoute(bookmarkId: Int): String {\n            return \"readable_content/$bookmarkId\"\n        }\n    }\n    data object NetworkLoggerNavItem : NavItem(\"networkLogger\")\n    data object LastCrashNavItem: NavItem(\"lastCrash\")\n\n    val route = run {\n        val argValues = navArgs.map { \"{${it.key}}\" }\n        listOf(baseRoute)\n            .plus(argValues)\n            .joinToString(\"/\")\n    }\n\n    val args = navArgs.map {\n        navArgument(it.key) { type = it.navType }\n    }\n}\n\nenum class NavArgs(val key: String, val navType: NavType<*>)"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/navigation/Navigation.kt",
    "content": "package com.desarrollodroide.pagekeeper.navigation\n\nimport android.os.Build\nimport androidx.annotation.RequiresApi\nimport androidx.compose.foundation.ExperimentalFoundationApi\nimport androidx.compose.runtime.Composable\nimport androidx.navigation.NavBackStackEntry\nimport androidx.navigation.NavGraphBuilder\nimport androidx.navigation.compose.NavHost\nimport androidx.navigation.compose.composable\nimport androidx.navigation.compose.rememberNavController\nimport com.desarrollodroide.pagekeeper.ui.feed.FeedViewModel\nimport com.desarrollodroide.pagekeeper.ui.home.HomeScreen\nimport com.desarrollodroide.pagekeeper.ui.login.LoginScreen\nimport com.desarrollodroide.pagekeeper.ui.login.LoginViewModel\nimport org.koin.androidx.compose.get\nimport java.io.File\n\n@RequiresApi(Build.VERSION_CODES.N)\n@ExperimentalFoundationApi\n@Composable\nfun Navigation(\n    onFinish: () -> Unit,\n    openUrlInBrowser: (String) -> Unit,\n    onAddManuallyClick: () -> Unit,\n    shareEpubFile: (File) -> Unit,\n    shareText: (String) -> Unit\n) {\n\n    val navController = rememberNavController()\n    val feedViewModel = get<FeedViewModel>()\n    val loginViewModel = get<LoginViewModel>()\n\n    NavHost(\n        navController = navController,\n        startDestination = NavItem.LoginNavItem.route\n    ) {\n\n        composable(NavItem.LoginNavItem) { backStackEntry ->\n            LoginScreen(\n                loginViewModel = loginViewModel,\n                onSuccess = {\n                    navController.navigate(NavItem.HomeNavItem.route)\n                }\n            )\n        }\n        composable(NavItem.HomeNavItem) { backStackEntry ->\n            HomeScreen(\n                feedViewModel = feedViewModel,\n                goToLogin = {\n                    loginViewModel.clearState()\n                    feedViewModel.resetData()\n                    navController.navigate(NavItem.LoginNavItem.route) {\n                        popUpTo(NavItem.HomeNavItem.route) { inclusive = true }\n                    }\n                },\n                onFinish = onFinish,\n                openUrlInBrowser = openUrlInBrowser,\n                shareEpubFile = shareEpubFile,\n                shareText = shareText,\n                onAddManuallyClick = onAddManuallyClick\n            )\n        }\n    }\n}\n\nprivate fun NavGraphBuilder.composable(\n    navItem: NavItem,\n    content: @Composable (NavBackStackEntry) -> Unit\n) {\n    composable(\n        route = navItem.route,\n        arguments = navItem.args\n    ) {\n        content(it)\n    }\n}\n\nprivate inline fun <reified T> NavBackStackEntry.findArg(key: String): T {\n    val value = arguments?.get(key)\n    requireNotNull(value)\n    return value as T\n}\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/bookmarkeditor/BookmarkEditorActivity.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.bookmarkeditor\n\nimport android.content.Context\nimport android.content.Intent\nimport android.os.Bundle\nimport android.util.Log\nimport android.widget.Toast\nimport androidx.activity.ComponentActivity\nimport androidx.activity.compose.setContent\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Surface\nimport androidx.compose.ui.Modifier\nimport androidx.lifecycle.lifecycleScope\nimport com.desarrollodroide.pagekeeper.ui.theme.ShioriTheme\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.pagekeeper.MainActivity\nimport kotlinx.coroutines.launch\nimport org.koin.androidx.viewmodel.ext.android.viewModel\n\nclass BookmarkEditorActivity : ComponentActivity() {\n\n    private val bookmarkViewModel: BookmarkViewModel by viewModel()\n\n    companion object {\n        const val EXTRA_MODE = \"extra_mode\"\n\n        fun createManualIntent(context: Context): Intent {\n            return Intent(context, BookmarkEditorActivity::class.java).apply {\n                putExtra(EXTRA_MODE, BookmarkEditorType.ADD_MANUALLY.name)\n            }\n        }\n    }\n\n    override fun onCreate(savedInstanceState: Bundle?) {\n        super.onCreate(savedInstanceState)\n\n        val mode = intent.getStringExtra(EXTRA_MODE)\n        if (mode == BookmarkEditorType.ADD_MANUALLY.name) {\n            setupBookmarkEditor(BookmarkEditorType.ADD_MANUALLY, \"\", \"\")\n            return\n        }\n\n        var sharedUrl = \"\"\n        var title = \"\"\n        intent?.let { intent ->\n            if (intent.action == Intent.ACTION_SEND) {\n                intent.extras?.keySet()?.forEach { key ->\n                    val value = intent.extras?.get(key)\n                    Log.v(\"Intent Extra\", \"$key: $value\")\n                }\n                sharedUrl = intent.getStringExtra(Intent.EXTRA_TEXT) ?: \"\"\n                Log.v(\"Shared link\", sharedUrl)\n                title = intent.getStringExtra(Intent.EXTRA_TITLE) ?: sharedUrl\n                Log.v(\"Shared title\", title)\n            } else {\n                Toast.makeText(this, \"Invalid shared link\", Toast.LENGTH_LONG).show()\n                finish()\n            }\n        }\n\n        lifecycleScope.launch {\n            bookmarkViewModel.toastMessage.collect { message ->\n                message?.let {\n                    Toast.makeText(this@BookmarkEditorActivity, it, Toast.LENGTH_LONG).show()\n                    finish()\n                }\n            }\n        }\n\n        lifecycleScope.launch {\n            if (sharedUrl.isNotEmpty()) {\n                if (bookmarkViewModel.userHasSession()) {\n                    if (bookmarkViewModel.autoAddBookmark) {\n                        bookmarkViewModel.autoAddBookmark(sharedUrl, title)\n                        Toast.makeText(this@BookmarkEditorActivity, \"Bookmark saved\", Toast.LENGTH_LONG).show()\n                        finish()\n                    } else {\n                        setupBookmarkEditor(BookmarkEditorType.ADD, sharedUrl, title)\n                    }\n                } else {\n                    setContent {\n                        ShioriTheme {\n                            NotSessionScreen(\n                                onClickLogin = {\n                                    startMainActivity()\n                                }\n                            )\n                        }\n                    }\n                }\n            } else {\n                Toast.makeText(this@BookmarkEditorActivity, \"No shared URL found\", Toast.LENGTH_LONG).show()\n                finish()\n            }\n        }\n    }\n\n    private fun setupBookmarkEditor(type: BookmarkEditorType, url: String, title: String) {\n        setContent {\n            ShioriTheme {\n                Surface(\n                    modifier = Modifier\n                        .fillMaxSize()\n                        .background(MaterialTheme.colorScheme.inverseOnSurface)\n                ) {\n                    val makeArchivePublic = bookmarkViewModel.makeArchivePublic\n                    val createEbook = bookmarkViewModel.createEbook\n                    val createArchive = bookmarkViewModel.createArchive\n                    BookmarkEditorScreen(\n                        pageTitle = if (type == BookmarkEditorType.ADD_MANUALLY) \"Add Manually\" else \"Add\",\n                        bookmarkEditorType = type,\n                        bookmark = Bookmark(\n                            url = url,\n                            title = title,\n                            tags = emptyList(),\n                            public = if (makeArchivePublic) 1 else 0,\n                            createArchive = createArchive,\n                            createEbook = createEbook\n                        ),\n                        onBack = { finish() },\n                        updateBookmark = { finish() },\n                        showToast = { message ->\n                            Toast.makeText(this@BookmarkEditorActivity, message, Toast.LENGTH_LONG).show()\n                        },\n                        startMainActivity = { startMainActivity() }\n                    )\n                }\n            }\n        }\n    }\n\n    private fun startMainActivity() {\n        val intent = Intent(this, MainActivity::class.java)\n        intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK\n        startActivity(intent)\n        finish()\n    }\n\n    override fun onPause() {\n        super.onPause()\n        finish()\n    }\n}\n\n\n\n\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/bookmarkeditor/BookmarkEditorScreen.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.bookmarkeditor\n\nimport android.util.Log\nimport androidx.activity.compose.BackHandler\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Error\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.MutableState\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport com.desarrollodroide.pagekeeper.ui.components.ConfirmDialog\nimport com.desarrollodroide.pagekeeper.ui.components.InfiniteProgressDialog\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.model.Tag\nimport org.koin.androidx.compose.get\n\n@Composable\nfun BookmarkEditorScreen(\n    pageTitle: String,\n    bookmarkEditorType: BookmarkEditorType,\n    bookmark: Bookmark,\n    onBack: () -> Unit,\n    updateBookmark: (Bookmark) -> Unit,\n    showToast: (String) -> Unit = {},\n    startMainActivity: () -> Unit = {}\n) {\n    val bookmarkViewModel = get<BookmarkViewModel>()\n    val newTag = remember { mutableStateOf(\"\") }\n    val availableTags = bookmarkViewModel.availableTags.collectAsState()\n    val bookmarkUiState = bookmarkViewModel.bookmarkUiState.collectAsState().value\n    var currentUrl by remember { mutableStateOf(bookmark.url) }\n\n    // No need to update values in settings\n    var localCreateEbook by remember { mutableStateOf(bookmarkViewModel.createEbook) }\n    var localCreateArchive by remember { mutableStateOf(bookmarkViewModel.createArchive) }\n    val assignedTags: MutableState<List<Tag>> = remember { mutableStateOf(bookmark.tags) }\n        var localMakeArchivePublic by remember {\n        mutableStateOf(\n            when (bookmarkEditorType) {\n                BookmarkEditorType.ADD, BookmarkEditorType.ADD_MANUALLY -> bookmarkViewModel.makeArchivePublic\n                BookmarkEditorType.EDIT -> bookmark.public == 1\n            }\n        )\n    }\n    BackHandler {\n        onBack()\n    }\n    if (bookmarkUiState.isLoading) {\n        Log.v(\"BookmarkEditorScreen\", \"isLoading\")\n        InfiniteProgressDialog(onDismissRequest = {})\n    }\n    if (!bookmarkUiState.error.isNullOrEmpty()) {\n        Log.v(\"BookmarkEditorScreen\", \"Error\")\n        ConfirmDialog(\n            icon = Icons.Default.Error,\n            title = \"Error\",\n            content = bookmarkUiState.error,\n            dismissButton = if (bookmarkViewModel.sessionExpired) \"Go to login\" else \"\",\n            confirmButton = \"Accept\",\n            openDialog = remember { mutableStateOf(true) },\n            onDismiss = {\n                if (bookmarkViewModel.sessionExpired) {\n                    startMainActivity()\n                }\n            },\n            onConfirm = {}\n        )\n    }\n\n    BookmarkEditorView(\n        title = pageTitle,\n        url = currentUrl,\n        bookmarkEditorType = bookmarkEditorType,\n        newTag = newTag,\n        assignedTags = assignedTags,\n        availableTags = availableTags,\n        saveBookmark = {\n            when (bookmarkEditorType) {\n                BookmarkEditorType.ADD, BookmarkEditorType.ADD_MANUALLY -> {\n                    bookmarkViewModel.saveBookmark(\n                        url = currentUrl,\n                        title = bookmark.title,\n                        tags = assignedTags.value,\n                        createArchive = localCreateArchive,\n                        makeArchivePublic = localMakeArchivePublic,\n                        createEbook = localCreateEbook\n                    )\n                }\n                BookmarkEditorType.EDIT -> {\n                    bookmarkViewModel.editBookmark(\n                        bookmark = bookmark.copy(\n                            tags = assignedTags.value,\n                            createEbook = bookmark.hasEbook,\n                            createArchive = bookmark.hasArchive,\n                            public = if (localMakeArchivePublic) 1 else 0,\n                        )\n                    )\n                }\n            }\n            onBack()\n        },\n        onBackClick = onBack,\n        createArchive = localCreateArchive,\n        makeArchivePublic = localMakeArchivePublic,\n        onMakeArchivePublicChanged = { localMakeArchivePublic = it },\n        createEbook = localCreateEbook,\n        onCreateEbookChanged = { localCreateEbook = it },\n        onCreateArchiveChanged = { localCreateArchive = it },\n        onUrlChange = { currentUrl = it }\n    )\n}\n\n\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/bookmarkeditor/BookmarkEditorView.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.bookmarkeditor\n\nimport androidx.compose.foundation.BorderStroke\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.border\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.ExperimentalLayoutApi\nimport androidx.compose.foundation.layout.FlowRow\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.heightIn\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.ArrowBack\nimport androidx.compose.material.icons.automirrored.filled.Label\nimport androidx.compose.material.icons.outlined.Save\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.Checkbox\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.OutlinedTextField\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.MutableState\nimport androidx.compose.runtime.State\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.ui.Alignment.Companion.CenterVertically\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.text.style.TextOverflow\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\nimport com.desarrollodroide.pagekeeper.ui.components.Categories\nimport com.desarrollodroide.pagekeeper.ui.components.CategoriesType\nimport com.desarrollodroide.model.Tag\n\nenum class BookmarkEditorType { ADD, ADD_MANUALLY, EDIT }\n@Composable\nfun BookmarkEditorView(\n    title: String,\n    url: String,\n    bookmarkEditorType: BookmarkEditorType,\n    newTag: MutableState<String>,\n    assignedTags: MutableState<List<Tag>>,\n    availableTags: State<List<Tag>>,\n    saveBookmark: (BookmarkEditorType) -> Unit,\n    onBackClick: () -> Unit,\n    createArchive: Boolean,\n    onCreateArchiveChanged: (Boolean) -> Unit,\n    makeArchivePublic: Boolean,\n    onMakeArchivePublicChanged: (Boolean) -> Unit,\n    createEbook: Boolean,\n    onCreateEbookChanged: (Boolean) -> Unit,\n    onUrlChange: (String) -> Unit = {}\n) {\n    Column(\n        modifier = Modifier\n            .background(MaterialTheme.colorScheme.background)\n            .padding(horizontal = 16.dp)\n            .padding(top = 16.dp)\n    ) {\n        Row(\n            modifier = Modifier.fillMaxWidth(),\n            verticalAlignment = CenterVertically,\n            horizontalArrangement = Arrangement.SpaceBetween\n        ) {\n            IconButton(onClick = onBackClick) {\n                Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = \"Back\")\n            }\n\n            Text(\n                text = title,\n                fontSize = 24.sp,\n                fontWeight = FontWeight.Bold,\n                modifier = Modifier.align(CenterVertically)\n            )\n            IconButton(onClick = {\n                saveBookmark(bookmarkEditorType)\n            }) {\n                Icon(Icons.Outlined.Save, contentDescription = \"Save\")\n            }\n        }\n        if (bookmarkEditorType == BookmarkEditorType.ADD_MANUALLY) {\n            OutlinedTextField(\n                value = url,\n                onValueChange = onUrlChange,\n                label = { Text(\"URL\") },\n                modifier = Modifier\n                    .fillMaxWidth()\n                    .padding(vertical = 8.dp),\n                singleLine = true\n            )\n        } else {\n            Text(\n                text = url,\n                color = MaterialTheme.colorScheme.onSurface,\n                modifier = Modifier\n                    .fillMaxWidth()\n                    .background(MaterialTheme.colorScheme.surfaceVariant)\n                    .padding(8.dp),\n                maxLines = 3,\n                overflow = TextOverflow.Ellipsis\n            )\n        }\n\n        if (bookmarkEditorType == BookmarkEditorType.ADD || bookmarkEditorType == BookmarkEditorType.ADD_MANUALLY) {\n            Row(verticalAlignment = CenterVertically) {\n                Checkbox(\n                    checked = createArchive,\n                    onCheckedChange = onCreateArchiveChanged\n                )\n                Text(\"Create archive\")\n            }\n            Row(verticalAlignment = CenterVertically) {\n                Checkbox(\n                    checked = createEbook,\n                    onCheckedChange = onCreateEbookChanged\n                )\n                Text(\"Create Ebook\")\n            }\n        }\n        Row(verticalAlignment = CenterVertically) {\n            Checkbox(\n                checked = makeArchivePublic,\n                onCheckedChange = onMakeArchivePublicChanged\n            )\n            Text(\"Make bookmark publicly available\")\n        }\n\n        Row {\n            OutlinedTextField(\n                modifier = Modifier\n                    .weight(1f)\n                    .align(CenterVertically),\n                value = newTag.value,\n                onValueChange = { newTag.value = it },\n                label = { Text(\"Add Tag\") },\n                singleLine = true,\n                leadingIcon = {\n                    Icon(Icons.AutoMirrored.Filled.Label, contentDescription = \"Tag\")\n                }\n            )\n            Spacer(modifier = Modifier.width(10.dp))\n            Button(\n                modifier = Modifier\n                    .align(CenterVertically)\n                    .padding(top = 4.dp),\n                onClick = {\n                    val normalizedName = newTag.value.lowercase().trim()\n                    if (normalizedName.isNotBlank() && !assignedTags.value.any { it.name.lowercase() == normalizedName }) {\n                        assignedTags.value = assignedTags.value + Tag(id = -1, name = normalizedName)\n                        newTag.value = \"\"\n                    }\n                }\n            ) {\n                Text(text = \"Add\")\n            }\n        }\n        Spacer(modifier = Modifier.height(10.dp))\n        Column(\n            Modifier\n                .background(MaterialTheme.colorScheme.surfaceVariant)\n                .heightIn(max = 145.dp)\n                .fillMaxWidth()\n                .border(\n                    BorderStroke(1.dp, MaterialTheme.colorScheme.primary),\n                    RoundedCornerShape(4.dp)\n                )\n                .padding(horizontal = 6.dp)\n                .verticalScroll(rememberScrollState())\n        ) {\n            Categories(\n                categoriesType = CategoriesType.REMOVEABLES,\n                showCategories = true,\n                uniqueCategories = assignedTags.value,\n                selectedTags = assignedTags.value,\n                onCategorySelected = { /* No se usa en modo REMOVEABLES */ },\n                onCategoryDeselected = { deselectedTag ->\n                    assignedTags.value = assignedTags.value.filter { it != deselectedTag }\n                }\n            )\n        }\n        Spacer(modifier = Modifier.heightIn(10.dp))\n        Text(\n            style = MaterialTheme.typography.titleMedium,\n            text = \"All Tags\"\n        )\n        Spacer(modifier = Modifier.heightIn(5.dp))\n        Column(\n            Modifier\n                .weight(1f)\n                .verticalScroll(rememberScrollState())\n        ) {\n            TagsSelectorView(\n                availableTags = availableTags.value,\n                onTagSelected = {\n                    if (!assignedTags.value.contains(it)) {\n                        assignedTags.value = assignedTags.value + it\n                    }\n                }\n            )\n        }\n    }\n}\n\n@Composable\n@OptIn(ExperimentalLayoutApi::class)\nprivate fun TagsSelectorView(\n    availableTags: List<Tag>,\n    onTagSelected: (Tag) -> Unit\n) {\n    FlowRow(\n    ) {\n        availableTags.forEach { category ->\n            Text(\n                color = Color.DarkGray,\n                modifier = Modifier\n                    .padding(5.dp)\n                    .clip(RoundedCornerShape(18.dp))\n                    .background(Color(0xFFEAEDED))\n                    .clickable { onTagSelected(category) }\n                    .padding(vertical = 8.dp, horizontal = 16.dp),\n                text = category.name\n            )\n        }\n    }\n}\n\n\n@Preview(showBackground = true)\n@Composable\nfun BookmarkEditorPreview() {\n    val tag1 = Tag(\n        id = 1,\n        name = \"tag1\",\n        selected = true,\n        nBookmarks = 0\n    )\n    val tag2 = Tag(\n        id = 2,\n        name = \"tag2\",\n        selected = false,\n        nBookmarks = 0\n    )\n    val assignedTags = remember { mutableStateOf(listOf<Tag>(tag1, tag2)) }\n    val newTag = remember { mutableStateOf(\"\")}\n\n    BookmarkEditorView(\n        title = \"Add\",\n        url = \"http://www.google.com\",\n        bookmarkEditorType = BookmarkEditorType.ADD,\n        assignedTags = remember { mutableStateOf(generateRandomTags(100)) },\n        saveBookmark = {},\n        availableTags = assignedTags,\n        newTag = newTag,\n        onBackClick = {},\n        makeArchivePublic = true,\n        createArchive = false,\n        createEbook = false,\n        onMakeArchivePublicChanged = {},\n        onCreateEbookChanged = {},\n        onCreateArchiveChanged = {}\n    )\n\n}\n\nprivate fun generateRandomTagName(length: Int): String {\n    val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9')\n    return (1..length)\n        .map { allowedChars.random() }\n        .joinToString(\"\")\n}\n\nprivate fun generateRandomTags(count: Int): List<Tag> {\n    return List(count) {\n        Tag(id = count,name = generateRandomTagName(10))\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/bookmarkeditor/BookmarkViewModel.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.bookmarkeditor\n\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport com.desarrollodroide.pagekeeper.ui.components.UiState\nimport com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource\nimport com.desarrollodroide.data.local.room.dao.BookmarksDao\nimport com.desarrollodroide.data.repository.BookmarksRepository\nimport com.desarrollodroide.domain.usecase.AddBookmarkUseCase\nimport com.desarrollodroide.domain.usecase.EditBookmarkUseCase\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.model.Tag\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.asStateFlow\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.flow.map\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport kotlinx.coroutines.runBlocking\n\nclass BookmarkViewModel(\n    bookmarkDatabase: BookmarksDao,\n    private val bookmarkAdditionUseCase: AddBookmarkUseCase,\n    private val bookmarksRepository: BookmarksRepository,\n    private val editBookmarkUseCase: EditBookmarkUseCase,\n    private val userPreferences: SettingsPreferenceDataSource,\n    private val settingsPreferenceDataSource: SettingsPreferenceDataSource,\n) : ViewModel() {\n\n    var backendUrl = \"\"\n    var sessionExpired = false\n    var autoAddBookmark: Boolean = false\n        private set\n\n    private val _bookmarkUiState = MutableStateFlow(UiState<Bookmark>(idle = true))\n    val bookmarkUiState = _bookmarkUiState.asStateFlow()\n\n    private val _toastMessage = MutableStateFlow<String?>(null)\n    val toastMessage = _toastMessage.asStateFlow()\n\n    init {\n        viewModelScope.launch {\n            backendUrl = userPreferences.getUrl()\n            initializePreferences()\n        }\n\n    }\n\n    private suspend fun initializePreferences() {\n        makeArchivePublic = settingsPreferenceDataSource.makeArchivePublicFlow.first()\n        createEbook = settingsPreferenceDataSource.createEbookFlow.first()\n        createArchive = settingsPreferenceDataSource.createArchiveFlow.first()\n        autoAddBookmark = settingsPreferenceDataSource.autoAddBookmarkFlow.first()\n    }\n\n    var makeArchivePublic: Boolean = false\n    var createEbook: Boolean = false\n    var createArchive: Boolean = false\n\n    val availableTags: StateFlow<List<Tag>> = bookmarkDatabase.getAll()\n        .map { bookmarks ->\n            bookmarks.flatMap { it.tags }.distinct()\n        }\n        .stateIn(\n            scope = viewModelScope,\n            started = SharingStarted.WhileSubscribed(5000),\n            initialValue = listOf()\n        )\n\n    fun autoAddBookmark(\n        url: String,\n        title: String,\n    ) = viewModelScope.launch {\n        saveBookmark(\n            url = url,\n            title = title,\n            tags = emptyList(),\n            createArchive = createArchive,\n            makeArchivePublic = makeArchivePublic,\n            createEbook = createEbook\n        )\n    }\n\n    fun saveBookmark(\n        url: String,\n        title: String,\n        tags: List<Tag>,\n        createArchive: Boolean,\n        makeArchivePublic: Boolean,\n        createEbook: Boolean\n    ) = viewModelScope.launch {\n        bookmarkAdditionUseCase.invoke(\n            bookmark = Bookmark(\n                url = url,\n                title = title,\n                tags = tags,\n                createArchive = createArchive,\n                createEbook = createEbook,\n                public = if (makeArchivePublic) 1 else 0\n            )\n        )\n    }\n\n    fun editBookmark(bookmark: Bookmark) = viewModelScope.launch {\n        viewModelScope.launch {\n            editBookmarkUseCase.invoke(\n                bookmark = bookmark\n            )\n        }\n    }\n\n    fun userHasSession() = runBlocking {\n        userPreferences.getUser().first().hasSession()\n    }\n\n    private fun emitToastIfAutoAdd(message: String) {\n        viewModelScope.launch {\n            if (autoAddBookmark) {\n                _toastMessage.value = message\n            }\n        }\n    }\n\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/bookmarkeditor/NotSessionScreen.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.bookmarkeditor\n\nimport androidx.compose.foundation.Image\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.ColorFilter\nimport androidx.compose.ui.layout.ContentScale\nimport androidx.compose.ui.res.painterResource\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.unit.dp\nimport com.desarrollodroide.pagekeeper.R\nimport com.desarrollodroide.pagekeeper.ui.theme.ShioriTheme\n\n@Composable\nfun NotSessionScreen(\n    onClickLogin: () -> Unit\n) {\n    Column(\n        modifier = Modifier\n            .fillMaxSize(),\n    ) {\n        Image(\n            painter = painterResource(id = R.drawable.img_authentication_failed),\n            contentDescription = null,\n            colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),\n            contentScale = ContentScale.Inside,\n            modifier = Modifier\n                .fillMaxWidth()\n                .padding(top = 100.dp)\n                .height(200.dp)\n        )\n        Spacer(modifier = Modifier.height(20.dp))\n        Text(\n            modifier = Modifier.padding(40.dp),\n            textAlign = TextAlign.Center,\n            color = MaterialTheme.colorScheme.primary,\n            text = \"Session not found, please log in and try again.\",\n            style = MaterialTheme.typography.headlineLarge\n        )\n        Spacer(modifier = Modifier.weight(1f))\n        Button(\n            onClick = onClickLogin,\n            modifier = Modifier.fillMaxWidth().padding(20.dp),\n            content = {\n                Text(\"Login\")\n            },\n        )\n    }\n}\n\n@Preview\n@Composable\nfun NotSessionScreenPreview() {\n    ShioriTheme {\n        NotSessionScreen(\n            onClickLogin = { }\n        )\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/bookmarkeditor/ProgressButton.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.bookmarkeditor\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.fillMaxHeight\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\n\n@Composable\nfun ProgressButton(\n    progress: Float,\n    onClick: () -> Unit\n) {\n    Box(\n        modifier = Modifier\n            .fillMaxWidth()\n            .height(48.dp)\n            .clip(RoundedCornerShape(24.dp))\n            .background(MaterialTheme.colorScheme.primaryContainer)\n            .clickable(onClick = onClick)\n    ) {\n        Box(\n            modifier = Modifier\n                .fillMaxHeight()\n                .fillMaxWidth(progress)\n                .background(MaterialTheme.colorScheme.primary)\n        )\n        Box(\n            modifier = Modifier.fillMaxSize(),\n            contentAlignment = Alignment.Center\n        ) {\n            Text(\n                text = \"Closing\",\n                color = Color.White,\n                fontSize = 16.sp,\n                fontWeight = FontWeight.Medium\n            )\n        }\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/CategoriesView.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.components\n\nimport android.util.Log\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.ExperimentalLayoutApi\nimport androidx.compose.foundation.layout.FlowRow\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Delete\nimport androidx.compose.material.icons.filled.Done\nimport androidx.compose.material3.FilterChip\nimport androidx.compose.material3.FilterChipDefaults\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.text.style.TextOverflow\nimport androidx.compose.ui.unit.dp\nimport com.desarrollodroide.model.Tag\n\nenum class CategoriesType {\n    SELECTABLES, REMOVEABLES\n}\n\n@Composable\n@OptIn(ExperimentalLayoutApi::class)\nfun Categories(\n    categoriesType: CategoriesType = CategoriesType.SELECTABLES,\n    showCategories: Boolean,\n    uniqueCategories: List<Tag>,\n    selectedTags: List<Tag>,\n    onCategorySelected: (Tag) -> Unit,\n    onCategoryDeselected: (Tag) -> Unit,\n    singleSelection: Boolean = false\n) {\n    AnimatedVisibility(showCategories) {\n        Column {\n            FlowRow {\n                uniqueCategories.forEach { category ->\n                    val selected = selectedTags.any { it.name == category.name }\n                    FilterChip(\n                        colors = FilterChipDefaults.filterChipColors(\n                            containerColor = MaterialTheme.colorScheme.surface,\n                            labelColor = MaterialTheme.colorScheme.onSurface,\n                            iconColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),\n                            disabledContainerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f),\n                            disabledLabelColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),\n                            selectedContainerColor = MaterialTheme.colorScheme.secondary,\n                            disabledSelectedContainerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f),\n                            selectedLabelColor = MaterialTheme.colorScheme.onSecondary,\n                            selectedLeadingIconColor = MaterialTheme.colorScheme.onSecondary\n                        ),\n                        selected = selected,\n                        label = { Text(category.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },\n                        modifier = Modifier.padding(horizontal = 4.dp),\n                        shape = RoundedCornerShape(12.dp),\n                        onClick = {\n                            when (categoriesType) {\n                                CategoriesType.SELECTABLES -> {\n                                    if (singleSelection) {\n                                        selectedTags.forEach {\n                                            onCategoryDeselected(it)\n                                        }\n                                        if (!selected) {\n                                            onCategorySelected(category)\n                                        }\n                                    } else {\n                                        if (selected) {\n                                            onCategoryDeselected(category)\n                                        } else {\n                                            onCategorySelected(category)\n                                        }\n                                    }\n                                }\n                                CategoriesType.REMOVEABLES -> {\n                                    onCategoryDeselected(category)\n                                }\n                            }\n                        },\n                        leadingIcon = {\n                            when(categoriesType){\n                                CategoriesType.SELECTABLES -> {\n                                    if (selected) {\n                                        Icon(\n                                            imageVector = Icons.Filled.Done,\n                                            contentDescription = null,\n                                            modifier = Modifier.size(FilterChipDefaults.IconSize)\n                                        )\n                                    }\n                                }\n                                CategoriesType.REMOVEABLES -> {\n                                    Icon(\n                                        imageVector = Icons.Filled.Delete,\n                                        contentDescription = null,\n                                        modifier = Modifier.size(FilterChipDefaults.IconSize)\n                                    )\n                                }\n                            }\n                        }\n                    )\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/Dialogs.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.components\n\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Error\nimport androidx.compose.material3.*\nimport androidx.compose.material3.AlertDialog\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.MutableState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Alignment.Companion.CenterHorizontally\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.compose.ui.res.dimensionResource\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.window.Dialog\nimport androidx.compose.ui.window.DialogProperties\nimport com.desarrollodroide.pagekeeper.R\n\n@Composable\nfun SimpleDialog(\n    title: String = \"\",\n    content: String = \"\",\n    icon: ImageVector? = null,\n    confirmButtonText: String = \"\",\n    dismissButtonText: String = \"\",\n    openDialog: MutableState<Boolean>,\n    onConfirm: (() -> Unit)? = null,\n    onDismiss: (() -> Unit)? = null,\n    properties: DialogProperties = DialogProperties(),\n) {\n    if (openDialog.value) {\n        AlertDialog(\n            onDismissRequest = {\n                // Dismiss the dialog when the user clicks outside the dialog or on the back\n                // button. If you want to disable that functionality, simply use an empty\n                // onDismissRequest.\n                openDialog.value = false\n            },\n            icon = { if (icon != null) Icon(imageVector = icon, contentDescription = null) },\n            title = {\n                if (title.isNotEmpty()) {\n                    Text(text = title)\n                }\n            },\n            text = {\n                if (content.isNotEmpty()) {\n                    Box(\n                        modifier = Modifier.fillMaxWidth(),\n                        contentAlignment = Alignment.Center\n                    ) {\n                        Text(\n                            modifier = Modifier\n                                .align(Alignment.Center),\n                            text = content\n                        )\n                    }\n                }\n            },\n            confirmButton = {\n                if (confirmButtonText.isNotEmpty()) {\n                    TextButton(\n                        onClick = {\n                            openDialog.value = false\n                            onConfirm?.invoke()\n                        }\n                    ) {\n                        Text(confirmButtonText)\n                    }\n                }\n            },\n            dismissButton = {\n                if (dismissButtonText.isNotEmpty()) {\n                    TextButton(\n                        onClick = {\n                            openDialog.value = false\n                            onDismiss?.invoke()\n                        }\n                    ) {\n                        Text(dismissButtonText)\n                    }\n                }\n            },\n            properties = properties\n        )\n    }\n}\n\n@Composable\nfun ConfirmDialog(\n    title: String = \"\",\n    content: String = \"\",\n    confirmButton: String = \"Accept\",\n    dismissButton: String = \"\",\n    icon: ImageVector? = null,\n    onConfirm: (() -> Unit)? = null,\n    onDismiss: (() -> Unit)? = null,\n    openDialog: MutableState<Boolean>,\n    properties: DialogProperties = DialogProperties(),\n) {\n    SimpleDialog(\n        title = title,\n        content = content,\n        icon = icon,\n        onConfirm = onConfirm,\n        onDismiss = onDismiss,\n        confirmButtonText = confirmButton,\n        dismissButtonText = dismissButton,\n        openDialog = openDialog,\n        properties = properties\n    )\n}\n\n@Composable\nfun InfiniteProgressDialog(\n    title: String? = null,\n    properties: DialogProperties = DialogProperties(),\n    onDismissRequest: () -> Unit\n) {\n    Dialog(\n        onDismissRequest = onDismissRequest,\n        properties = properties\n    ) {\n        Column(\n            horizontalAlignment = CenterHorizontally\n        ) {\n            Surface(\n                modifier = Modifier.clip(CircleShape),\n            ) {\n                Column(\n                    modifier = Modifier\n                        .padding(dimensionResource(id = R.dimen.progressDialog_margin))\n                ) {\n                    CircularProgressIndicator(\n                        strokeWidth = dimensionResource(id = R.dimen.progressDialog_stroke),\n                        modifier = Modifier\n                            .height(dimensionResource(id = R.dimen.progressDialog_size))\n                            .width(dimensionResource(id = R.dimen.progressDialog_size))\n                    )\n\n                }\n            }\n            Spacer(modifier = Modifier.height(20.dp))\n            if (title != null) {\n                Surface(\n                    modifier = Modifier\n                        .clip(RoundedCornerShape(20)),\n                ) {\n                    Text(\n                        modifier = Modifier.padding(vertical = 10.dp, horizontal = 15.dp),\n                        text = title\n                    )\n                }\n            }\n        }\n    }\n}\n\n@Composable\nfun ErrorDialog(\n    title: String = \"\",\n    content: String = \"\",\n    openDialog: MutableState<Boolean>,\n    onConfirm: (() -> Unit)? = null,\n) {\n    SimpleDialog(\n        title = title,\n        content = content,\n        icon = Icons.Default.Error,\n        confirmButtonText = \"Accept\",\n        openDialog = openDialog,\n        onConfirm = onConfirm,\n    )\n}\n\n@Composable\nfun UpdateCacheDialog(\n    isLoading: Boolean,\n    showDialog: MutableState<Boolean>,\n    onConfirm: (keepOldTitle: Boolean, updateArchive: Boolean, updateEbook: Boolean) -> Unit,\n) {\n    if (showDialog.value) {\n        var keepOldTitleChecked by remember { mutableStateOf(false) }\n        var updateArchiveChecked by remember { mutableStateOf(false) }\n        var updateEbookChecked by remember { mutableStateOf(false) }\n\n        val wasLoading = remember { mutableStateOf(isLoading) }\n        LaunchedEffect(isLoading) {\n            if (wasLoading.value && !isLoading) {\n                showDialog.value = false\n            }\n            wasLoading.value = isLoading\n        }\n\n        AlertDialog(\n            onDismissRequest = {\n                showDialog.value = false\n            },\n            title = { Text(\"Update cache for selected bookmark? This action is irreversible.\") },\n            text = {\n                Column {\n                    Row(\n                        verticalAlignment = Alignment.CenterVertically,\n                        modifier = Modifier\n                            .clickable(enabled = !isLoading) {\n                                keepOldTitleChecked = !keepOldTitleChecked\n                            }\n                            .padding(8.dp)\n                    ) {\n                        Checkbox(\n                            enabled = !isLoading,\n                            checked = keepOldTitleChecked,\n                            onCheckedChange = null\n                        )\n                        Text(\"Keep the old title and excerpt\", modifier = Modifier.padding(start = 8.dp))\n                    }\n                    Row(\n                        verticalAlignment = Alignment.CenterVertically,\n                        modifier = Modifier\n                            .clickable(enabled = !isLoading) {\n                                updateArchiveChecked = !updateArchiveChecked\n                            }\n                            .padding(8.dp)\n                    ) {\n                        Checkbox(\n                            enabled = !isLoading,\n                            checked = updateArchiveChecked,\n                            onCheckedChange = null\n                        )\n                        Text(\"Update archive as well\", modifier = Modifier.padding(start = 8.dp))\n                    }\n                    Row(\n                        verticalAlignment = Alignment.CenterVertically,\n                        modifier = Modifier\n                            .clickable(enabled = !isLoading) {\n                                updateEbookChecked = !updateEbookChecked\n                            }\n                            .padding(8.dp)\n                    ) {\n                        Checkbox(\n                            enabled = !isLoading,\n                            checked = updateEbookChecked,\n                            onCheckedChange = null\n                        )\n                        Text(\"Update Ebook as well\", modifier = Modifier.padding(start = 8.dp))\n                    }\n                }\n            },\n            confirmButton = {\n                LoadingButton(\n                    text = \"Update\",\n                    onClick = {\n                        onConfirm(keepOldTitleChecked, updateArchiveChecked, updateEbookChecked)\n                    },\n                    loading = isLoading)\n            },\n\n            dismissButton = {\n                AnimatedVisibility (\n                    enter = fadeIn(),\n                    exit = fadeOut(),\n                    visible = !isLoading\n                ){\n                    Button(onClick = {\n                        showDialog.value = false\n                    }) {\n                        Text(\"Cancel\")\n                    }\n                }\n            },\n            properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true)\n        )\n    }\n}\n\n@Composable\nfun EpubOptionsDialog(\n    title: String = \"\",\n    content: String = \"\",\n    icon: ImageVector? = null,\n    onClickOption: ((Int) -> Unit)? = null,\n    properties: DialogProperties = DialogProperties(),\n    showDialog: MutableState<Boolean>\n) {\n    if (showDialog.value) {\n        AlertDialog(\n            onDismissRequest = {\n                showDialog.value = false\n            },\n            icon = { if (icon != null) Icon(imageVector = icon, contentDescription = null) },\n            title = {\n                if (title.isNotEmpty()) {\n                    Text(text = title)\n                }\n            },\n            text = {\n                if (content.isNotEmpty()) {\n                    Box(\n                        modifier = Modifier.fillMaxWidth(),\n                        contentAlignment = Alignment.Center\n                    ) {\n                        Text(\n                            modifier = Modifier\n                                .align(Alignment.Center),\n                            text = content\n                        )\n                    }\n                }\n            },\n            confirmButton = {\n                Row(\n                    modifier = Modifier.padding(all = 8.dp),\n                    horizontalArrangement = Arrangement.SpaceBetween\n                ) {\n                    TextButton(onClick = {\n                        showDialog.value = false\n                    }) {\n                        Text(\"Cancel\")\n                    }\n                    Spacer(modifier = Modifier.weight(1f))\n                    TextButton(onClick = {\n                        showDialog.value = false\n                        onClickOption?.invoke(2)\n                    }) {\n                        Text(\"Share\")\n                    }\n                }\n            },\n            properties = properties\n        )\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/LoadingButton.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.components\n\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.ExperimentalAnimationApi\nimport androidx.compose.animation.core.Spring\nimport androidx.compose.animation.core.Transition\nimport androidx.compose.animation.core.VisibilityThreshold\nimport androidx.compose.animation.core.animateDp\nimport androidx.compose.animation.core.spring\nimport androidx.compose.animation.core.updateTransition\nimport androidx.compose.animation.expandHorizontally\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.shrinkHorizontally\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.PaddingValues\nimport androidx.compose.foundation.layout.defaultMinSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.CircularProgressIndicator\nimport androidx.compose.material3.LocalContentColor\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.StrokeCap\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.unit.IntSize\nimport androidx.compose.ui.unit.dp\n\n@Composable\nfun LoadingButton(\n    text:  String,\n    onClick: () -> Unit,\n    loading: Boolean,\n) {\n    val transition = updateTransition(\n        targetState = loading,\n        label = \"master transition\",\n    )\n    val horizontalContentPadding by transition.animateDp(\n        transitionSpec = {\n            spring(\n                stiffness = SpringStiffness,\n            )\n        },\n        targetValueByState = { toLoading -> if (toLoading) 12.dp else 24.dp },\n        label = \"button's content padding\",\n    )\n    Button(\n        onClick = onClick,\n        modifier = Modifier.defaultMinSize(minWidth = 1.dp),\n        contentPadding = PaddingValues(\n            horizontal = horizontalContentPadding,\n            vertical = 8.dp,\n        ),\n    ) {\n        Box(contentAlignment = Alignment.Center) {\n            LoadingContent(\n                loadingStateTransition = transition,\n            )\n            PrimaryContent(\n                text = text,\n                loadingStateTransition = transition,\n            )\n        }\n    }\n}\n\n@OptIn(ExperimentalAnimationApi::class)\n@Composable\nprivate fun LoadingContent(\n    loadingStateTransition: Transition<Boolean>,\n) {\n    loadingStateTransition.AnimatedVisibility(\n        visible = { loading -> loading },\n        enter = fadeIn(),\n        exit = fadeOut(\n            animationSpec = spring(\n                stiffness = SpringStiffness,\n                visibilityThreshold = 0.10f,\n            ),\n        ),\n    ) {\n        CircularProgressIndicator(\n            modifier = Modifier.size(18.dp),\n            color = LocalContentColor.current,\n            strokeWidth = 1.5f.dp,\n            strokeCap = StrokeCap.Round,\n        )\n    }\n}\n\n@OptIn(ExperimentalAnimationApi::class)\n@Composable\nprivate fun PrimaryContent(\n    loadingStateTransition: Transition<Boolean>,\n    text: String,\n) {\n    loadingStateTransition.AnimatedVisibility(\n        visible = { loading -> !loading },\n        enter = fadeIn() + expandHorizontally(\n            animationSpec = spring(\n                stiffness = SpringStiffness,\n                dampingRatio = Spring.DampingRatioMediumBouncy,\n                visibilityThreshold = IntSize.VisibilityThreshold,\n            ),\n            expandFrom = Alignment.CenterHorizontally,\n        ),\n        exit = fadeOut(\n            animationSpec = spring(\n                stiffness = SpringStiffness,\n                visibilityThreshold = 0.10f,\n            ),\n        ) + shrinkHorizontally(\n            animationSpec = spring(\n                stiffness = SpringStiffness,\n                // dampingRatio is not applicable here, size cannot become negative\n                visibilityThreshold = IntSize.VisibilityThreshold,\n            ),\n            shrinkTowards = Alignment.CenterHorizontally,\n        ),\n    ) {\n        Text(\n            text = text,\n            modifier = Modifier\n                // so that bouncing button's width doesn't cut first and last letters\n                .padding(horizontal = 4.dp),\n        )\n    }\n}\n\n// use same spring stiffness so that all animations finish at about the same time\nprivate val SpringStiffness = Spring.StiffnessMediumLow\n\n@Preview\n@Composable\nprivate fun LoadingButtonPreview() {\n    Box(\n        modifier = Modifier.fillMaxWidth(),\n        contentAlignment = Alignment.Center,\n    ) {\n        LoadingButton(\n            text = \"Login\",\n            onClick = {},\n            loading = false,\n        )\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/UiState.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.components\n\nimport kotlinx.coroutines.flow.MutableStateFlow\n\ndata class UiState<T>(\n    val isLoading: Boolean = false,\n    val isUpdating: Boolean = false,\n    val error: String? = null,\n    val data: T? = null,\n    val idle: Boolean = true\n)\n\nfun <T> UiState<T>.success(data: T) = copy(isLoading = false, data = data, error = null, idle = false, isUpdating = false)\n\nfun <T> UiState<T>.error(error: String) = copy(isLoading = false, data = null, error = error, idle = false, isUpdating = false)\n\n\nfun <T> MutableStateFlow<UiState<T>>.success(data: T?) {\n    value = value.copy(isLoading = false, data = data, error = null, idle = false, isUpdating = false)\n}\n\nfun <T> MutableStateFlow<UiState<T>>.error(errorMessage: String) {\n    value = value.copy(isLoading = false, data = null, error = errorMessage, idle = false, isUpdating = false)\n}\n\nfun <T> MutableStateFlow<UiState<T>>.isLoading(isLoading: Boolean) {\n    value = value.copy(isLoading = isLoading, data = null, error = null, idle = false, isUpdating = false)\n}\n\nfun <T> MutableStateFlow<UiState<T>>.idle(isIdle: Boolean) {\n    value = value.copy(isLoading = false, data = null, error = null, idle = isIdle, isUpdating = false)\n}\n\nfun <T> MutableStateFlow<UiState<T>>.isUpdating(isUpdating: Boolean) {\n    value = value.copy(isLoading = false, data = value.data, error = null, idle = false, isUpdating = isUpdating)\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/pulltorefresh/PullRefresh.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.components.pulltorefresh\n\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.geometry.Offset\nimport androidx.compose.ui.input.nestedscroll.NestedScrollConnection\nimport androidx.compose.ui.input.nestedscroll.NestedScrollSource\nimport androidx.compose.ui.input.nestedscroll.NestedScrollSource.Companion.Drag\nimport androidx.compose.ui.input.nestedscroll.nestedScroll\nimport androidx.compose.ui.platform.debugInspectorInfo\nimport androidx.compose.ui.platform.inspectable\nimport androidx.compose.ui.unit.Velocity\n\n/**\n * A nested scroll modifier that provides scroll events to [state].\n *\n * Note that this modifier must be added above a scrolling container, such as a lazy column, in\n * order to receive scroll events. For example:\n *\n * @sample androidx.compose.material.samples.PullRefreshSample\n *\n * @param state The [PullRefreshState] associated with this pull-to-refresh component.\n * The state will be updated by this modifier.\n * @param enabled If not enabled, all scroll delta and fling velocity will be ignored.\n */\n// TODO(b/244423199): Move pullRefresh into its own material library similar to material-ripple.\nfun Modifier.pullRefresh(\n    state: PullRefreshState,\n    enabled: Boolean = true,\n) = inspectable(inspectorInfo = debugInspectorInfo {\n    name = \"pullRefresh\"\n    properties[\"state\"] = state\n    properties[\"enabled\"] = enabled\n}) {\n    Modifier.pullRefresh(state::onPull, state::onRelease, enabled)\n}\n\n/**\n * A nested scroll modifier that provides [onPull] and [onRelease] callbacks to aid building custom\n * pull refresh components.\n *\n * Note that this modifier must be added above a scrolling container, such as a lazy column, in\n * order to receive scroll events. For example:\n *\n * @sample androidx.compose.material.samples.CustomPullRefreshSample\n *\n * @param onPull Callback for dispatching vertical scroll delta, takes float pullDelta as argument.\n * Positive delta (pulling down) is dispatched only if the child does not consume it (i.e. pulling\n * down despite being at the top of a scrollable component), whereas negative delta (swiping up) is\n * dispatched first (in case it is needed to push the indicator back up), and then the unconsumed\n * delta is passed on to the child. The callback returns how much delta was consumed.\n * @param onRelease Callback for when drag is released, takes float flingVelocity as argument.\n * The callback returns how much velocity was consumed - in most cases this should only consume\n * velocity if pull refresh has been dragged already and the velocity is positive (the fling is\n * downwards), as an upwards fling should typically still scroll a scrollable component beneath the\n * pullRefresh. This is invoked before any remaining velocity is passed to the child.\n * @param enabled If not enabled, all scroll delta and fling velocity will be ignored and neither\n * [onPull] nor [onRelease] will be invoked.\n */\nfun Modifier.pullRefresh(\n    onPull: (pullDelta: Float) -> Float,\n    onRelease: suspend (flingVelocity: Float) -> Float,\n    enabled: Boolean = true,\n) = inspectable(inspectorInfo = debugInspectorInfo {\n    name = \"pullRefresh\"\n    properties[\"onPull\"] = onPull\n    properties[\"onRelease\"] = onRelease\n    properties[\"enabled\"] = enabled\n}) {\n    Modifier.nestedScroll(PullRefreshNestedScrollConnection(onPull, onRelease, enabled))\n}\n\nprivate class PullRefreshNestedScrollConnection(\n    private val onPull: (pullDelta: Float) -> Float,\n    private val onRelease: suspend (flingVelocity: Float) -> Float,\n    private val enabled: Boolean,\n) : NestedScrollConnection {\n\n    override fun onPreScroll(\n        available: Offset,\n        source: NestedScrollSource,\n    ): Offset = when {\n        !enabled -> Offset.Zero\n        source == Drag && available.y < 0 -> Offset(0f, onPull(available.y)) // Swiping up\n        else -> Offset.Zero\n    }\n\n    override fun onPostScroll(\n        consumed: Offset,\n        available: Offset,\n        source: NestedScrollSource,\n    ): Offset = when {\n        !enabled -> Offset.Zero\n        source == Drag && available.y > 0 -> Offset(0f, onPull(available.y)) // Pulling down\n        else -> Offset.Zero\n    }\n\n    override suspend fun onPreFling(available: Velocity): Velocity {\n        return Velocity(0f, onRelease(available.y))\n    }\n}\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/pulltorefresh/PullRefreshIndicator.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.components.pulltorefresh\n\nimport androidx.compose.animation.Crossfade\nimport androidx.compose.animation.core.LinearEasing\nimport androidx.compose.animation.core.animateFloatAsState\nimport androidx.compose.animation.core.tween\nimport androidx.compose.foundation.Canvas\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.material3.CircularProgressIndicator\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Surface\nimport androidx.compose.material3.contentColorFor\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.Immutable\nimport androidx.compose.runtime.derivedStateOf\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.remember\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.geometry.Offset\nimport androidx.compose.ui.geometry.Rect\nimport androidx.compose.ui.geometry.center\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.Path\nimport androidx.compose.ui.graphics.PathFillType\nimport androidx.compose.ui.graphics.StrokeCap\nimport androidx.compose.ui.graphics.drawscope.DrawScope\nimport androidx.compose.ui.graphics.drawscope.Stroke\nimport androidx.compose.ui.graphics.drawscope.rotate\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.unit.dp\nimport kotlin.math.abs\nimport kotlin.math.max\nimport kotlin.math.min\nimport kotlin.math.pow\n\n/**\n * The default indicator for Compose pull-to-refresh, based on Android's SwipeRefreshLayout.\n *\n * @sample androidx.compose.material.samples.PullRefreshSample\n *\n * @param refreshing A boolean representing whether a refresh is occurring.\n * @param state The [PullRefreshState] which controls where and how the indicator will be drawn.\n * @param modifier Modifiers for the indicator.\n * @param backgroundColor The color of the indicator's background.\n * @param contentColor The color of the indicator's arc and arrow.\n * @param scale A boolean controlling whether the indicator's size scales with pull progress or not.\n */\n@Composable\n// TODO(b/244423199): Consider whether the state parameter should be replaced with lambdas to\n//  enable people to use this indicator with custom pull-to-refresh components.\nfun PullRefreshIndicator(\n    refreshing: Boolean,\n    state: PullRefreshState,\n    modifier: Modifier = Modifier,\n    backgroundColor: Color = MaterialTheme.colorScheme.surface,\n    contentColor: Color = contentColorFor(backgroundColor),\n    scale: Boolean = false,\n) {\n    val showElevation by remember(refreshing, state) {\n        derivedStateOf { refreshing || state.position > 0.5f }\n    }\n\n    Surface(\n        modifier = modifier\n            .size(IndicatorSize)\n            .pullRefreshIndicatorTransform(state, scale),\n        shape = SpinnerShape,\n        color = backgroundColor,\n        shadowElevation = if (showElevation) Elevation else 0.dp,\n    ) {\n        Crossfade(\n            targetState = refreshing,\n            animationSpec = tween(durationMillis = CrossfadeDurationMs)\n        ) { refreshing ->\n            Box(\n                modifier = Modifier.fillMaxSize(),\n                contentAlignment = Alignment.Center\n            ) {\n                val spinnerSize = (ArcRadius + StrokeWidth).times(2)\n\n                if (refreshing) {\n                    CircularProgressIndicator(\n                        color = contentColor,\n                        strokeWidth = StrokeWidth,\n                        modifier = Modifier.size(spinnerSize),\n                    )\n                } else {\n                    CircularArrowIndicator(state, contentColor, Modifier.size(spinnerSize))\n                }\n            }\n        }\n    }\n}\n\n/**\n * Modifier.size MUST be specified.\n */\n@Composable\nprivate fun CircularArrowIndicator(\n    state: PullRefreshState,\n    color: Color,\n    modifier: Modifier,\n) {\n    val path = remember { Path().apply { fillType = PathFillType.EvenOdd } }\n\n    val targetAlpha by remember(state) {\n        derivedStateOf {\n            if (state.progress >= 1f) MaxAlpha else MinAlpha\n        }\n    }\n\n    val alphaState = animateFloatAsState(targetValue = targetAlpha, animationSpec = AlphaTween)\n\n    // Empty semantics for tests\n    Canvas(modifier.semantics {}) {\n        val values = ArrowValues(state.progress)\n        val alpha = alphaState.value\n\n        rotate(degrees = values.rotation) {\n            val arcRadius = ArcRadius.toPx() + StrokeWidth.toPx() / 2f\n            val arcBounds = Rect(\n                size.center.x - arcRadius,\n                size.center.y - arcRadius,\n                size.center.x + arcRadius,\n                size.center.y + arcRadius\n            )\n            drawArc(\n                color = color,\n                alpha = alpha,\n                startAngle = values.startAngle,\n                sweepAngle = values.endAngle - values.startAngle,\n                useCenter = false,\n                topLeft = arcBounds.topLeft,\n                size = arcBounds.size,\n                style = Stroke(\n                    width = StrokeWidth.toPx(),\n                    cap = StrokeCap.Square\n                )\n            )\n            drawArrow(path, arcBounds, color, alpha, values)\n        }\n    }\n}\n\n@Immutable\nprivate class ArrowValues(\n    val rotation: Float,\n    val startAngle: Float,\n    val endAngle: Float,\n    val scale: Float,\n)\n\nprivate fun ArrowValues(progress: Float): ArrowValues {\n    // Discard first 40% of progress. Scale remaining progress to full range between 0 and 100%.\n    val adjustedPercent = max(min(1f, progress) - 0.4f, 0f) * 5 / 3\n    // How far beyond the threshold pull has gone, as a percentage of the threshold.\n    val overshootPercent = abs(progress) - 1.0f\n    // Limit the overshoot to 200%. Linear between 0 and 200.\n    val linearTension = overshootPercent.coerceIn(0f, 2f)\n    // Non-linear tension. Increases with linearTension, but at a decreasing rate.\n    val tensionPercent = linearTension - linearTension.pow(2) / 4\n\n    // Calculations based on SwipeRefreshLayout specification.\n    val endTrim = adjustedPercent * MaxProgressArc\n    val rotation = (-0.25f + 0.4f * adjustedPercent + tensionPercent) * 0.5f\n    val startAngle = rotation * 360\n    val endAngle = (rotation + endTrim) * 360\n    val scale = min(1f, adjustedPercent)\n\n    return ArrowValues(rotation, startAngle, endAngle, scale)\n}\n\nprivate fun DrawScope.drawArrow(\n    arrow: Path,\n    bounds: Rect,\n    color: Color,\n    alpha: Float,\n    values: ArrowValues,\n) {\n    arrow.reset()\n    arrow.moveTo(0f, 0f) // Move to left corner\n    arrow.lineTo(x = ArrowWidth.toPx() * values.scale, y = 0f) // Line to right corner\n\n    // Line to tip of arrow\n    arrow.lineTo(\n        x = ArrowWidth.toPx() * values.scale / 2,\n        y = ArrowHeight.toPx() * values.scale\n    )\n\n    val radius = min(bounds.width, bounds.height) / 2f\n    val inset = ArrowWidth.toPx() * values.scale / 2f\n    arrow.translate(\n        Offset(\n            x = radius + bounds.center.x - inset,\n            y = bounds.center.y + StrokeWidth.toPx() / 2f\n        )\n    )\n    arrow.close()\n    rotate(degrees = values.endAngle) {\n        drawPath(path = arrow, color = color, alpha = alpha)\n    }\n}\n\nprivate const val CrossfadeDurationMs = 100\nprivate const val MaxProgressArc = 0.8f\n\nprivate val IndicatorSize = 40.dp\nprivate val SpinnerShape = CircleShape\nprivate val ArcRadius = 7.5.dp\nprivate val StrokeWidth = 2.5.dp\nprivate val ArrowWidth = 10.dp\nprivate val ArrowHeight = 5.dp\nprivate val Elevation = 6.dp\n\n// Values taken from SwipeRefreshLayout\nprivate const val MinAlpha = 0.3f\nprivate const val MaxAlpha = 1f\nprivate val AlphaTween = tween<Float>(300, easing = LinearEasing)\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/pulltorefresh/PullRefreshIndicatorTransform.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.components.pulltorefresh\n\nimport androidx.compose.animation.core.LinearOutSlowInEasing\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.drawWithContent\nimport androidx.compose.ui.graphics.drawscope.clipRect\nimport androidx.compose.ui.graphics.graphicsLayer\nimport androidx.compose.ui.platform.debugInspectorInfo\nimport androidx.compose.ui.platform.inspectable\n\n/**\n * A modifier for translating the position and scaling the size of a pull-to-refresh indicator\n * based on the given [PullRefreshState].\n *\n * @sample androidx.compose.material.samples.PullRefreshIndicatorTransformSample\n *\n * @param state The [PullRefreshState] which determines the position of the indicator.\n * @param scale A boolean controlling whether the indicator's size scales with pull progress or not.\n */\n// TODO: Consider whether the state parameter should be replaced with lambdas.\nfun Modifier.pullRefreshIndicatorTransform(\n    state: PullRefreshState,\n    scale: Boolean = false,\n) = inspectable(inspectorInfo = debugInspectorInfo {\n    name = \"pullRefreshIndicatorTransform\"\n    properties[\"state\"] = state\n    properties[\"scale\"] = scale\n}) {\n    Modifier\n        // Essentially we only want to clip the at the top, so the indicator will not appear when\n        // the position is 0. It is preferable to clip the indicator as opposed to the layout that\n        // contains the indicator, as this would also end up clipping shadows drawn by items in a\n        // list for example - so we leave the clipping to the scrolling container. We use MAX_VALUE\n        // for the other dimensions to allow for more room for elevation / arbitrary indicators - we\n        // only ever really want to clip at the top edge.\n        .drawWithContent {\n            clipRect(\n                top = 0f,\n                left = -Float.MAX_VALUE,\n                right = Float.MAX_VALUE,\n                bottom = Float.MAX_VALUE\n            ) {\n                this@drawWithContent.drawContent()\n            }\n        }\n        .graphicsLayer {\n            translationY = state.position - size.height\n\n            if (scale && !state.refreshing) {\n                val scaleFraction = LinearOutSlowInEasing\n                    .transform(state.position / state.threshold)\n                    .coerceIn(0f, 1f)\n                scaleX = scaleFraction\n                scaleY = scaleFraction\n            }\n        }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/pulltorefresh/PullRefreshState.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.components.pulltorefresh\n\nimport androidx.compose.animation.core.animate\nimport androidx.compose.foundation.MutatorMutex\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.SideEffect\nimport androidx.compose.runtime.State\nimport androidx.compose.runtime.derivedStateOf\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.rememberUpdatedState\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.platform.LocalDensity\nimport androidx.compose.ui.unit.Dp\nimport androidx.compose.ui.unit.dp\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.launch\nimport kotlin.math.abs\nimport kotlin.math.pow\n\n/**\n * Creates a [PullRefreshState] that is remembered across compositions.\n *\n * Changes to [refreshing] will result in [PullRefreshState] being updated.\n *\n * @sample androidx.compose.material.samples.PullRefreshSample\n *\n * @param refreshing A boolean representing whether a refresh is currently occurring.\n * @param onRefresh The function to be called to trigger a refresh.\n * @param refreshThreshold The threshold below which, if a release\n * occurs, [onRefresh] will be called.\n * @param refreshingOffset The offset at which the indicator will be drawn while refreshing. This\n * offset corresponds to the position of the bottom of the indicator.\n */\n@Composable\nfun rememberPullRefreshState(\n    refreshing: Boolean,\n    onRefresh: () -> Unit,\n    refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold,\n    refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset,\n): PullRefreshState {\n    require(refreshThreshold > 0.dp) { \"The refresh trigger must be greater than zero!\" }\n\n    val scope = rememberCoroutineScope()\n    val onRefreshState = rememberUpdatedState(onRefresh)\n    val thresholdPx: Float\n    val refreshingOffsetPx: Float\n\n    with(LocalDensity.current) {\n        thresholdPx = refreshThreshold.toPx()\n        refreshingOffsetPx = refreshingOffset.toPx()\n    }\n\n    val state = remember(scope) {\n        PullRefreshState(scope, onRefreshState, refreshingOffsetPx, thresholdPx)\n    }\n\n    SideEffect {\n        state.setRefreshing(refreshing)\n        state.setThreshold(thresholdPx)\n        state.setRefreshingOffset(refreshingOffsetPx)\n    }\n\n    return state\n}\n\n/**\n * A state object that can be used in conjunction with [pullRefresh] to add pull-to-refresh\n * behaviour to a scroll component. Based on Android's SwipeRefreshLayout.\n *\n * Provides [progress], a float representing how far the user has pulled as a percentage of the\n * refreshThreshold. Values of one or less indicate that the user has not yet pulled past the\n * threshold. Values greater than one indicate how far past the threshold the user has pulled.\n *\n * Can be used in conjunction with [pullRefreshIndicatorTransform] to implement Android-like\n * pull-to-refresh behaviour with a custom indicator.\n *\n * Should be created using [rememberPullRefreshState].\n */\nclass PullRefreshState internal constructor(\n    private val animationScope: CoroutineScope,\n    private val onRefreshState: State<() -> Unit>,\n    refreshingOffset: Float,\n    threshold: Float,\n) {\n    /**\n     * A float representing how far the user has pulled as a percentage of the refreshThreshold.\n     *\n     * If the component has not been pulled at all, progress is zero. If the pull has reached\n     * halfway to the threshold, progress is 0.5f. A value greater than 1 indicates that pull has\n     * gone beyond the refreshThreshold - e.g. a value of 2f indicates that the user has pulled to\n     * two times the refreshThreshold.\n     */\n    val progress get() = adjustedDistancePulled / threshold\n\n    internal val refreshing get() = _refreshing\n    internal val position get() = _position\n    internal val threshold get() = _threshold\n\n    private val adjustedDistancePulled by derivedStateOf { distancePulled * DragMultiplier }\n\n    private var _refreshing by mutableStateOf(false)\n    private var _position by mutableStateOf(0f)\n    private var distancePulled by mutableStateOf(0f)\n    private var _threshold by mutableStateOf(threshold)\n    private var _refreshingOffset by mutableStateOf(refreshingOffset)\n\n    internal fun onPull(pullDelta: Float): Float {\n        if (_refreshing) return 0f // Already refreshing, do nothing.\n\n        val newOffset = (distancePulled + pullDelta).coerceAtLeast(0f)\n        val dragConsumed = newOffset - distancePulled\n        distancePulled = newOffset\n        _position = calculateIndicatorPosition()\n        return dragConsumed\n    }\n\n    internal fun onRelease(velocity: Float): Float {\n        if (refreshing) return 0f // Already refreshing, do nothing\n\n        if (adjustedDistancePulled > threshold) {\n            onRefreshState.value()\n        }\n        animateIndicatorTo(0f)\n        val consumed = when {\n            // We are flinging without having dragged the pull refresh (for example a fling inside\n            // a list) - don't consume\n            distancePulled == 0f -> 0f\n            // If the velocity is negative, the fling is upwards, and we don't want to prevent the\n            // the list from scrolling\n            velocity < 0f -> 0f\n            // We are showing the indicator, and the fling is downwards - consume everything\n            else -> velocity\n        }\n        distancePulled = 0f\n        return consumed\n    }\n\n    internal fun setRefreshing(refreshing: Boolean) {\n        if (_refreshing != refreshing) {\n            _refreshing = refreshing\n            distancePulled = 0f\n            animateIndicatorTo(if (refreshing) _refreshingOffset else 0f)\n        }\n    }\n\n    internal fun setThreshold(threshold: Float) {\n        _threshold = threshold\n    }\n\n    internal fun setRefreshingOffset(refreshingOffset: Float) {\n        if (_refreshingOffset != refreshingOffset) {\n            _refreshingOffset = refreshingOffset\n            if (refreshing) animateIndicatorTo(refreshingOffset)\n        }\n    }\n\n    // Make sure to cancel any existing animations when we launch a new one. We use this instead of\n    // Animatable as calling snapTo() on every drag delta has a one frame delay, and some extra\n    // overhead of running through the animation pipeline instead of directly mutating the state.\n    private val mutatorMutex = MutatorMutex()\n\n    private fun animateIndicatorTo(offset: Float) = animationScope.launch {\n        mutatorMutex.mutate {\n            animate(initialValue = _position, targetValue = offset) { value, _ ->\n                _position = value\n            }\n        }\n    }\n\n    private fun calculateIndicatorPosition(): Float = when {\n        // If drag hasn't gone past the threshold, the position is the adjustedDistancePulled.\n        adjustedDistancePulled <= threshold -> adjustedDistancePulled\n        else -> {\n            // How far beyond the threshold pull has gone, as a percentage of the threshold.\n            val overshootPercent = abs(progress) - 1.0f\n            // Limit the overshoot to 200%. Linear between 0 and 200.\n            val linearTension = overshootPercent.coerceIn(0f, 2f)\n            // Non-linear tension. Increases with linearTension, but at a decreasing rate.\n            val tensionPercent = linearTension - linearTension.pow(2) / 4\n            // The additional offset beyond the threshold.\n            val extraOffset = threshold * tensionPercent\n            threshold + extraOffset\n        }\n    }\n}\n\n/**\n * Default parameter values for [rememberPullRefreshState].\n */\nobject PullRefreshDefaults {\n    /**\n     * If the indicator is below this threshold offset when it is released, a refresh\n     * will be triggered.\n     */\n    val RefreshThreshold = 100.dp\n\n    /**\n     * The offset at which the indicator should be rendered whilst a refresh is occurring.\n     */\n    val RefreshingOffset = 56.dp\n}\n\n/**\n * The distance pulled is multiplied by this value to give us the adjusted distance pulled, which\n * is used in calculating the indicator position (when the adjusted distance pulled is less than\n * the refresh threshold, it is the indicator position, otherwise the indicator position is\n * derived from the progress).\n */\nprivate const val DragMultiplier = 0.5f\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/BookmarkViewer.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.feed\n\nimport android.webkit.WebView\nimport android.webkit.WebViewClient\nimport androidx.activity.compose.BackHandler\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.ArrowBack\nimport androidx.compose.material.icons.automirrored.filled.ArrowForward\nimport androidx.compose.material.icons.filled.ArrowBack\nimport androidx.compose.material.icons.filled.Refresh\nimport androidx.compose.material3.CenterAlignedTopAppBar\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Scaffold\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TopAppBarDefaults\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.viewinterop.AndroidView\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun HtmlTextViewer(\n    htmlString: String,\n    onBack: () -> Unit\n) {\n    BackHandler {\n        onBack()\n    }\n    Scaffold(\n        topBar = {\n            CenterAlignedTopAppBar(\n                title = { Text(\"Viewer\") },\n                navigationIcon = {\n                    IconButton(onClick = onBack) {\n                        Icon(\n                            Icons.Filled.ArrowBack,\n                            contentDescription = \"Back\"\n                        )\n                    }\n                },\n                colors = TopAppBarDefaults.centerAlignedTopAppBarColors(\n                    containerColor = MaterialTheme.colorScheme.background\n                )\n            )\n        },\n        containerColor = MaterialTheme.colorScheme.background\n    ) { padding ->\n        Column(\n            modifier = Modifier.padding(padding)\n        ) {\n            val webViewClient = remember { WebViewClient() }\n            // Remember and hold a reference to the WebView instance\n            val webViewInstance = remember { mutableStateOf<WebView?>(null) }\n\n            AndroidView(\n                modifier = Modifier\n                    .fillMaxWidth()\n                    .weight(1f),\n                factory = { context ->\n                    WebView(context).apply {\n                        this.webViewClient = webViewClient\n                        settings.loadWithOverviewMode = true\n                        loadDataWithBaseURL(null, htmlString, \"text/html\", \"UTF-8\", null)\n                    }\n                },\n                update = { webView ->\n                    webViewInstance.value = webView // Update the remembered WebView instance\n                }\n            )\n\n            Row(\n                modifier = Modifier.fillMaxWidth(),\n                horizontalArrangement = Arrangement.SpaceEvenly\n            ) {\n                IconButton(onClick = { webViewInstance.value?.goBack() }) {\n                    Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = \"Back\")\n                }\n                IconButton(onClick = { webViewInstance.value?.goForward() }) {\n                    Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = \"Forward\")\n                }\n                IconButton(onClick = { webViewInstance.value?.reload() }) {\n                    Icon(Icons.Filled.Refresh, contentDescription = \"Refresh\")\n                }\n            }\n        }\n    }\n}\n\n@Preview\n@Composable\nfun HtmlTextViewerPreview() {\n    val html = \"\"\"\n   <div id=\"readability-page-1\" class=\"page\"><div><h2 id=\"825f\"><a href=\"http://android-developers.googleblog.com/2024/02/first-developer-preview-android15.html\" rel=\"noopener ugc nofollow\" target=\"_blank\">The First Developer Preview of Android 15</a> 🧑‍💻</h2><figure></figure><p id=\"bbff\">We released the first Developer Preview of Android 15, which focuses on providing access to superior media capabilities, minimizing battery impact, maintaining buttery smooth app performance, and protecting user privacy/security — all while enabling a diverse ecosystem of devices.</p><p id=\"2588\">Android 15 includes updates to <a href=\"https://developer.android.com/design-for-safety/privacy-sandbox\" rel=\"noopener ugc nofollow\" target=\"_blank\">Privacy Sandbox</a> and <a href=\"https://developer.android.com/health-and-fitness/guides/health-connect\" rel=\"noopener ugc nofollow\" target=\"_blank\">Health Connect</a>, while introducing new <a href=\"https://developer.android.com/reference/android/security/FileIntegrityManager\" rel=\"noopener ugc nofollow\" target=\"_blank\">file integrity protection APIs</a>. It provides <a href=\"https://developer.android.com/reference/android/hardware/camera2/CameraCharacteristics\" rel=\"noopener ugc nofollow\" target=\"_blank\">enhanced camera controls</a> and <a href=\"https://developer.android.com/reference/android/media/midi/MidiUmpDeviceService\" rel=\"noopener ugc nofollow\" target=\"_blank\">virtual MIDI 2.0 devices</a> to help power creative applications. It expands the <a href=\"https://developer.android.com/games/optimize/adpf\" rel=\"noopener ugc nofollow\" target=\"_blank\">Android Dynamic Performance Framework</a> to support a <a href=\"https://developer.android.com/reference/android/os/PerformanceHintManager.Session#setPreferPowerEfficiency(boolean)\" rel=\"noopener ugc nofollow\" target=\"_blank\">power-efficiency mode</a>, <a href=\"https://developer.android.com/reference/android/os/WorkDuration#setActualGpuDurationNanos(long)\" rel=\"noopener ugc nofollow\" target=\"_blank\">report GPU work durations</a>, and return <a href=\"https://developer.android.com/reference/android/os/PowerManager#getThermalHeadroomThresholds()\" rel=\"noopener ugc nofollow\" target=\"_blank\">Thermal Headroom thresholds</a>. It adds quality of life focused OpenJDK APIs that will be updated on over a billion devices through Google Play system updates.</p><p id=\"a742\"><a href=\"https://developer.android.com/about/versions/15/get\" rel=\"noopener ugc nofollow\" target=\"_blank\">Get started today</a> testing your app with Android 15 in the emulator, or by flashing a system image onto a Pixel 6+, Pixel Fold, or Pixel Tablet device.</p><h2 id=\"bf5a\"><a href=\"http://android-developers.googleblog.com/2024/02/android-studio-iguana-is-stable.html\" rel=\"noopener ugc nofollow\" target=\"_blank\">Android Studio Iguana launched to stable</a>🦎</h2><figure></figure><p id=\"8baf\">We launched <a href=\"https://developer.android.com/studio/releases\" rel=\"noopener ugc nofollow\" target=\"_blank\">Android Studio Iguana 🦎</a> in the stable release channel to make it easier for you to create high quality apps.</p><p id=\"715c\">Enhancements include <a href=\"https://developer.android.com/studio/releases#compose-ui-check\" rel=\"noopener ugc nofollow\" target=\"_blank\">Compose UI Check</a>, which automatically audits Compose UI for accessibility and adaptive issues across different screen sizes. <a href=\"https://developer.android.com/studio/releases#compose-progressive-rendering\" rel=\"noopener ugc nofollow\" target=\"_blank\">Progressive rendering in Compose Preview</a> which speeds up iteration on complex layouts by lowering the detail of out-of-view previews. Iguana adds <a href=\"https://developer.android.com/studio/releases#aqi-vcs\" rel=\"noopener ugc nofollow\" target=\"_blank\">Version Control System support in App Quality Insights</a>, <a href=\"https://developer.android.com/studio/releases#baseline-profiles-module-wizard\" rel=\"noopener ugc nofollow\" target=\"_blank\">built-in support to create Baseline Profiles</a>, and enhanced support for Gradle Version Catalogs. The <a href=\"https://developer.android.com/studio/releases#espresso-device-api\" rel=\"noopener ugc nofollow\" target=\"_blank\">Espresso device API</a> enables configuration change testing. The integrated <a href=\"https://developer.android.com/studio/releases#intellij-platform-update\" rel=\"noopener ugc nofollow\" target=\"_blank\">IntelliJ 2023.2 update</a> includes many enhancements such as support for GitLab and text search in Search Everywhere. The <a href=\"http://android-developers.googleblog.com/2024/02/android-studio-iguana-is-stable.html\" rel=\"noopener ugc nofollow\" target=\"_blank\">blog has information on all these changes</a> and more.</p><h2 id=\"6888\"><a href=\"http://android-developers.googleblog.com/2024/02/cloud-photos-now-available-in-android-photo-picker.html\" rel=\"noopener ugc nofollow\" target=\"_blank\">Cloud photos now available in the Android photo picker</a>☁️📷</h2><figure></figure><p id=\"a9c8\">Android’s <a href=\"https://developer.android.com/training/data-storage/shared/photopicker\" rel=\"noopener ugc nofollow\" target=\"_blank\">photo picker</a> now integrates cloud photos, giving apps a unified way to let users browse and grant access to selected local and cloud photos and videos. It’s currently available integrated with Google Photos and is open to other cloud media apps that meet the eligibility criteria. The cloud photos feature is currently rolling out with the February Google Play system update to devices running Android 12 and above.</p><h2 id=\"6e01\"><a href=\"http://android-developers.googleblog.com/2024/02/ml-kit-document-scanner-api.html\" rel=\"noopener ugc nofollow\" target=\"_blank\">Easily add document scanning capability to your app with the ML Kit Document Scanner API</a>📃📷</h2><figure></figure><p id=\"707f\">We launched the <a href=\"https://developers.google.com/ml-kit/vision/doc-scanner\" rel=\"noopener ugc nofollow\" target=\"_blank\">ML Kit Document Scanner API</a>, enabling you to easily integrate advanced document scanning capabilities into your apps.</p><p id=\"528f\">The API offers a standardized and user-friendly interface for document scanning, includes precise corner and edge detection for accurate document capture, and allows users to further crop scanned documents, apply filters, and remove fingers or blemishes. It processes documents on the device, eliminates the need for camera permissions, and is supported on devices with Android API level 21 or above.</p><h2 id=\"7e22\"><a href=\"http://android-developers.googleblog.com/2024/02/wear-os-hybrid-interface-boosting-power-and-performance.html\" rel=\"noopener ugc nofollow\" target=\"_blank\">Android Developers Blog: Wear OS hybrid interface: Boosting power and performance</a>⌚</h2><figure></figure><p id=\"b2d1\">The WearOS powered OnePlus Watch 2 launched with a dual-chipset architecture that works with our hybrid interface to dramatically extend battery life up to 100 hours of Smart Mode regular use.</p><p id=\"fd70\">You can leverage existing Wear OS APIs to get the advantage of these optimizations, such as <a href=\"https://developer.android.com/training/wearables/notifications?_gl=1*9dlcvi*_up*MQ..*_ga*NjY5MzY0MTMzLjE3MDc3ODEwMzU.*_ga_6HH9YJMN9M*MTcwNzc4MTAzNC4xLjAuMTcwNzc4MTAzNC4wLjAuMA..#add-wearable-features\" rel=\"noopener ugc nofollow\" target=\"_blank\">NotificationCompat</a>, and <a href=\"https://developer.android.com/health-and-fitness/guides/health-services\" rel=\"noopener ugc nofollow\" target=\"_blank\">Health Services on Wear OS</a>. With Wear OS 4, we launched the <a href=\"https://android-developers.googleblog.com/2023/05/introducing-watch-face-format-for-wear-os.html\" rel=\"noopener ugc nofollow\" target=\"_blank\">Watch Face Format</a>, and the new format helps future-proof watch faces to take advantage of emerging optimizations in future devices.</p><h2 id=\"c0d5\">Articles📚</h2><p id=\"b547\">There are a bunch of other articles worth checking out.</p><p id=\"95e1\"><a href=\"https://medium.com/u/68e2e0af15b1?source=post_page-----46422a7fefe8--------------------------------\" rel=\"noopener\" target=\"_blank\">Levi</a> covered <a rel=\"noopener\" href=\"https://medium.com/androiddevelopers/understanding-nested-scrolling-in-jetpack-compose-eb57c1ea0af0\">Nested Scrolling in Jetpack Compose</a>, giving a deep dive into how you can implement custom nested behaviors, such as what the Material 3’s TopAppBar <a href=\"https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/AppBar.kt;l=653?q=TopAppBarScrollBehavior&amp;sq=&amp;ss=androidx%2Fplatform%2Fframeworks%2Fsupport\" rel=\"noopener ugc nofollow\" target=\"_blank\">scrollBehavior</a> parameter does.</p><p id=\"fa0d\"><a href=\"https://medium.com/u/84718b19bc40?source=post_page-----46422a7fefe8--------------------------------\" rel=\"noopener\" target=\"_blank\">Ben</a> explained <a rel=\"noopener\" href=\"https://medium.com/androiddevelopers/jetpack-compose-strong-skipping-mode-explained-cbdb2aa4b900\">Jetpack Compose’s Strong Skipping Mode</a>, an experimental feature in the Jetpack Compose Compiler 1.5.4+ that changes the rules for what composables can skip recomposition which should greatly reduce recomposition, improving performance.</p><p id=\"f40c\"><a href=\"https://medium.com/u/3f9b9c30bec7?source=post_page-----46422a7fefe8--------------------------------\" rel=\"noopener\" target=\"_blank\">Rebecca</a> showed how you can use <a rel=\"noopener\" href=\"https://medium.com/androiddevelopers/fun-with-shapes-in-compose-8814c439e1a0\">shapes in Compose to create a cool progress bar that morphs between two shapes</a> using the <a href=\"https://developer.android.com/jetpack/compose/graphics/draw/shapes\" rel=\"noopener ugc nofollow\" target=\"_blank\">graphics-shapes library</a>, which has <a href=\"https://developer.android.com/jetpack/compose/graphics/draw/shapes\" rel=\"noopener ugc nofollow\" target=\"_blank\">new documentation</a> to help you add these effects into your apps.</p><h2 id=\"b94c\">Videos📹</h2><p id=\"5a1b\">Over in videos, #WeArePlay <a href=\"https://www.youtube.com/watch?v=CfzhLOiczDQ\" rel=\"noopener ugc nofollow\" target=\"_blank\">highlighted the developers behind</a> <a href=\"https://play.google.com/store/apps/details?id=fr.altplusun.we_spot_turtles\" rel=\"noopener ugc nofollow\" target=\"_blank\">We Spot Turtles!</a>, whose app helps crowdsource pictures that a machine learning model uses to help collect extensive data on sea turtles in the wild.</p><figure></figure><p id=\"aa80\">There’s also an associated blog post if you’d rather read about them!</p><h2 id=\"4c1a\">AndroidX releases 🚀</h2><p id=\"a536\">There was a bunch of activity over in Android Jetpack, including the first alphas of Annotation 1.8, Benchmark 1.3, Core-RemoteViews 1.1, Glance 1.1, ProfileInstaller 1.4, Lint 1.0, Wear Watchface 1.3, Webkit 1.11, and Compose Material 3 1.3. Highlights include:</p><ul><li id=\"ca40\">Compose Material 3 1.3 includes more support for predictive back, and updates to the Slider and ProgressIndicator to improve accessibility.</li><li id=\"e4ad\">The new Lint library is a set of lint checks for Gradle Plugin authors on projects that apply java-gradle-plugin to help catch mistakes in their code.</li><li id=\"18ea\">Glance 1.1 adds a new unit test library (that doesn’t require UI Automator), higher level components, new modifiers, and a new API for getting a flow of RemoteViews from a composition.</li></ul><p id=\"9c42\">We also released Hilt Version 1.2 with assisted injection support for hiltViewModel() and hiltNavGraphViewModels() as well as Test Uiautomator 2.3, which adds support for multiple displays and custom wait conditions.</p><h2 id=\"0de4\"><a href=\"https://adbackstage.libsyn.com/episode-204-fanotations\" rel=\"noopener ugc nofollow\" target=\"_blank\">Android Developers Backstage🎙</a></h2><figure></figure><p id=\"0b93\">In <a href=\"https://adbackstage.libsyn.com/episode-204-fanotations\" rel=\"noopener ugc nofollow\" target=\"_blank\">Episode 204: Fan’otations</a> <a href=\"https://medium.com/u/8251a5f98c9d?source=post_page-----46422a7fefe8--------------------------------\" rel=\"noopener\" target=\"_blank\">Tor</a>, <a href=\"https://medium.com/u/c967b7e51f8b?source=post_page-----46422a7fefe8--------------------------------\" rel=\"noopener\" target=\"_blank\">Romain</a>, and <a href=\"https://medium.com/u/cb2c4874d3e9?source=post_page-----46422a7fefe8--------------------------------\" rel=\"noopener\" target=\"_blank\">Chet</a> talk about one of Tor’s favorite topics: Lint! Specifically, they talk about Lint checks and the annotations that use them to enable better, more robust, and more self-documenting APIs.</p><p id=\"e6b1\">As <a href=\"https://medium.com/u/cb2c4874d3e9?source=post_page-----46422a7fefe8--------------------------------\" rel=\"noopener\" target=\"_blank\">Chet</a> says, “Lint: It’s not just for pockets anymore.” Thank you Chet for all you’ve done for Android and the community, and for helping us keep our sense of humor.</p><h2 id=\"6c1a\">Now then… 👋</h2><p id=\"7d70\">That’s it for this week with <a href=\"http://android-developers.googleblog.com/2024/02/first-developer-preview-android15.html\" rel=\"noopener ugc nofollow\" target=\"_blank\">Android 15 developer preview 1</a>, the <a href=\"http://android-developers.googleblog.com/2024/02/android-studio-iguana-is-stable.html\" rel=\"noopener ugc nofollow\" target=\"_blank\">stable release of Android Studio Iguana</a>, <a href=\"http://android-developers.googleblog.com/2024/02/cloud-photos-now-available-in-android-photo-picker.html\" rel=\"noopener ugc nofollow\" target=\"_blank\">cloud photos now available in Photo Picker</a>, <a href=\"http://android-developers.googleblog.com/2024/02/ml-kit-document-scanner-api.html\" rel=\"noopener ugc nofollow\" target=\"_blank\">ML Kit Document Scanning</a>, the <a href=\"http://android-developers.googleblog.com/2024/02/wear-os-hybrid-interface-boosting-power-and-performance.html\" rel=\"noopener ugc nofollow\" target=\"_blank\">Wear OS hybrid interface</a>, <a rel=\"noopener\" href=\"https://medium.com/androiddevelopers/understanding-nested-scrolling-in-jetpack-compose-eb57c1ea0af0\">nested scrolling</a>/<a rel=\"noopener\" href=\"https://medium.com/androiddevelopers/jetpack-compose-strong-skipping-mode-explained-cbdb2aa4b900\">strong skipping</a>/<a rel=\"noopener\" href=\"https://medium.com/androiddevelopers/fun-with-shapes-in-compose-8814c439e1a0\">shape morphing</a> in Compose, <a href=\"https://adbackstage.libsyn.com/episode-204-fanotations\" rel=\"noopener ugc nofollow\" target=\"_blank\">annotations with Lint</a>, and more!</p><p id=\"5dcd\">Check back soon for your next update from the Android developer universe! 🌌</p></div></div>\n\"\"\".trimIndent()\n    MaterialTheme {\n        HtmlTextViewer(\n            htmlString = html,\n            onBack = {}\n        )\n    }\n}\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/CategoriesView.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.feed\n\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.expandVertically\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.shrinkVertically\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.VisibilityOff\nimport androidx.compose.material.icons.outlined.Sell\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.unit.dp\nimport com.desarrollodroide.model.Tag\nimport com.desarrollodroide.pagekeeper.ui.components.Categories\nimport com.desarrollodroide.pagekeeper.ui.components.CategoriesType\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun CategoriesView(\n    onDismiss: () -> Unit,\n    uniqueCategories: List<Tag>,\n    tagToHide: Tag?,\n    onFilterHiddenTag: (Boolean) -> Unit,\n    selectedOptionIndex: Int,\n    onSelectedOptionIndexChanged: (Int) -> Unit,\n    selectedTags: List<Tag>,\n    onCategorySelected: (Tag) -> Unit,\n    onCategoryDeselected: (Tag) -> Unit,\n    onResetAll: () -> Unit\n) {\n    Box(modifier = Modifier.fillMaxWidth()) {\n        Column(\n            modifier = Modifier\n                .fillMaxWidth()\n                .verticalScroll(rememberScrollState())\n                .padding(bottom = 80.dp)\n                .padding(horizontal = 16.dp),\n            horizontalAlignment = Alignment.CenterHorizontally\n        ) {\n            AnimatedVisibility(\n                visible = tagToHide != null,\n                enter = fadeIn() + expandVertically(),\n                exit = fadeOut() + shrinkVertically()\n            ) {\n                Column(\n                    modifier = Modifier\n                        .fillMaxWidth()\n                        .padding(16.dp),\n                ) {\n                    Row(\n                        modifier = Modifier\n                            .fillMaxWidth()\n                            .padding(horizontal = 16.dp),\n                        verticalAlignment = Alignment.CenterVertically\n                    ) {\n                        Icon(\n                            imageVector = Icons.Filled.VisibilityOff,\n                            contentDescription = \"Hidden tag\",\n                            tint = MaterialTheme.colorScheme.primary,\n                            modifier = Modifier.size(24.dp)\n                        )\n                        Spacer(Modifier.width(12.dp))\n                        Column(modifier = Modifier.weight(1f)) {\n                            Text(\n                                \"Hidden: ${tagToHide?.name}\",\n                                style = MaterialTheme.typography.bodyMedium,\n                                color = MaterialTheme.colorScheme.onSurfaceVariant\n                            )\n                            Text(\n                                \"Bookmarks with this tag are currently hidden.\",\n                                style = MaterialTheme.typography.bodySmall,\n                                color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)\n                            )\n                        }\n                    }\n                    Spacer(Modifier.height(8.dp))\n                    SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {\n                        SegmentedButton(\n                            selected = selectedOptionIndex == 0,\n                            onClick = {\n                                onSelectedOptionIndexChanged(0)\n                                onFilterHiddenTag(false)\n                            },\n                            shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),\n                        ) {\n                            Text(\"All\", style = MaterialTheme.typography.titleMedium)\n                        }\n                        SegmentedButton(\n                            selected = selectedOptionIndex == 1,\n                            onClick = {\n                                onSelectedOptionIndexChanged(1)\n                                onFilterHiddenTag(true)\n                            },\n                            shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),\n                        ) {\n                            Text(\"Hidden tag\", style = MaterialTheme.typography.titleMedium)\n                        }\n                    }\n                    Spacer(Modifier.height(8.dp))\n                    Text(\n                        modifier = Modifier.align(Alignment.CenterHorizontally),\n                        text = if (selectedOptionIndex == 0)\n                            \"Filter by all bookmarks\\n\"\n                        else\n                            \"Showing only bookmarks with the '${tagToHide?.name}' tag\",\n                        style = MaterialTheme.typography.bodySmall,\n                        color = MaterialTheme.colorScheme.onSurfaceVariant\n                    )\n                }\n            }\n\n            AnimatedVisibility(\n                visible = selectedOptionIndex == 0,\n                enter = fadeIn() + expandVertically(),\n                exit = fadeOut() + shrinkVertically()\n            ) {\n                Column(\n                    modifier = Modifier.fillMaxWidth(),\n                    horizontalAlignment = Alignment.CenterHorizontally\n                ) {\n                    Text(\"Categories\", style = MaterialTheme.typography.headlineSmall)\n                    Spacer(Modifier.height(8.dp))\n\n                    if (uniqueCategories.isEmpty()) {\n                        Row(\n                            horizontalArrangement = Arrangement.Center,\n                            verticalAlignment = Alignment.CenterVertically,\n                            modifier = Modifier\n                                .padding(vertical = 16.dp)\n                                .fillMaxWidth()\n                        ) {\n                            Icon(\n                                imageVector = Icons.Outlined.Sell,\n                                contentDescription = \"No categories available\",\n                                modifier = Modifier.size(24.dp)\n                            )\n                            Spacer(Modifier.width(8.dp))\n                            Text(\n                                \"No categories available\",\n                                style = MaterialTheme.typography.bodyLarge\n                            )\n                        }\n                    } else {\n                        Categories(\n                            categoriesType = CategoriesType.SELECTABLES,\n                            showCategories = true,\n                            uniqueCategories = uniqueCategories,\n                            selectedTags = selectedTags,\n                            onCategorySelected = { tag ->\n                                onCategorySelected(tag)\n                                onFilterHiddenTag(false)\n                            },\n                            onCategoryDeselected = { tag ->\n                                onCategoryDeselected(tag)\n                                onFilterHiddenTag(false)\n                            }\n                        )\n                    }\n                }\n            }\n        }\n\n        // Fixed bottom buttons\n        Surface(\n            modifier = Modifier\n                .align(Alignment.BottomCenter)\n                .fillMaxWidth(),\n            shadowElevation = 8.dp,\n            color = MaterialTheme.colorScheme.surface\n        ) {\n            Row(\n                modifier = Modifier\n                    .fillMaxWidth()\n                    .padding(16.dp),\n                horizontalArrangement = Arrangement.SpaceBetween\n            ) {\n                Button(\n                    enabled = selectedOptionIndex == 0,\n                    onClick = {\n                        onResetAll()\n                        onFilterHiddenTag(false)\n                    },\n                    modifier = Modifier.weight(1f)\n                ) {\n                    Text(\"Reset All\")\n                }\n\n                Spacer(Modifier.width(8.dp))\n\n                Button(\n                    onClick = onDismiss,\n                    modifier = Modifier.weight(1f)\n                ) {\n                    Text(\"Close\")\n                }\n            }\n        }\n    }\n}\n\n@Preview(showBackground = true)\n@Composable\nfun SortAndFilterScreenPreview() {\n    val regionOptions = listOf(\n        Tag(id = 1, name = \"Northern Europe\"),\n        Tag(id = 2, name = \"Western Europe\"),\n        Tag(id = 3, name = \"Southern Europe\"),\n        Tag(id = 4, name = \"Southeast Europe\"),\n        Tag(id = 5, name = \"Central Europe\"),\n        Tag(id = 6, name = \"Eastern Europe\")\n    )\n\n    val selectedOptionIndex = remember { mutableStateOf(0) }\n    val selectedTags = remember { mutableStateOf(listOf(Tag(id = 3, name = \"Southern Europe\"))) }\n\n    MaterialTheme {\n        CategoriesView(\n            onDismiss = {},\n            uniqueCategories = regionOptions,\n            tagToHide = Tag(id = 3, name = \"Southeast Europe\"),\n            onFilterHiddenTag = {},\n            selectedOptionIndex = selectedOptionIndex.value,\n            onSelectedOptionIndexChanged = { selectedOptionIndex.value = it },\n            selectedTags = selectedTags.value,\n            onCategorySelected = { },\n            onCategoryDeselected = { },\n            onResetAll = { }\n        )\n    }\n}\n\n@Preview(showBackground = true)\n@Composable\nfun SortAndFilterScreenPreview2() {\n    val regionOptions = (1..200).map {\n        Tag(id = it, name = \"Category $it\")\n    }\n\n    val selectedOptionIndex = remember { mutableStateOf(0) }\n    val selectedTags = remember { mutableStateOf(emptyList<Tag>()) }\n\n    MaterialTheme {\n        CategoriesView(\n            onDismiss = {},\n            uniqueCategories = regionOptions,\n            tagToHide = null,\n            onFilterHiddenTag = {},\n            selectedOptionIndex = selectedOptionIndex.value,\n            onSelectedOptionIndexChanged = { selectedOptionIndex.value = it },\n            selectedTags = selectedTags.value,\n            onCategorySelected = { },\n            onCategoryDeselected = { },\n            onResetAll = { }\n        )\n    }\n}\n\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/FeedContent.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.feed\n\nimport android.util.Log\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.animateContentSize\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.scaleIn\nimport androidx.compose.animation.scaleOut\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.rememberLazyListState\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.KeyboardArrowUp\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.ListItem\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.material.icons.rounded.Bookmark\nimport androidx.compose.material3.FloatingActionButton\nimport androidx.compose.material3.HorizontalDivider\nimport androidx.compose.material3.ListItemDefaults\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.derivedStateOf\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.text.style.TextOverflow\nimport androidx.paging.LoadState\nimport androidx.paging.compose.LazyPagingItems\nimport com.desarrollodroide.data.helpers.BookmarkViewType\nimport com.desarrollodroide.data.helpers.SESSION_HAS_BEEN_EXPIRED\nimport com.desarrollodroide.pagekeeper.ui.components.pulltorefresh.PullRefreshIndicator\nimport com.desarrollodroide.pagekeeper.ui.components.pulltorefresh.pullRefresh\nimport com.desarrollodroide.pagekeeper.ui.components.pulltorefresh.rememberPullRefreshState\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.model.Tag\nimport com.desarrollodroide.pagekeeper.ui.feed.item.BookmarkActions\nimport com.desarrollodroide.pagekeeper.ui.feed.item.BookmarkItem\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.launch\n\n@Composable\nfun FeedContent(\n    actions: FeedActions,\n    viewType: BookmarkViewType,\n    serverURL: String,\n    xSessionId: String,\n    token: String,\n    bookmarksPagingItems: LazyPagingItems<Bookmark>,\n    tagToHide: Tag?,\n    showOnlyHiddenTag: Boolean\n) {\n    val refreshCoroutineScope = rememberCoroutineScope()\n    var isRefreshing by remember { mutableStateOf(false) }\n    val listState = rememberLazyListState()\n\n    LaunchedEffect(bookmarksPagingItems.loadState.refresh) {\n        if (bookmarksPagingItems.loadState.refresh is LoadState.NotLoading && isRefreshing) {\n            listState.animateScrollToItem(0)\n            delay(100)\n            isRefreshing = false\n        }\n    }\n\n    // Scroll to top when a new bookmark is added (item count increases)\n    var previousItemCount by remember { mutableStateOf(bookmarksPagingItems.itemCount) }\n    LaunchedEffect(bookmarksPagingItems.itemCount) {\n        if (bookmarksPagingItems.itemCount > previousItemCount && previousItemCount > 0) {\n            listState.animateScrollToItem(0)\n        }\n        previousItemCount = bookmarksPagingItems.itemCount\n    }\n\n    fun refreshBookmarks() = refreshCoroutineScope.launch {\n        actions.onRefreshFeed.invoke()\n        isRefreshing = true\n        delay(1500)\n        isRefreshing = false\n    }\n\n    val refreshState = rememberPullRefreshState(isRefreshing, ::refreshBookmarks)\n    val coroutineScope = rememberCoroutineScope()\n\n    Box(\n        Modifier.fillMaxHeight()\n            .padding(bottom = 10.dp)\n    ) {\n        //val listState = rememberLazyListState()\n        LazyColumn(\n            state = listState,\n            modifier = Modifier\n                .fillMaxHeight()\n                .padding(horizontal = 10.dp)\n                .pullRefresh(state = refreshState)\n                .animateContentSize(),\n            verticalArrangement = Arrangement.spacedBy(6.dp)\n        ) {\n            items(\n                count = bookmarksPagingItems.itemCount,\n                key = { index ->\n                    // Including 'modified' in the key ensures that when a bookmark's 'modified' field changes,\n                    // Compose recognizes it as a new item and recomposes it. This updates the UI immediately\n                    // after data changes\n                    val bookmark = bookmarksPagingItems[index]\n                    \"${bookmark?.id}_${bookmark?.modified}\" ?: index\n                }\n            ) { index ->\n                val bookmark = bookmarksPagingItems[index]\n                if (bookmark != null ) {\n                    BookmarkItem(\n                        getBookmark = { bookmark },\n                        serverURL = serverURL,\n                        xSessionId = xSessionId,\n                        token = token,\n                        viewType = viewType,\n                        actions = BookmarkActions(\n                            onClickEdit = { getBookmark -> actions.onEditBookmark(getBookmark()) },\n                            onClickDelete = { getBookmark -> actions.onDeleteBookmark(getBookmark()) },\n                            onClickShare = { getBookmark -> actions.onShareBookmark(getBookmark()) },\n                            onClickBookmark = { getBookmark -> actions.onBookmarkSelect(getBookmark()) },\n                            onClickEpub = { getBookmark -> actions.onBookmarkEpub(getBookmark()) },\n                            onClickSync = { getBookmark -> actions.onClickSync(getBookmark()) },\n                            onClickCategory = { category -> }\n                        ),\n                    )\n                    if (index < bookmarksPagingItems.itemCount - 1) {\n                        HorizontalDivider(\n                            modifier = Modifier\n                                .height(1.dp)\n                                .padding(horizontal = 6.dp),\n                            color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)\n                        )\n                    }\n                }\n            }\n            bookmarksPagingItems.apply {\n                when {\n                    loadState.refresh is LoadState.Loading -> {\n                        item { PageLoader(modifier = Modifier.fillParentMaxSize()) }\n                    }\n                    loadState.refresh is LoadState.Error -> {\n                        val error = loadState.refresh as LoadState.Error\n                        if (error.error.localizedMessage == SESSION_HAS_BEEN_EXPIRED) {\n                            actions.goToLogin()\n                        } else {\n                            item {\n                                ErrorMessage(\n                                    modifier = Modifier.fillParentMaxSize(),\n                                    message = error.error.localizedMessage ?: \"Unknown error\",\n                                    onClickRetry = { retry() })\n                            }\n                        }\n                    }\n                    loadState.append is LoadState.Loading -> {\n                        item { LoadingNextPageItem(modifier = Modifier) }\n                    }\n                    loadState.append is LoadState.Error -> {\n                        val error = loadState.append as LoadState.Error\n                        item {\n                            ErrorMessage(\n                                modifier = Modifier,\n                                message = error.error.localizedMessage ?: \"Unknown error\",\n                                onClickRetry = { retry() })\n                        }\n                    }\n                }\n            }\n            item {  Spacer(modifier = Modifier.height(30.dp))  }\n        }\n\n        PullRefreshIndicator(\n            modifier = Modifier.align(alignment = Alignment.TopCenter),\n            refreshing = isRefreshing,\n            state = refreshState,\n            scale = true\n        )\n        val showScrollToTopButton by remember {\n            derivedStateOf { listState.firstVisibleItemIndex > 0 }\n        }\n\n        AnimatedVisibility(\n            visible = showScrollToTopButton,\n            modifier = Modifier\n                .align(Alignment.BottomEnd)\n                .padding(16.dp),\n            enter = fadeIn() + scaleIn(),\n            exit = fadeOut() + scaleOut()\n        ) {\n            FloatingActionButton(\n                onClick = {\n                    coroutineScope.launch {\n                        listState.animateScrollToItem(0)\n                    }\n                }\n            ) {\n                Icon(Icons.Filled.KeyboardArrowUp, contentDescription = \"Scroll to top\")\n            }\n        }\n    }\n}\n\n@Composable\nfun BookmarkSuggestions(\n    bookmarks: LazyPagingItems<Bookmark>,\n    onClickSuggestion: (Bookmark) -> Unit\n) {\n    LazyColumn(\n        modifier = Modifier.fillMaxSize(),\n        contentPadding = PaddingValues(16.dp),\n        verticalArrangement = Arrangement.spacedBy(4.dp)\n    ) {\n        items(bookmarks.itemCount) { index ->\n            val bookmark = bookmarks[index]\n            if (bookmark != null) {\n                ListItem(\n                    colors = ListItemDefaults.colors(\n                        containerColor = Color.Transparent\n                    ),\n                    modifier = Modifier\n                        .clickable {\n                            onClickSuggestion(bookmark)\n                        }\n                        .background(Color.Transparent),\n                    headlineContent = {\n                        Text(\n                            text = bookmark.title,\n                            style = MaterialTheme.typography.titleMedium\n                        )\n                    },\n                    supportingContent = {\n                        Text(\n                            overflow = TextOverflow.Ellipsis,\n                            text = bookmark.excerpt,\n                            maxLines = 3,\n                            style = MaterialTheme.typography.bodyMedium\n                        )\n                    },\n                    leadingContent = { Icon(Icons.Rounded.Bookmark, contentDescription = null) },\n                )\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/FeedScreen.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.feed\n\nimport android.media.MediaScannerConnection\nimport android.util.Log\nimport androidx.compose.foundation.gestures.detectTapGestures\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Error\nimport androidx.compose.material3.BottomSheetDefaults\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.ModalBottomSheet\nimport androidx.compose.material3.rememberModalBottomSheetState\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.MutableState\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.snapshotFlow\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.input.nestedscroll.nestedScroll\nimport androidx.compose.ui.input.pointer.pointerInput\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.rememberNestedScrollInteropConnection\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.window.DialogProperties\nimport androidx.paging.LoadState\nimport androidx.paging.compose.LazyPagingItems\nimport androidx.paging.compose.collectAsLazyPagingItems\nimport com.desarrollodroide.data.helpers.BookmarkViewType\nimport com.desarrollodroide.data.helpers.SESSION_HAS_BEEN_EXPIRED\nimport com.desarrollodroide.pagekeeper.extensions.shareText\nimport com.desarrollodroide.pagekeeper.ui.bookmarkeditor.BookmarkEditorScreen\nimport com.desarrollodroide.pagekeeper.ui.bookmarkeditor.BookmarkEditorType\nimport com.desarrollodroide.pagekeeper.ui.components.ConfirmDialog\nimport com.desarrollodroide.pagekeeper.ui.components.InfiniteProgressDialog\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.model.Tag\nimport com.desarrollodroide.pagekeeper.ui.components.EpubOptionsDialog\nimport com.desarrollodroide.pagekeeper.ui.components.UpdateCacheDialog\nimport kotlinx.coroutines.launch\nimport java.io.File\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun FeedScreen(\n    feedViewModel: FeedViewModel,\n    goToLogin: () -> Unit,\n    goToReadableContent:(Bookmark) -> Unit,\n    openUrlInBrowser: (String) -> Unit,\n    shareEpubFile: (File) -> Unit,\n    isCategoriesVisible: MutableState<Boolean>,\n    isSearchBarVisible: MutableState<Boolean>,\n    setShowTopBar: (Boolean) -> Unit,\n) {\n    val context = LocalContext.current\n    val tagsState by feedViewModel.tagsState.collectAsState()\n    val tagToHide by feedViewModel.tagToHide.collectAsState()\n    val showOnlyHiddenTag by feedViewModel.showOnlyHiddenTag.collectAsState()\n\n    LaunchedEffect(feedViewModel) {\n        feedViewModel.initializeIfNeeded()\n    }\n    LaunchedEffect(isCategoriesVisible.value) {\n        if (isCategoriesVisible.value) {\n            // TODO remove when sync functionality is implemented\n            //feedViewModel.getTags()\n        }\n    }\n\n    val bookmarksPagingItems: LazyPagingItems<Bookmark> =\n        feedViewModel.bookmarksState.collectAsLazyPagingItems()\n\n    LaunchedEffect(Unit) {\n        snapshotFlow { bookmarksPagingItems.itemSnapshotList.items }\n            .collect { updatedItems ->\n                Log.d(\"FeedScreen\", \"Los bookmarks se han modificado: ${updatedItems.size} items\")\n            }\n    }\n\n\n\n    val bookmarksUiState = feedViewModel.bookmarksUiState.collectAsState().value\n    val downloadUiState = feedViewModel.downloadUiState.collectAsState()\n    val isCompactView by feedViewModel.compactView.collectAsState()\n\n\n    val sheetState = rememberModalBottomSheetState(\n        skipPartiallyExpanded = true\n    )\n    val sheetStateCategories = rememberModalBottomSheetState(\n        skipPartiallyExpanded = false\n    )\n\n    val actions = FeedActions(\n        goToLogin = {\n            goToLogin()\n        },\n        onBookmarkSelect = { bookmark ->\n            goToReadableContent(bookmark)\n        },\n        onRefreshFeed = {\n            feedViewModel.refreshFeed()\n        },\n        onEditBookmark = { bookmark ->\n            feedViewModel.bookmarkSelected.value = bookmark\n            feedViewModel.showBookmarkEditorScreen.value = true\n        },\n        onDeleteBookmark = { bookmark ->\n            feedViewModel.bookmarkToDelete.value = bookmark\n            feedViewModel.showDeleteConfirmationDialog.value = true\n        },\n        onShareBookmark = { bookmark ->\n            context.shareText(bookmark.url)\n        },\n        onBookmarkEpub = { bookmark ->\n            feedViewModel.downloadFile(bookmark)\n        },\n        onClickSync = { bookmark ->\n            feedViewModel.bookmarkToUpdateCache.value = bookmark\n            feedViewModel.showSyncDialog.value = true\n        },\n        onClearError = {\n            feedViewModel.resetData()\n        },\n        onCategoriesSelectedChanged = { categories -> },\n    )\n\n    LaunchedEffect(bookmarksPagingItems.loadState) {\n        val loadState = bookmarksPagingItems.loadState.refresh\n        if (loadState is LoadState.Error) {\n            val error = loadState.error\n            if (error.message == SESSION_HAS_BEEN_EXPIRED) {\n                feedViewModel.handleLoadState(loadState)\n            }\n        }\n    }\n\n    FeedView(\n        actions = actions,\n        serverURL = feedViewModel.getServerUrl(),\n        xSessionId = feedViewModel.getSession(),\n        token = feedViewModel.getToken(),\n        viewType = if (isCompactView) BookmarkViewType.SMALL else BookmarkViewType.FULL,\n        bookmarksPagingItems = bookmarksPagingItems,\n        tagToHide = tagToHide,\n        showOnlyHiddenTag = showOnlyHiddenTag\n    )\n    if (feedViewModel.showBookmarkEditorScreen.value && feedViewModel.bookmarkSelected.value != null) {\n        Box(\n            modifier = Modifier\n                .fillMaxSize()\n                .pointerInput(Unit) {\n                    detectTapGestures { }\n                }\n        ) {\n            feedViewModel.bookmarkSelected.value?.let {\n                setShowTopBar(false)\n                BookmarkEditorScreen(\n                    pageTitle = \"Edit\",\n                    bookmarkEditorType = BookmarkEditorType.EDIT,\n                    bookmark = it,\n                    onBack = {\n                        setShowTopBar(true)\n                        feedViewModel.showBookmarkEditorScreen.value = false\n                    },\n                    updateBookmark = { bookMark ->\n                        setShowTopBar(true)\n                        feedViewModel.showBookmarkEditorScreen.value = false\n                        feedViewModel.refreshFeed()\n                    }\n                )\n            }\n        }\n    }\n    if (feedViewModel.showDeleteConfirmationDialog.value && feedViewModel.bookmarkToDelete.value != null) {\n        ConfirmDialog(\n            title = \"Confirmation\",\n            content = \"Are you sure you want to delete this bookmark?\",\n            confirmButton = \"Delete\",\n            dismissButton = \"Cancel\",\n            onConfirm = {\n                feedViewModel.bookmarkToDelete.value?.let {\n                    feedViewModel.deleteLocalBookmark(it)\n                    feedViewModel.showDeleteConfirmationDialog.value = false\n                }\n            },\n            openDialog = feedViewModel.showDeleteConfirmationDialog,\n            properties = DialogProperties(\n                dismissOnClickOutside = true,\n                dismissOnBackPress = true\n            )\n        )\n    }\n    if (bookmarksUiState.isLoading || downloadUiState.value.isLoading) {\n        InfiniteProgressDialog(onDismissRequest = {})\n    }\n    if (!bookmarksUiState.error.isNullOrEmpty()) {\n        ConfirmDialog(\n            icon = Icons.Default.Error,\n            title = \"Error\",\n            content = bookmarksUiState.error,\n            openDialog = remember { mutableStateOf(true) },\n            onConfirm = {\n                if (bookmarksUiState.error == SESSION_HAS_BEEN_EXPIRED){\n                    actions.onClearError()\n                    actions.goToLogin.invoke()\n                }\n            },\n            properties = DialogProperties(\n                dismissOnClickOutside = false,\n                dismissOnBackPress = false\n            ),\n        )\n        Log.v(\"bookmarksUiState\", \"Error\")\n    }\n    val isUpdating = feedViewModel.bookmarksUiState.collectAsState().value.isUpdating\n    UpdateCacheDialog(\n        isLoading = isUpdating,\n        showDialog = feedViewModel.showSyncDialog\n    ) { keepOldTitle, updateArchive, updateEbook ->\n        feedViewModel.updateBookmarkCache(\n            keepOldTitle = keepOldTitle,\n            updateEbook = updateEbook,\n            updateArchive = updateArchive,\n        )\n        feedViewModel.showSyncDialog.value = false\n    }\n    if (!downloadUiState.value.error.isNullOrEmpty()) {\n        ConfirmDialog(\n            icon = Icons.Default.Error,\n            title = \"Download Error\",\n            content = downloadUiState.value.error?:\"Unknown error\",\n            openDialog = remember { mutableStateOf(true) },\n            onConfirm = { },\n            properties = DialogProperties(\n                dismissOnClickOutside = true,\n                dismissOnBackPress = true\n            ),\n        )\n    }\n\n    if (downloadUiState.value.data != null && feedViewModel.showEpubOptionsDialog.value) {\n        MediaScannerConnection.scanFile(\n            context,\n            arrayOf(downloadUiState.value.data?.absolutePath),\n            null\n        ) { path, uri -> }\n        EpubOptionsDialog(\n            icon = Icons.Default.Error,\n            title = \"Success\",\n            content = \"Epub file downloaded, would you like to share it?\",\n            onClickOption = { index ->\n                when (index) {\n                    2 -> {\n                        shareEpubFile.invoke(downloadUiState.value.data!!)\n                    }\n                }\n            },\n            properties = DialogProperties(\n                dismissOnClickOutside = true,\n                dismissOnBackPress = true\n            ),\n            showDialog = feedViewModel.showEpubOptionsDialog\n        )\n    }\n\n    if (isSearchBarVisible.value) {\n        val scope = rememberCoroutineScope()\n        ModalBottomSheet(\n            modifier = Modifier.fillMaxSize(),\n            shape = BottomSheetDefaults.ExpandedShape,\n            onDismissRequest = {\n                isSearchBarVisible.value = false\n            },\n            sheetState = sheetState,\n            dragHandle = null\n        ) {\n            SearchBar(\n                onBookmarkClick =  actions.onBookmarkSelect,\n                onDismiss = {\n                    scope.launch {\n                        sheetState.hide()\n                        isSearchBarVisible.value = false\n                    }\n                }\n            )\n        }\n    }\n\n    if (isCategoriesVisible.value) {\n        val scope = rememberCoroutineScope()\n        ModalBottomSheet(\n            shape = BottomSheetDefaults.ExpandedShape,\n            onDismissRequest = {\n                isCategoriesVisible.value = false\n            },\n            sheetState = sheetStateCategories,\n        ) {\n            val selectedTags by feedViewModel.selectedTags.collectAsState()\n            CategoriesView(\n                onDismiss = {\n                    scope.launch {\n                        sheetStateCategories.hide()\n                        isCategoriesVisible.value = false\n                    }\n                },\n                uniqueCategories = tagsState.data ?: emptyList(),\n                tagToHide = tagToHide,\n                onFilterHiddenTag = { value ->\n                    feedViewModel.showOnlyHiddenTag.value = value\n                },\n                selectedOptionIndex = feedViewModel.selectedOptionIndex.value,\n                onSelectedOptionIndexChanged = { newIndex ->\n                    feedViewModel.selectedOptionIndex.value = newIndex\n                },\n                selectedTags = selectedTags,\n                onCategoryDeselected = { tag ->\n                    feedViewModel.removeSelectedTag(tag)\n                },\n                onCategorySelected = { tag ->\n                    feedViewModel.addSelectedTag(tag)\n                },\n                onResetAll = {\n                    feedViewModel.resetTags()\n                },\n            )\n        }\n    }\n}\n\n@Composable\nprivate fun FeedView(\n    actions: FeedActions,\n    viewType: BookmarkViewType,\n    serverURL: String,\n    xSessionId: String,\n    token: String,\n    bookmarksPagingItems: LazyPagingItems<Bookmark>,\n    tagToHide: Tag?,\n    showOnlyHiddenTag: Boolean\n) {\n    if (bookmarksPagingItems.itemCount > 0) {\n        Column {\n            Box(\n                modifier = Modifier\n                    .fillMaxSize()\n                    .nestedScroll(rememberNestedScrollInteropConnection()),\n            ) {\n                FeedContent(\n                    actions = actions,\n                    serverURL = serverURL,\n                    xSessionId = xSessionId,\n                    token = token,\n                    viewType = viewType,\n                    bookmarksPagingItems = bookmarksPagingItems,\n                    tagToHide = tagToHide,\n                    showOnlyHiddenTag = showOnlyHiddenTag\n                )\n            }\n        }\n    } else  {\n        EmptyView(actions)\n    }\n}\n\n@Composable\nprivate fun EmptyView(actions: FeedActions) {\n    Box(\n        modifier = Modifier.fillMaxSize()\n    ) {\n        NoContentView(\n            modifier = Modifier\n                .padding(top = 100.dp)\n                .align(Alignment.Center),\n            onRefresh = actions.onRefreshFeed\n        )\n    }\n}\n\ndata class FeedActions(\n    val goToLogin: () -> Unit,\n    val onBookmarkSelect: (Bookmark) -> Unit,\n    val onRefreshFeed: () -> Unit,\n    val onEditBookmark: (Bookmark) -> Unit,\n    val onDeleteBookmark: (Bookmark) -> Unit,\n    val onShareBookmark: (Bookmark) -> Unit,\n    val onBookmarkEpub: (Bookmark) -> Unit,\n    val onClickSync: (Bookmark) -> Unit,\n    val onClearError: () -> Unit,\n    val onCategoriesSelectedChanged: (List<Tag>) -> Unit,\n)"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/FeedViewModel.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.feed\n\nimport android.util.Log\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport androidx.paging.LoadState\nimport com.desarrollodroide.pagekeeper.ui.components.UiState\nimport com.desarrollodroide.pagekeeper.ui.components.error\nimport com.desarrollodroide.pagekeeper.ui.components.idle\nimport com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource\nimport com.desarrollodroide.data.mapper.toProtoEntity\nimport com.desarrollodroide.network.model.SessionDTO\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.asStateFlow\nimport kotlinx.coroutines.launch\nimport com.desarrollodroide.common.result.Result\nimport com.desarrollodroide.data.extensions.removeTrailingSlash\nimport com.desarrollodroide.domain.usecase.DeleteBookmarkUseCase\nimport com.desarrollodroide.domain.usecase.DownloadFileUseCase\nimport com.desarrollodroide.domain.usecase.GetLocalPagingBookmarksUseCase\nimport com.desarrollodroide.domain.usecase.UpdateBookmarkCacheUseCase\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.model.Tag\nimport com.desarrollodroide.model.UpdateCachePayload\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.distinctUntilChanged\nimport kotlinx.coroutines.runBlocking\nimport java.io.File\nimport androidx.paging.cachedIn\nimport androidx.paging.PagingData\nimport com.desarrollodroide.data.helpers.SESSION_HAS_BEEN_EXPIRED\nimport com.desarrollodroide.data.local.room.dao.BookmarksDao\nimport com.desarrollodroide.data.mapper.toDomainModel\nimport com.desarrollodroide.data.repository.SyncWorks\nimport com.desarrollodroide.data.repository.SyncStatus\nimport com.desarrollodroide.domain.usecase.DeleteLocalBookmarkUseCase\nimport com.desarrollodroide.domain.usecase.GetTagsUseCase\nimport com.desarrollodroide.domain.usecase.SyncBookmarksUseCase\nimport com.desarrollodroide.domain.usecase.GetAllRemoteBookmarksUseCase\nimport com.desarrollodroide.model.SyncBookmarksRequestPayload\nimport com.desarrollodroide.model.SyncBookmarksResponse\nimport com.desarrollodroide.pagekeeper.ui.components.success\nimport kotlinx.coroutines.Job\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.flatMapLatest\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.flow.update\n\nclass FeedViewModel(\n    private val bookmarkDatabase: BookmarksDao,\n    private val settingsPreferenceDataSource: SettingsPreferenceDataSource,\n    private val getTagsUseCase: GetTagsUseCase,\n    private val getLocalPagingBookmarksUseCase: GetLocalPagingBookmarksUseCase,\n    private val deleteBookmarkUseCase: DeleteBookmarkUseCase,\n    private val updateBookmarkCacheUseCase: UpdateBookmarkCacheUseCase,\n    private val downloadFileUseCase: DownloadFileUseCase,\n    private val getAllRemoteBookmarksUseCase: GetAllRemoteBookmarksUseCase,\n    private val deleteLocalBookmarkUseCase: DeleteLocalBookmarkUseCase,\n    private val syncBookmarksUseCase: SyncBookmarksUseCase,\n    private val syncManager: SyncWorks,\n\n    ) : ViewModel() {\n\n    private val TAG = \"FeedViewModel\"\n    private val _bookmarksUiState = MutableStateFlow(UiState<List<Bookmark>>(idle = true))\n    val bookmarksUiState = _bookmarksUiState.asStateFlow()\n    private val _downloadUiState = MutableStateFlow(UiState<File>(idle = true))\n    val downloadUiState = _downloadUiState.asStateFlow()\n    private val _bookmarksState: MutableStateFlow<PagingData<Bookmark>> = MutableStateFlow(value = PagingData.empty())\n    val bookmarksState: MutableStateFlow<PagingData<Bookmark>> get() = _bookmarksState\n\n    private val _tagsState = MutableStateFlow(UiState<List<Tag>>(idle = true))\n    val tagsState = _tagsState.asStateFlow()\n\n    private val _currentBookmark = MutableStateFlow<Bookmark?>(null)\n    val currentBookmark = _currentBookmark.asStateFlow()\n\n    private var tagsJob: Job? = null\n    private var serverUrl = \"\"\n    private var xSessionId = \"\"\n    private var token = \"\"\n    val showBookmarkEditorScreen = mutableStateOf(false)\n    val showDeleteConfirmationDialog = mutableStateOf(false)\n    val showEpubOptionsDialog = mutableStateOf(false)\n    val showSyncDialog = mutableStateOf(false)\n    val bookmarkSelected = mutableStateOf<Bookmark?>(null)\n    val bookmarkToDelete = mutableStateOf<Bookmark?>(null)\n    val bookmarkToUpdateCache = mutableStateOf<Bookmark?>(null)\n    val showOnlyHiddenTag = MutableStateFlow<Boolean>(false)\n    val selectedOptionIndex = mutableStateOf(0)\n    private var isInitialized = false\n\n    val compactView: StateFlow<Boolean> = settingsPreferenceDataSource.compactViewFlow\n        .stateIn(viewModelScope, SharingStarted.Eagerly, false)\n    val tagToHide: StateFlow<Tag?> = settingsPreferenceDataSource.hideTagFlow\n        .stateIn(viewModelScope, SharingStarted.Eagerly, null)\n\n    val selectedTags: StateFlow<List<Tag>> = combine(\n        settingsPreferenceDataSource.selectedCategoriesFlow,\n        _tagsState\n    ) { selectedIds, tagsState ->\n        val allTags = tagsState.data ?: emptyList()\n        allTags.filter { it.id.toString() in selectedIds }\n    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())\n\n    private val _syncState = MutableStateFlow<UiState<SyncBookmarksResponse>>(UiState(idle = true))\n    val syncState: StateFlow<UiState<SyncBookmarksResponse>> = _syncState.asStateFlow()\n\n\n    suspend fun initializeIfNeeded() {\n        if (!isInitialized) {\n            isInitialized = true\n            loadInitialData()\n        }\n    }\n\n    init {\n        viewModelScope.launch {\n            combine(\n                selectedTags,\n                showOnlyHiddenTag,\n                tagToHide\n            ) { selectedTags, showOnlyHidden, hiddenTag ->\n                Triple(selectedTags, showOnlyHidden, hiddenTag)\n            }.flatMapLatest { (selectedTags, showOnlyHidden, hiddenTag) ->\n                getLocalPagingBookmarksUseCase.invoke(\n                    serverUrl = serverUrl,\n                    xSession = settingsPreferenceDataSource.getSession(),\n                    tags = if (showOnlyHidden) emptyList() else selectedTags,\n                    showOnlyHiddenTag = showOnlyHidden,\n                    tagToHide = hiddenTag\n                )\n            }.cachedIn(viewModelScope)\n                .collect { pagingData ->\n                    _bookmarksState.value = pagingData\n                }\n        }\n\n        viewModelScope.launch {\n            getTagsUseCase.getLocalTags()\n                .distinctUntilChanged()\n                .collect { localTags ->\n                    Log.d(\"FeedViewModel\", \"Tags updated: ${localTags.size}\")\n                    if (localTags.isNotEmpty()) {\n                        _tagsState.success(localTags)\n                    } else {\n                        _tagsState.success(emptyList())\n                    }\n                }\n        }\n    }\n\n    fun loadInitialData() {\n        viewModelScope.launch {\n            serverUrl = settingsPreferenceDataSource.getUrl()\n            token = settingsPreferenceDataSource.getToken()\n            xSessionId = settingsPreferenceDataSource.getSession()\n            //getLocalTags()\n            if (_tagsState.value.data.isNullOrEmpty()) {\n                getRemoteTags()\n            }\n            if (bookmarkDatabase.isEmpty()) {\n                settingsPreferenceDataSource.setCurrentTimeStamp()\n                retrieveAllRemoteBookmarks()\n            }\n            refreshFeed()\n        }\n    }\n\n    fun getLocalTags() {\n        tagsJob?.cancel()\n        tagsJob = viewModelScope.launch {\n            getTagsUseCase.getLocalTags()\n                .distinctUntilChanged()\n                .collect { localTags ->\n                    Log.d(\"FeedViewModel\", \"Tags updated: ${localTags.size}\")\n                    if (localTags.isNotEmpty()) {\n                        _tagsState.success(localTags)\n                    } else {\n                        _tagsState.success(emptyList())\n                    }\n                }\n        }\n    }\n\n    fun syncBookmarks(ids: List<Int>, lastSync: Long, page: Int = 1) {\n        viewModelScope.launch {\n            _syncState.value = UiState(isLoading = true)\n            val syncBookmarksRequestPayload = SyncBookmarksRequestPayload(\n                ids = ids,\n                last_sync = lastSync,\n                page = page\n            )\n            syncBookmarksUseCase(\n                token = token,\n                serverUrl = serverUrl,\n                syncBookmarksRequestPayload = syncBookmarksRequestPayload\n            ).collect { result ->\n                when (result) {\n                    is Result.Success -> {\n                        result.data?.let { response ->\n                            Log.v(TAG, \"Sync response: $response\")\n                            _syncState.value = UiState(data = response)\n                            syncBookmarksUseCase.handleSuccessfulSync(response, lastSync)\n                        } ?: run {\n                            _syncState.value = UiState(error = \"Sync response was null\")\n                            Log.e(TAG, \"Sync response was null\")\n                        }\n                    }\n                    is Result.Error -> {\n                        //_syncState.value = UiState(error = result.error?.message)\n                        Log.e(TAG, \"Error syncing bookmarks: ${result.error?.message}\")\n                    }\n                    is Result.Loading -> {}\n                }\n            }\n        }\n    }\n\n    private fun retrieveAllRemoteBookmarks() {\n        Log.v(TAG, \"Syncing bookmarks\")\n        viewModelScope.launch {\n            getAllRemoteBookmarksUseCase.invoke(\n                serverUrl = serverUrl,\n                xSession = settingsPreferenceDataSource.getSession()\n            ).collect { result ->\n                result.fold(\n                    onSuccess = { status ->\n                        when (status) {\n                            is SyncStatus.Started -> {\n                                Log.v(TAG, \"Sync started\")\n                            }\n                            is SyncStatus.InProgress -> {\n                                Log.v(TAG, \"Sync in progress\")\n                            }\n                            is SyncStatus.Completed -> {\n                                Log.v(TAG, \"Sync completed\")\n                            }\n                            is SyncStatus.Error -> {\n                                Log.v(TAG, \"Sync error\")\n                                if (status.error is Result.ErrorType.SessionExpired) {\n                                    Log.v(TAG, \"Session expired\")\n                                }\n                                handleSyncError(status.error)\n                            }\n                            SyncStatus.Started -> { }\n                        }\n                    },\n                    onFailure = { throwable ->\n                        _bookmarksUiState.error(errorMessage = throwable.message.toString())\n                    }\n                )\n            }\n        }\n    }\n\n    private fun handleSyncError(error: Result.ErrorType) {\n        if (error is Result.ErrorType.SessionExpired) {\n            _bookmarksUiState.error(errorMessage = SESSION_HAS_BEEN_EXPIRED)\n        } else {\n            Log.e(TAG, \"Unhandled exception: ${error.message}\")\n            //_bookmarksUiState.error(errorMessage = \"Unhandled exception: ${error.message}\")\n        }\n    }\n\n    fun refreshFeed() {\n        viewModelScope.launch {\n            val localBookmarkIds = bookmarkDatabase.getAllBookmarkIds()\n            // TODO sync disabled until endpoint finished\n            //syncBookmarks(localBookmarkIds, settingsPreferenceDataSource.getLastSyncTimestamp())\n            // TODO remove with sync is completed in backend\n            retrieveAllRemoteBookmarks()\n        }\n    }\n\n    fun getRemoteTags() {\n        tagsJob?.cancel()\n        tagsJob =  viewModelScope.launch {\n            getTagsUseCase.invoke(\n                serverUrl = serverUrl,\n                token = token,\n            )\n                .distinctUntilChanged()\n                .collect() { result ->\n                when (result) {\n                    is Result.Error -> {\n                        Log.v(\"FeedViewModel\", \"Error getting tags: ${result.error?.message}\")\n                    }\n                    is Result.Loading -> {\n                        Log.v(\"FeedViewModel\", \"Loading, updating tags from cache...\")\n                        _tagsState.success(result.data)\n                    }\n                    is Result.Success -> {\n                        Log.v(\"FeedViewModel\", \"Tags loaded successfully.\")\n                        _tagsState.success(result.data)\n                    }\n                }\n            }\n        }\n    }\n\n    fun handleLoadState(loadState: LoadState) {\n        if (loadState is LoadState.Error) {\n            _bookmarksUiState.update { currentState ->\n                currentState.copy(isLoading = false, error = loadState.error.message)\n            }\n        }\n    }\n\n    fun updateBookmarkCache(\n        keepOldTitle: Boolean,\n        updateArchive: Boolean,\n        updateEbook: Boolean,\n    ) {\n        val updateCachePayload = UpdateCachePayload(\n            ids = listOf(bookmarkToUpdateCache.value?.id ?: -1),\n            createArchive = updateArchive,\n            createEbook = updateEbook,\n            keepMetadata = keepOldTitle,\n            skipExist = false\n        )\n\n        viewModelScope.launch {\n            updateBookmarkCacheUseCase.invoke(\n                bookmark = bookmarkToUpdateCache.value ?: return@launch,\n                updateCachePayload = updateCachePayload\n            )\n        }\n    }\n\n    fun resetData() {\n        isInitialized = false\n        _bookmarksUiState.idle(true)\n        viewModelScope.launch {\n            settingsPreferenceDataSource.saveUser(\n                password = \"\",\n                session = SessionDTO(null, null, null).toProtoEntity(),\n                serverUrl = \"\"\n            )\n        }\n    }\n\n    fun getUrl(bookmark: Bookmark) =\n        if (bookmark.public == 1) \"${serverUrl.removeTrailingSlash()}/bookmark/${bookmark.id}/content\" else {\n            bookmark.url\n        }\n\n    fun getEpubUrl(bookmark: Bookmark) =\n        \"${serverUrl.removeTrailingSlash()}/bookmark/${bookmark.id}/ebook\"\n\n    fun deleteBookmark(bookmark: Bookmark) {\n        viewModelScope.launch {\n            deleteBookmarkUseCase.invoke(bookmark = bookmark)\n        }\n    }\n\n    fun deleteLocalBookmark(bookmark: Bookmark) {\n        viewModelScope.launch {\n            deleteLocalBookmarkUseCase(bookmark).collect { result ->\n                if (result is Result.Success) {\n                    deleteBookmark(bookmark = bookmark)\n                    // TODO\n                } else if (result is Result.Error){\n                    Log.v(\"FeedViewModel\",\"Error deleting local bookmark: ${result.error?.message}\")\n                    _bookmarksUiState.error(\n                        errorMessage = result.error?.message ?: \"Unknown error\"\n                    )\n                }\n            }\n        }\n    }\n\n    fun downloadFile(\n        bookmark: Bookmark,\n    ) {\n        viewModelScope.launch(Dispatchers.IO) {\n            val sessionId = settingsPreferenceDataSource.getSession()\n            _downloadUiState.value = UiState(isLoading = true)\n            try {\n                val downloadedFile =\n                    downloadFileUseCase.execute(getEpubUrl(bookmark), bookmark.title, sessionId)\n                _downloadUiState.value = UiState(data = downloadedFile)\n                showEpubOptionsDialog.value = true\n            } catch (e: Exception) {\n                _downloadUiState.value = UiState(error = e.message)\n            }\n        }\n    }\n\n    fun getServerUrl() = serverUrl\n\n    fun getSession(): String = xSessionId\n\n    fun getToken(): String = runBlocking {\n        settingsPreferenceDataSource.getToken()\n    }\n\n    fun addSelectedTag(tag: Tag) {\n        viewModelScope.launch {\n            val currentTags = selectedTags.value\n            if (tag !in currentTags) {\n                settingsPreferenceDataSource.addSelectedCategory(tag)\n            }\n        }\n    }\n\n    fun removeSelectedTag(tag: Tag) {\n        viewModelScope.launch {\n            settingsPreferenceDataSource.removeSelectedCategory(tag)\n        }\n    }\n\n    fun resetTags() {\n        viewModelScope.launch {\n            settingsPreferenceDataSource.setSelectedCategories(emptyList())\n        }\n    }\n\n    fun getPendingWorks() =\n        syncManager.getPendingJobs()\n\n    fun retryAllPendingJobs() {\n        viewModelScope.launch {\n            syncManager.retryAllPendingJobs()\n        }\n    }\n\n    fun loadBookmarkById(id: Int) {\n        viewModelScope.launch {\n            _currentBookmark.value = bookmarkDatabase.getBookmarkById(id)?.toDomainModel()\n        }\n    }\n}\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/ItemLazyLoad.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.feed\n\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.wrapContentWidth\nimport androidx.compose.material3.CircularProgressIndicator\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.OutlinedButton\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.style.TextOverflow\nimport androidx.compose.ui.unit.dp\n\n@Composable\nfun PageLoader(modifier: Modifier = Modifier) {\n    Column(\n        modifier = modifier,\n        verticalArrangement = Arrangement.Center,\n        horizontalAlignment = Alignment.CenterHorizontally\n    ) {\n        Text(\n            text = \"Fetching data from server\",\n            color = MaterialTheme.colorScheme.primary,\n            maxLines = 1,\n            overflow = TextOverflow.Ellipsis\n        )\n        CircularProgressIndicator(Modifier.padding(top = 10.dp))\n    }\n}\n\n@Composable\nfun LoadingNextPageItem(modifier: Modifier) {\n    CircularProgressIndicator(\n        modifier = modifier\n            .fillMaxWidth()\n            .padding(10.dp)\n            .wrapContentWidth(Alignment.CenterHorizontally)\n    )\n}\n\n@Composable\nfun ErrorMessage(\n    message: String,\n    modifier: Modifier = Modifier,\n    onClickRetry: () -> Unit\n) {\n    Row(\n        modifier = modifier.padding(10.dp),\n        horizontalArrangement = Arrangement.SpaceBetween,\n        verticalAlignment = Alignment.CenterVertically\n    ) {\n        Text(\n            text = message,\n            color = MaterialTheme.colorScheme.error,\n            modifier = Modifier.weight(1f),\n            maxLines = 2\n        )\n        OutlinedButton(onClick = onClickRetry) {\n            Text(\"Retry\")\n        }\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/NoContentView.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.feed\n\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.width\n\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.res.painterResource\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.unit.dp\nimport com.desarrollodroide.pagekeeper.R\n\n@Composable\nfun NoContentView(\n    modifier: Modifier = Modifier,\n    onRefresh: () -> Unit,\n) {\n    Column(\n        modifier = modifier,\n        horizontalAlignment = Alignment.CenterHorizontally,\n        verticalArrangement = Arrangement.Center\n    ) {\n        Icon(\n            modifier = Modifier.width(100.dp),\n            tint = MaterialTheme.colorScheme.secondary,\n            painter = painterResource(id = R.drawable.ic_empty_list),\n            contentDescription = \"No content image\"\n        )\n        Button(\n            onClick = onRefresh,\n            modifier = Modifier.padding(16.dp),\n        ) {\n            Text(text = \"Refresh\")\n        }\n    }\n}\n\n@Preview(\n    showBackground = true,\n    widthDp = 320,\n    heightDp = 480\n)\n@Composable\nfun NoContentViewPreview() {\n    NoContentView(onRefresh = {})\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/SearchBarView.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.feed\n\nimport android.widget.Toast\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.ArrowBack\nimport androidx.compose.material.icons.filled.Cancel\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.saveable.rememberSaveable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.unit.dp\nimport androidx.paging.compose.collectAsLazyPagingItems\nimport com.desarrollodroide.model.Bookmark\nimport org.koin.androidx.compose.getViewModel\n\n@Composable\n@OptIn(ExperimentalMaterial3Api::class)\nfun SearchBar(\n    onBookmarkClick: (Bookmark) -> Unit,\n    onDismiss: () -> Unit,\n    viewModel: SearchViewModel = getViewModel()\n) {\n    val searchText by viewModel.searchQuery.collectAsState()\n    val isActive = rememberSaveable { mutableStateOf(true) }\n    val context = LocalContext.current\n    val filteredBookmarks = viewModel.bookmarksState.collectAsLazyPagingItems()\n    Box(\n        Modifier\n        .fillMaxSize()) {\n        androidx.compose.material3.SearchBar(\n            modifier = Modifier\n                .align(Alignment.TopCenter),\n            query = searchText,\n            onQueryChange = { viewModel.updateSearchQuery(it) },\n            onSearch = {\n                Toast.makeText(context, \"Select bookmark from list\", Toast.LENGTH_SHORT).show()\n            },\n            active = isActive.value,\n            onActiveChange = { isActive.value = it },\n            placeholder = { Text(\"Search...\") },\n            leadingIcon = {\n                IconButton(onClick = {\n                    onDismiss()\n                }) {\n                    Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = \"Go back\")\n                }\n            },\n            trailingIcon = {\n                Row() {\n                    Box(modifier = Modifier\n                        .padding(end = 8.dp)\n                        .clickable {\n                            viewModel.resetSearch()\n                        }) {\n                        Icon(Icons.Default.Cancel, contentDescription = null)\n                    }\n                }\n            },\n        ) {\n            BookmarkSuggestions(\n                bookmarks = filteredBookmarks,\n                onClickSuggestion = onBookmarkClick\n            )\n        }\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/SearchViewModel.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.feed\n\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport androidx.paging.PagingData\nimport androidx.paging.cachedIn\nimport com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource\nimport com.desarrollodroide.domain.usecase.GetLocalPagingBookmarksUseCase\nimport com.desarrollodroide.model.Bookmark\nimport kotlinx.coroutines.FlowPreview\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.collectLatest\nimport kotlinx.coroutines.flow.debounce\nimport kotlinx.coroutines.flow.distinctUntilChanged\nimport kotlinx.coroutines.flow.update\nimport kotlinx.coroutines.launch\n\n@OptIn(FlowPreview::class)\nclass SearchViewModel(\n    private val getPagingBookmarksUseCase: GetLocalPagingBookmarksUseCase,\n    private val settingsPreferenceDataSource: SettingsPreferenceDataSource,\n    ) : ViewModel() {\n\n    private val _searchQuery = MutableStateFlow(\"\")\n    val searchQuery: StateFlow<String> = _searchQuery\n\n    private val _bookmarksState: MutableStateFlow<PagingData<Bookmark>> = MutableStateFlow(value = PagingData.empty())\n    val bookmarksState: MutableStateFlow<PagingData<Bookmark>> get() = _bookmarksState\n\n    init {\n        viewModelScope.launch {\n            _searchQuery\n                .debounce(1000)\n                .distinctUntilChanged()\n                .collectLatest { query ->\n                    if (query.isNotEmpty()) {\n                        getPagingBookmarks(query)\n                    }\n                }\n        }\n    }\n\n    fun updateSearchQuery(query: String) {\n        _searchQuery.update { query }\n    }\n\n    suspend fun getPagingBookmarks(\n        searchText: String\n    ) {\n        _bookmarksState.value = PagingData.empty()\n        getPagingBookmarksUseCase.invoke(\n            serverUrl = settingsPreferenceDataSource.getUrl(),\n            xSession = settingsPreferenceDataSource.getSession(),\n            searchText = searchText,\n            tags = emptyList(),\n        )\n            .cachedIn(viewModelScope)\n            .collect {\n                _bookmarksState.value = it\n            }\n    }\n\n    fun resetSearch() {\n        _searchQuery.value = \"\"\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/item/BookmarkImageView.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.feed.item\n\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.heightIn\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.layout.ContentScale\nimport coil.request.ImageRequest\nimport okhttp3.Headers\nimport android.graphics.Bitmap\nimport androidx.compose.material3.Icon\nimport androidx.compose.ui.platform.LocalInspectionMode\nimport coil.ImageLoader\nimport coil.size.Size\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.graphics.FilterQuality\nimport coil.compose.AsyncImage\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Image\nimport org.koin.androidx.compose.get\n\n@Composable\nfun BookmarkImageView(\n    imageUrl: String,\n    xSessionId: String,\n    token: String,\n    modifier: Modifier = Modifier,\n    contentScale: ContentScale,\n    loadAsThumbnail: Boolean\n) {\n    if (LocalInspectionMode.current) {\n        Icon(\n            imageVector = Icons.Default.Image,\n            contentDescription = \"Placeholder image\",\n            modifier = modifier\n        )\n    } else {\n        val context = LocalContext.current\n        val imageLoader = get<ImageLoader>()\n\n        AsyncImage(\n            model = ImageRequest.Builder(context)\n                .data(imageUrl)\n                .bitmapConfig(Bitmap.Config.ARGB_8888)\n                .apply {\n                    if (loadAsThumbnail) {\n                        size(Size(100, 100))\n                    } else {\n                        size(Size.ORIGINAL)\n                    }\n                }\n                .headers(\n                    Headers.Builder().add(\"Authorization\", \"Bearer $token\").build()\n                )\n                .build(),\n            contentDescription = \"Bookmark image\",\n            imageLoader = imageLoader,\n            modifier = modifier\n                .heightIn(max = if (loadAsThumbnail) 100.dp else 200.dp)\n                .fillMaxWidth(),\n            alignment = Alignment.Center,\n            contentScale = contentScale,\n            alpha = 1.0f,\n            colorFilter = null,\n            filterQuality = FilterQuality.Medium,\n            clipToBounds = true\n        )\n    }\n}\n\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/item/BookmarkItem.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.feed.item\n\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material3.Card\nimport androidx.compose.material3.CardDefaults\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.derivedStateOf\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.remember\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.unit.dp\nimport com.desarrollodroide.data.helpers.BookmarkViewType\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.model.Tag\n\ndata class BookmarkActions(\n    val onClickEdit: (GetBookmark) -> Unit,\n    val onClickDelete: (GetBookmark) -> Unit,\n    val onClickShare: (GetBookmark) -> Unit,\n    val onClickCategory: (Tag) -> Unit,\n    val onClickBookmark: (GetBookmark) -> Unit,\n    val onClickEpub: (GetBookmark) -> Unit,\n    val onClickSync: (GetBookmark) -> Unit\n)\n\ntypealias GetBookmark = () -> Bookmark\n\n@Composable\nfun BookmarkItem(\n    getBookmark: GetBookmark,\n    serverURL: String,\n    xSessionId: String,\n    token: String,\n    actions: BookmarkActions,\n    viewType: BookmarkViewType\n) {\n    val bookmark by remember { derivedStateOf(getBookmark) }\n    Box(modifier = Modifier\n        .padding(horizontal = 6.dp)\n        .padding(bottom = if (viewType == BookmarkViewType.FULL) 0.dp else 6.dp)\n    ) {\n        Box(\n            modifier = Modifier\n                .fillMaxWidth()\n                .clickable { actions.onClickBookmark(getBookmark) },\n        ) {\n            when (viewType) {\n                BookmarkViewType.FULL -> FullBookmarkView(\n                    getBookmark = getBookmark,\n                    serverURL = serverURL,\n                    xSessionId = xSessionId,\n                    token = token,\n                    actions = actions\n                )\n\n                BookmarkViewType.SMALL -> SmallBookmarkView(\n                    getBookmark = getBookmark,\n                    serverURL = serverURL,\n                    xSessionId = xSessionId,\n                    token = token,\n                    actions = actions\n                )\n            }\n        }\n    }\n}\n\n@Preview\n@Composable\nfun PreviewPost() {\n    MaterialTheme {\n        val mockBookmark = Bookmark.mock()\n        val actions = BookmarkActions(\n            onClickEdit = { },\n            onClickDelete = { },\n            onClickShare = { },\n            onClickCategory = { },\n            onClickBookmark = { },\n            onClickEpub = { },\n            onClickSync = { }\n        )\n        Column {\n            BookmarkItem(\n                getBookmark = { mockBookmark },\n                serverURL = \"\",\n                xSessionId = \"\",\n                token = \"\",\n                actions = actions,\n                viewType = BookmarkViewType.FULL\n            )\n            BookmarkItem(\n                getBookmark = { mockBookmark },\n                serverURL = \"\",\n                xSessionId = \"\",\n                token = \"\",\n                actions = actions,\n                viewType = BookmarkViewType.SMALL\n            )\n        }\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/item/ButtonsView.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.feed.item\n\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.CloudUpload\nimport androidx.compose.material.icons.filled.Delete\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.filled.Share\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.derivedStateOf\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.remember\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.res.painterResource\nimport com.desarrollodroide.data.extensions.isTimestampId\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.pagekeeper.R\n\n@Composable\nfun ButtonsView(\n    getBookmark: GetBookmark,\n    actions: BookmarkActions\n) {\n    val bookmark by remember { derivedStateOf(getBookmark) }\n\n    Row(\n        modifier = Modifier.fillMaxWidth(),\n        horizontalArrangement = Arrangement.End\n    ) {\n        IconButton(onClick = { actions.onClickEdit(getBookmark) }) {\n            Icon(\n                imageVector = Icons.Filled.Edit,\n                contentDescription = \"Edit\",\n                tint = MaterialTheme.colorScheme.secondary\n            )\n        }\n        IconButton(onClick = { actions.onClickDelete(getBookmark) }) {\n            Icon(\n                imageVector = Icons.Filled.Delete,\n                contentDescription = \"Delete\",\n                tint = MaterialTheme.colorScheme.secondary\n            )\n        }\n        if (bookmark.hasEbook) {\n            IconButton(onClick = { actions.onClickEpub(getBookmark) }) {\n                Icon(\n                    painter = painterResource(id = R.drawable.ic_book),\n                    contentDescription = \"Epub\",\n                    tint = MaterialTheme.colorScheme.secondary\n                )\n            }\n        }\n        IconButton(onClick = { actions.onClickShare(getBookmark) }) {\n            Icon(\n                imageVector = Icons.Filled.Share,\n                contentDescription = \"Share\",\n                tint = MaterialTheme.colorScheme.secondary\n            )\n        }\n        if (!bookmark.id.isTimestampId()){\n            IconButton(onClick = { actions.onClickSync(getBookmark) }) {\n                Icon(\n                    imageVector = Icons.Filled.CloudUpload,\n                    contentDescription = \"Sync\",\n                    tint = MaterialTheme.colorScheme.secondary\n                )\n            }\n        }\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/item/ClickableCategoriesView.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.feed.item\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.ExperimentalLayoutApi\nimport androidx.compose.foundation.layout.FlowRow\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.unit.dp\nimport com.desarrollodroide.model.Tag\n\n@Composable\n@OptIn(ExperimentalLayoutApi::class)\nfun ClickableCategoriesView(\n    uniqueCategories: List<Tag>,\n    onClickCategory: (Tag) -> Unit\n) {\n    FlowRow(\n    ) {\n        uniqueCategories.forEach { category ->\n            Text(\n                color = MaterialTheme.colorScheme.onSurface,\n                modifier = Modifier\n                    .padding(5.dp)\n                    .clip(RoundedCornerShape(18.dp))\n                    .background(MaterialTheme.colorScheme.secondaryContainer)\n                    .clickable { onClickCategory(category) }\n                    .padding(vertical = 8.dp, horizontal = 16.dp),\n                text = category.name\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/item/FullBookmarkView.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.feed.item\n\nimport android.content.res.Configuration\nimport android.util.Log\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.CompositionLocalProvider\nimport androidx.compose.runtime.derivedStateOf\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.remember\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.layout.ContentScale\nimport androidx.compose.ui.platform.LocalLayoutDirection\nimport androidx.compose.ui.text.style.TextOverflow\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.unit.LayoutDirection\nimport androidx.compose.ui.unit.dp\nimport com.desarrollodroide.data.extensions.removeTrailingSlash\nimport com.desarrollodroide.data.helpers.BookmarkViewType\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.pagekeeper.extensions.isRTLText\n\n@Composable\nfun FullBookmarkView(\n    getBookmark: GetBookmark,\n    serverURL: String,\n    xSessionId: String,\n    token: String,\n    actions: BookmarkActions\n) {\n    val bookmark by remember { derivedStateOf(getBookmark) }\n    val isArabic by remember { derivedStateOf { bookmark.title.isRTLText() || bookmark.excerpt.isRTLText() } }\n    //val imageUrl by remember { derivedStateOf { \"${serverURL.removeTrailingSlash()}${bookmark.imageURL}?lastUpdated=${bookmark.modified}\" } }\n    val imageUrl by remember { derivedStateOf { \"${serverURL.removeTrailingSlash()}${bookmark.imageURL}\" } }\n\n    Column {\n        if (bookmark.isPendingServerProcessing) {\n            PendingSyncBanner()\n        }\n        if (bookmark.imageURL.isNotEmpty()) {\n            BookmarkImageView(\n                imageUrl = imageUrl,\n                xSessionId = xSessionId,\n                token = token,\n                modifier = Modifier\n                    .fillMaxWidth()\n                    .padding(top = 16.dp)\n                    .clip(RoundedCornerShape(12.dp)),\n                contentScale = ContentScale.FillWidth,\n                loadAsThumbnail = false\n            )\n        }\n        Column(\n            modifier = Modifier.padding(16.dp)\n        ) {\n            CompositionLocalProvider(LocalLayoutDirection provides if (isArabic) LayoutDirection.Rtl else LayoutDirection.Ltr) {\n                Text(\n                    modifier = Modifier.fillMaxWidth(),\n                    text = if (bookmark.title.isNullOrEmpty()) bookmark.url else bookmark.title,\n                    style = MaterialTheme.typography.titleLarge,\n                    overflow = TextOverflow.Ellipsis,\n                    maxLines = 2\n                )\n                Text(\n                    modifier = Modifier\n                        .fillMaxWidth()\n                        .padding(top = 5.dp),\n                    text = bookmark.excerpt,\n                    style = MaterialTheme.typography.bodyMedium,\n                    color = MaterialTheme.colorScheme.secondary,\n                    overflow = TextOverflow.Ellipsis,\n                    maxLines = 3\n                )\n            }\n            Text(\n                modifier = Modifier.padding(top = 8.dp),\n                text = bookmark.modified,\n                style = MaterialTheme.typography.bodySmall,\n                color = MaterialTheme.colorScheme.tertiary,\n                maxLines = 1\n            )\n            Spacer(modifier = Modifier.height(8.dp))\n            ClickableCategoriesView(\n                uniqueCategories = bookmark.tags,\n                onClickCategory = actions.onClickCategory\n            )\n            Spacer(modifier = Modifier.height(8.dp))\n            ButtonsView(getBookmark = getBookmark, actions = actions)\n        }\n    }\n}\n\n@Preview\n@Composable\nprivate fun FullBookmarkViewPreview() {\n    MaterialTheme {\n        val mockBookmark = Bookmark.mock()\n        val actions = BookmarkActions(\n            onClickEdit = {},\n            onClickDelete = {},\n            onClickShare = {},\n            onClickCategory = {},\n            onClickBookmark = {},\n            onClickEpub = {},\n            onClickSync = {}\n        )\n        Column {\n            BookmarkItem(\n                getBookmark = { mockBookmark },\n                serverURL = \"\",\n                xSessionId = \"\",\n                token = \"\",\n                actions = actions,\n                viewType = BookmarkViewType.FULL\n            )\n            BookmarkItem(\n                getBookmark = { mockBookmark },\n                serverURL = \"\",\n                xSessionId = \"\",\n                token = \"\",\n                actions = actions,\n                viewType = BookmarkViewType.SMALL\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/item/PendingSyncBanner.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.feed.item\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.HourglassTop\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.unit.dp\n\n@Composable\nfun PendingSyncBanner() {\n    Row(\n        modifier = Modifier\n            .fillMaxWidth()\n            .padding(horizontal = 16.dp, vertical = 8.dp)\n            .background(\n                color = MaterialTheme.colorScheme.tertiaryContainer,\n                shape = RoundedCornerShape(8.dp)\n            )\n            .padding(horizontal = 12.dp, vertical = 8.dp),\n        verticalAlignment = Alignment.CenterVertically\n    ) {\n        Icon(\n            imageVector = Icons.Filled.HourglassTop,\n            contentDescription = \"Pending\",\n            modifier = Modifier.size(18.dp),\n            tint = MaterialTheme.colorScheme.onTertiaryContainer\n        )\n        Spacer(modifier = Modifier.width(8.dp))\n        Column {\n            Text(\n                text = \"Pending server processing\",\n                style = MaterialTheme.typography.labelMedium,\n                color = MaterialTheme.colorScheme.onTertiaryContainer\n            )\n            Text(\n                text = \"Pull to refresh to update\",\n                style = MaterialTheme.typography.bodySmall,\n                color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.7f)\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/item/SmallBookmarkView.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.feed.item\n\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.aspectRatio\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.layout.wrapContentHeight\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.MoreVert\nimport androidx.compose.material.icons.outlined.CloudUpload\nimport androidx.compose.material.icons.outlined.Delete\nimport androidx.compose.material.icons.outlined.Edit\nimport androidx.compose.material.icons.outlined.Share\nimport androidx.compose.material3.DropdownMenu\nimport androidx.compose.material3.DropdownMenuItem\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.CompositionLocalProvider\nimport androidx.compose.runtime.derivedStateOf\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.layout.ContentScale\nimport androidx.compose.ui.platform.LocalLayoutDirection\nimport androidx.compose.ui.res.painterResource\nimport androidx.compose.ui.text.style.TextOverflow\nimport androidx.compose.ui.unit.DpOffset\nimport androidx.compose.ui.unit.LayoutDirection\nimport androidx.compose.ui.unit.dp\nimport com.desarrollodroide.data.extensions.isTimestampId\nimport com.desarrollodroide.data.extensions.removeTrailingSlash\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.pagekeeper.R\nimport com.desarrollodroide.pagekeeper.extensions.isRTLText\n\n@Composable\nfun SmallBookmarkView(\n    getBookmark: GetBookmark,\n    serverURL: String,\n    xSessionId: String,\n    token: String,\n    actions: BookmarkActions\n) {\n    val bookmark by remember { derivedStateOf(getBookmark) }\n    val imageUrl by remember { derivedStateOf {\n        \"${serverURL.removeTrailingSlash()}${bookmark.imageURL}\"\n    }}\n    val modifier = if (bookmark.imageURL.isNotEmpty()) Modifier.height(90.dp) else Modifier.wrapContentHeight()\n    val isArabic by remember { derivedStateOf { bookmark.title.isRTLText() || bookmark.excerpt.isRTLText() } }\n\n    Column {\n        if (bookmark.isPendingServerProcessing) {\n            PendingSyncBanner()\n        }\n        Row(\n            modifier = modifier\n                .padding(vertical = 8.dp)\n                .padding(start = 8.dp)\n        ) {\n        if (bookmark.imageURL.isNotEmpty()) {\n            BookmarkImageView(\n                imageUrl = imageUrl,\n                xSessionId = xSessionId,\n                token = token,\n                modifier = Modifier\n                    .aspectRatio(1f)\n                    .clip(\n                        RoundedCornerShape(8.dp)\n                    ),\n                contentScale = ContentScale.Crop,\n                loadAsThumbnail = true\n            )\n            Spacer(modifier = Modifier.width(8.dp))\n        }\n        Spacer(modifier = Modifier.width(8.dp))\n        Row(\n            modifier = Modifier\n                .fillMaxWidth()\n                .padding(top = 4.dp),\n            horizontalArrangement = Arrangement.SpaceBetween\n        ) {\n            Column(\n                modifier = Modifier.weight(1f)\n            ) {\n                CompositionLocalProvider(LocalLayoutDirection provides if (isArabic) LayoutDirection.Rtl else LayoutDirection.Ltr) {\n                    Text(\n                        text = if (bookmark.title.isNullOrEmpty()) bookmark.url else bookmark.title,\n                        style = MaterialTheme.typography.titleMedium,\n                        overflow = TextOverflow.Ellipsis,\n                        maxLines = 2\n                    )\n                    Spacer(modifier = Modifier.height(8.dp))\n                    Text(\n                        text = bookmark.modified,\n                        style = MaterialTheme.typography.bodySmall,\n                        color = MaterialTheme.colorScheme.tertiary,\n                        maxLines = 1\n                    )\n                }\n            }\n            Column {\n                val expanded = remember { mutableStateOf(false) }\n                IconButton(onClick = {\n                    expanded.value = true\n                }) {\n                    Icon(\n                        imageVector = Icons.Filled.MoreVert,\n                        contentDescription = \"Edit\",\n                        tint = MaterialTheme.colorScheme.secondary\n                    )\n                }\n                DropdownMenu(\n                    modifier = Modifier\n                        .align(alignment = Alignment.End),\n                    offset = DpOffset((8).dp, 0.dp),\n                    expanded = expanded.value,\n                    onDismissRequest = { expanded.value = false }\n                ) {\n                    DropdownMenuItem(\n                        text = { Text(\"Edit\") },\n                        onClick = {\n                            expanded.value = false\n                            actions.onClickEdit(getBookmark)\n                        },\n                        leadingIcon = {\n                            Icon(\n                                Icons.Outlined.Edit,\n                                contentDescription = null\n                            )\n                        })\n                    DropdownMenuItem(\n                        text = { Text(\"Delete\") },\n                        onClick = {\n                            expanded.value = false\n                            actions.onClickDelete(getBookmark)\n                        },\n                        leadingIcon = {\n                            Icon(\n                                Icons.Outlined.Delete,\n                                contentDescription = null\n                            )\n                        })\n                    if (bookmark.hasEbook) {\n                        DropdownMenuItem(\n                            text = { Text(\"Epub\") },\n                            onClick = {\n                                expanded.value = false\n                                actions.onClickEpub(getBookmark)\n                            },\n                            leadingIcon = {\n                                Icon(\n                                    painter = painterResource(id = R.drawable.ic_book),\n                                    contentDescription = \"Epub\",\n                                    tint = MaterialTheme.colorScheme.secondary\n                                )\n                            })\n                    }\n                    DropdownMenuItem(\n                        text = { Text(\"Share\") },\n                        onClick = {\n                            expanded.value = false\n                            actions.onClickShare(getBookmark)\n                        },\n                        leadingIcon = {\n                            Icon(\n                                Icons.Outlined.Share,\n                                contentDescription = null\n                            )\n                        })\n                    if (!bookmark.id.isTimestampId()){\n                        DropdownMenuItem(\n                            text = { Text(\"Update\") },\n                            onClick = {\n                                expanded.value = false\n                                actions.onClickSync(getBookmark)\n                            },\n                            leadingIcon = {\n                                Icon(\n                                    Icons.Outlined.CloudUpload,\n                                    contentDescription = null\n                                )\n                            })\n                    }\n                }\n            }\n        }\n        }\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/home/BottomNavItem.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.home\n\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport com.desarrollodroide.pagekeeper.navigation.NavItem\n\ndata class BottomNavItem(\n    val name: String,\n    val navItem: NavItem,\n    val icon: ImageVector,\n)"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/home/HomeScreen.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.home\n\nimport androidx.activity.compose.BackHandler\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.core.Animatable\nimport androidx.compose.animation.core.LinearEasing\nimport androidx.compose.animation.core.RepeatMode\nimport androidx.compose.animation.core.infiniteRepeatable\nimport androidx.compose.animation.core.tween\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.scaleIn\nimport androidx.compose.animation.scaleOut\nimport androidx.compose.foundation.Image\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.layout.wrapContentWidth\nimport androidx.compose.ui.Modifier\nimport org.koin.androidx.compose.get\nimport androidx.compose.runtime.*\nimport androidx.compose.material3.Text\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.material.icons.filled.Search\nimport androidx.compose.material.icons.filled.Settings\nimport androidx.compose.material.icons.filled.Sync\nimport androidx.compose.material.icons.filled.VisibilityOff\nimport androidx.compose.material.icons.outlined.Sell\nimport androidx.compose.material3.Badge\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.CircularProgressIndicator\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.HorizontalDivider\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.ModalBottomSheet\nimport androidx.compose.material3.Scaffold\nimport androidx.compose.material3.TopAppBar\nimport androidx.compose.material3.TopAppBarDefaults\nimport androidx.compose.material3.TopAppBarScrollBehavior\nimport androidx.compose.material3.rememberModalBottomSheetState\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.graphics.graphicsLayer\nimport androidx.compose.ui.input.nestedscroll.nestedScroll\nimport androidx.compose.ui.res.painterResource\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.text.style.TextOverflow\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\nimport androidx.navigation.NavType\nimport androidx.navigation.compose.NavHost\nimport androidx.navigation.compose.composable\nimport androidx.navigation.compose.rememberNavController\nimport androidx.navigation.navArgument\nimport androidx.paging.compose.collectAsLazyPagingItems\nimport com.desarrollodroide.data.helpers.SHIORI_ANDROID_CLIENT_GITHUB_URL\nimport com.desarrollodroide.model.PendingJob\nimport com.desarrollodroide.model.SyncOperationType\nimport com.desarrollodroide.pagekeeper.navigation.NavItem\nimport com.desarrollodroide.pagekeeper.ui.feed.FeedScreen\nimport com.desarrollodroide.pagekeeper.ui.feed.FeedViewModel\nimport com.desarrollodroide.pagekeeper.ui.settings.PrivacyPolicyScreen\nimport com.desarrollodroide.pagekeeper.ui.settings.SettingsScreen\nimport com.desarrollodroide.pagekeeper.ui.settings.TermsOfUseScreen\nimport java.io.File\nimport com.desarrollodroide.pagekeeper.R\nimport com.desarrollodroide.pagekeeper.extensions.isRTLText\nimport com.desarrollodroide.pagekeeper.ui.readablecontent.ReadableContentScreen\nimport com.desarrollodroide.pagekeeper.ui.settings.crash.CrashLogScreen\nimport com.desarrollodroide.pagekeeper.ui.settings.logcat.NetworkLogScreen\nimport kotlinx.coroutines.launch\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun HomeScreen(\n    feedViewModel: FeedViewModel,\n    goToLogin: () -> Unit,\n    onFinish: () -> Unit,\n    openUrlInBrowser: (String) -> Unit,\n    onAddManuallyClick: () -> Unit,\n    shareEpubFile: (File) -> Unit,\n    shareText: (String) -> Unit\n) {\n    val navController = rememberNavController()\n    val isCategoriesVisible = remember { mutableStateOf(false) }\n    val isSearchBarVisible = remember { mutableStateOf(false) }\n    val (showTopBar, setShowTopBar) = remember { mutableStateOf(true) }\n    val hasBookmarks = feedViewModel.bookmarksState.collectAsLazyPagingItems().itemCount > 0\n    val selectedTags by feedViewModel.selectedTags.collectAsState()\n    val showOnlyHiddenTag by feedViewModel.showOnlyHiddenTag.collectAsState()\n    val coroutineScope = rememberCoroutineScope()\n    val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false, confirmValueChange = { true })\n    val showBottomSheet = remember { mutableStateOf(false) }\n\n    BackHandler {\n        onFinish()\n    }\n\n    val pendingJobs by feedViewModel.getPendingWorks().collectAsState(initial = emptyList())\n    if (showBottomSheet.value) {\n        ModalBottomSheet(\n            sheetState = bottomSheetState,\n            onDismissRequest = { showBottomSheet.value = false }\n        ) {\n            SyncJobsBottomSheetContent(\n                pendingJobs = pendingJobs,\n                onDismiss = { showBottomSheet.value = false },\n                onRetryAll = { feedViewModel.retryAllPendingJobs() }\n            )\n        }\n    }\n\n    NavHost(\n        navController = navController,\n        startDestination = NavItem.HomeNavItem.route\n    ) {\n        composable(NavItem.HomeNavItem.route) {\n            val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()\n            val pendingJobsCount by feedViewModel.getPendingWorks().collectAsState(initial = emptyList())\n            val pendingJobs by feedViewModel.getPendingWorks().collectAsState(initial = emptyList())\n            Scaffold(\n                containerColor = MaterialTheme.colorScheme.background,\n                modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),\n                topBar = {\n                    AnimatedVisibility (showTopBar) {\n                        TopBar(\n                            toggleCategoryVisibility = { isCategoriesVisible.value = !isCategoriesVisible.value },\n                            toggleSearchBarVisibility = { isSearchBarVisible.value = !isSearchBarVisible.value },\n                            onSettingsClick = { navController.navigate(NavItem.SettingsNavItem.route) },\n                            scrollBehavior = scrollBehavior,\n                            hasBookmarks = hasBookmarks,\n                            selectedTagsCount = selectedTags.size,\n                            showOnlyHiddenTag = showOnlyHiddenTag,\n                            pendingJobsCount = pendingJobsCount.size,\n                            onSyncButtonClick = {\n                                coroutineScope.launch {\n                                    showBottomSheet.value = true\n                                    bottomSheetState.show()\n                                }\n                            },\n                            pendingJobs = pendingJobs,\n                            onAddManuallyClick = onAddManuallyClick,\n                        )\n                    }\n                }\n            ) { paddingValues ->\n                Box(\n                    modifier = Modifier\n                        .padding(paddingValues)\n                ) {\n                    FeedScreen(\n                        feedViewModel = feedViewModel,\n                        isCategoriesVisible = isCategoriesVisible,\n                        goToLogin = goToLogin,\n                        openUrlInBrowser = openUrlInBrowser,\n                        shareEpubFile = shareEpubFile,\n                        isSearchBarVisible = isSearchBarVisible,\n                        setShowTopBar = setShowTopBar,\n                        goToReadableContent = { bookmark->\n                             navController.navigate(NavItem.ReadableContentNavItem.createRoute(\n                                 bookmarkId = bookmark.id,\n                             ))\n                        },\n                    )\n                }\n            }\n        }\n        composable(NavItem.SettingsNavItem.route) {\n            SettingsScreen(\n                settingsViewModel = get(),\n                goToLogin = goToLogin,\n                onNavigateToPrivacyPolicy = {\n                    navController.navigate(NavItem.PrivacyPolicyNavItem.route)\n                },\n                onNavigateToTermsOfUse = {\n                    navController.navigate(NavItem.TermsOfUseNavItem.route)\n                },\n                onBack = {\n                    navController.navigateUp()\n                },\n                onNavigateToSourceCode = {\n                    openUrlInBrowser.invoke(SHIORI_ANDROID_CLIENT_GITHUB_URL)\n                },\n                onNavigateToLogs = {\n                    navController.navigate(NavItem.NetworkLoggerNavItem.route)\n                },\n                onViewLastCrash = {\n                    navController.navigate(NavItem.LastCrashNavItem.route)\n                }\n            )\n        }\n        composable(NavItem.TermsOfUseNavItem.route) {\n            TermsOfUseScreen(\n                onBack = {\n                    navController.navigateUp()\n                }\n            )\n        }\n        composable(NavItem.PrivacyPolicyNavItem.route) {\n            PrivacyPolicyScreen(\n                onBack = {\n                    navController.navigateUp()\n                }\n            )\n        }\n        composable(NavItem.NetworkLoggerNavItem.route) {\n            NetworkLogScreen(\n                onBack = {\n                    navController.navigateUp()\n                },\n                onShare = shareText\n            )\n        }\n        composable(NavItem.LastCrashNavItem.route) {\n            CrashLogScreen(\n                onBack = {\n                    navController.navigateUp()\n                },\n                onShare = shareText\n            )\n        }\n        composable(\n            route = NavItem.ReadableContentNavItem.route,\n            arguments = listOf(\n                navArgument(\"bookmarkId\") { type = NavType.IntType }\n            )\n        ) { backStackEntry ->\n            val bookmarkId = backStackEntry.arguments?.getInt(\"bookmarkId\") ?: 0\n            val bookmark by feedViewModel.currentBookmark.collectAsState()\n\n            LaunchedEffect(bookmarkId) {\n                feedViewModel.loadBookmarkById(bookmarkId)\n            }\n\n            bookmark?.let {\n                ReadableContentScreen(\n                    readableContentViewModel = get(),\n                    bookmarkId = bookmarkId,\n                    bookmarkUrl = it.url,\n                    onBack = {\n                        navController.navigateUp()\n                    },\n                    openUrlInBrowser = openUrlInBrowser,\n                    bookmarkDate = it.modified,\n                    bookmarkTitle = it.title,\n                    isRtl = it.title.isRTLText() || it.excerpt.isRTLText()\n                )\n            }\n        }\n    }\n}\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun TopBar(\n    toggleCategoryVisibility: () -> Unit,\n    toggleSearchBarVisibility: () -> Unit,\n    onAddManuallyClick: () -> Unit,\n    onSettingsClick: () -> Unit,\n    onSyncButtonClick: () -> Unit,\n    scrollBehavior: TopAppBarScrollBehavior,\n    hasBookmarks: Boolean,\n    selectedTagsCount: Int,\n    showOnlyHiddenTag: Boolean,\n    pendingJobsCount: Int,\n    pendingJobs: List<PendingJob>\n) {\n    var showTooltip by remember { mutableStateOf(false) }\n    val hasRunningJobs = pendingJobs.any { it.state.uppercase() == \"RUNNING\" }\n    val rotation by remember { mutableStateOf(Animatable(0f)) }\n    LaunchedEffect(hasRunningJobs) {\n        if (hasRunningJobs) {\n            rotation.animateTo(\n                targetValue = 360f,\n                animationSpec = infiniteRepeatable(\n                    animation = tween(2000, easing = LinearEasing),\n                    repeatMode = RepeatMode.Restart\n                )\n            )\n        } else {\n            rotation.snapTo(0f)\n        }\n    }\n    TopAppBar(\n        scrollBehavior = scrollBehavior,\n        title = {\n            Box(modifier = Modifier.fillMaxWidth()) {\n                Text(\n                    color = MaterialTheme.colorScheme.primary,\n                    text = \"Shiori\",\n                    modifier = Modifier.align(Alignment.CenterStart),\n                    style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold, fontSize = 28.sp)\n                )\n            }\n        },\n        navigationIcon = {\n            Image(\n                painter = painterResource(id = R.drawable.logo_pagekeeper),\n                contentDescription = \"Menu\",\n                modifier = Modifier\n                    .width(45.dp)\n                    .padding(8.dp)\n            )\n        },\n        actions = {\n            IconButton(onClick = onAddManuallyClick) {\n                Icon(\n                    imageVector = Icons.Default.Add,\n                    contentDescription = \"Add Manually\",\n                    tint = MaterialTheme.colorScheme.secondary,\n                )\n            }\n            IconButton(onClick = { toggleSearchBarVisibility() }) {\n                Icon(\n                    imageVector = Icons.Filled.Search,\n                    contentDescription = \"Search\",\n                    tint = MaterialTheme.colorScheme.secondary,\n                )\n            }\n            Box(contentAlignment = Alignment.TopEnd) {\n                IconButton(onClick = { toggleCategoryVisibility() }) {\n                    Icon(\n                        imageVector = if (showOnlyHiddenTag) Icons.Default.VisibilityOff else Icons.Outlined.Sell,\n                        contentDescription = if (showOnlyHiddenTag) \"Hidden Tags\" else \"Filter\",\n                        tint = MaterialTheme.colorScheme.secondary,\n                    )\n                }\n                this@TopAppBar.AnimatedVisibility(\n                    visible = selectedTagsCount > 0 && !showOnlyHiddenTag,\n                    enter = fadeIn() + scaleIn(),\n                    exit = fadeOut() + scaleOut()\n                ) {\n                    Badge(modifier = Modifier.padding(2.dp)) {\n                        Text(\n                            text = selectedTagsCount.toString(),\n                            style = MaterialTheme.typography.labelSmall\n                        )\n                    }\n                }\n            }\n            Box(contentAlignment = Alignment.TopEnd) {\n                IconButton(\n                    onClick = {\n                        showTooltip = !showTooltip\n                        onSyncButtonClick()\n                    }\n                ) {\n                    Icon(\n                        imageVector = Icons.Default.Sync,\n                        contentDescription = \"Sync\",\n                        tint = MaterialTheme.colorScheme.secondary,\n                        modifier = Modifier.graphicsLayer {\n                            rotationZ = rotation.value\n                        }\n                    )\n                }\n                this@TopAppBar.AnimatedVisibility(\n                    visible = pendingJobsCount > 0,\n                    enter = fadeIn() + scaleIn(),\n                    exit = fadeOut() + scaleOut()\n                ) {\n                    Badge(modifier = Modifier.padding(2.dp)) {\n                        Text(\n                            text = pendingJobsCount.toString(),\n                            style = MaterialTheme.typography.labelSmall\n                        )\n                    }\n                }\n            }\n\n            IconButton(onClick = onSettingsClick) {\n                Icon(\n                    imageVector = Icons.Filled.Settings,\n                    contentDescription = \"Settings\",\n                    tint = MaterialTheme.colorScheme.secondary\n                )\n            }\n        },\n\n            colors = TopAppBarDefaults.smallTopAppBarColors(\n            containerColor = MaterialTheme.colorScheme.background, // Sets the background color of the TopAppBar\n            titleContentColor = MaterialTheme.colorScheme.primary, // Optional: Set the title color if needed\n            navigationIconContentColor = MaterialTheme.colorScheme.primary, // Optional: Set the navigation icon color if needed\n            actionIconContentColor = MaterialTheme.colorScheme.primary // Optional: Set the action icons color if needed\n        )\n    )\n}\n\n@Composable\nfun SyncJobsBottomSheetContent(\n    pendingJobs: List<PendingJob>,\n    onDismiss: () -> Unit,\n    onRetryAll: () -> Unit\n) {\n    Column(\n        modifier = Modifier\n            .fillMaxWidth()\n            .padding(horizontal = 16.dp)\n            .padding(bottom = 26.dp)\n    ) {\n        Text(\n            modifier = Modifier\n                .fillMaxWidth()\n                .wrapContentWidth(Alignment.CenterHorizontally),\n            text = \"Pending Sync Jobs\",\n            style = MaterialTheme.typography.titleMedium,\n            color = MaterialTheme.colorScheme.primary\n        )\n        Spacer(modifier = Modifier.height(16.dp))\n        if (pendingJobs.isEmpty()) {\n            Text(\n                modifier = Modifier\n                    .fillMaxWidth()\n                    .wrapContentWidth(Alignment.CenterHorizontally),\n                text = \"No pending jobs\",\n                style = MaterialTheme.typography.bodyMedium\n            )\n        } else {\n            HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f))\n            pendingJobs.forEach { job ->\n                Row(\n                    modifier = Modifier\n                        .fillMaxWidth()\n                        .padding(vertical = 8.dp),\n                    horizontalArrangement = Arrangement.SpaceBetween,\n                    verticalAlignment = Alignment.CenterVertically\n                ) {\n                    Column(modifier = Modifier.weight(1f)) {\n                        Row(\n                            verticalAlignment = Alignment.CenterVertically,\n                            horizontalArrangement = Arrangement.spacedBy(8.dp)\n                        ) {\n                            Text(\n                                text = job.operationType.name,\n                                style = MaterialTheme.typography.bodySmall,\n                                fontWeight = FontWeight.Medium\n                            )\n                            if (job.state.uppercase() == \"RUNNING\") {\n                                CircularProgressIndicator(\n                                    modifier = Modifier.size(12.dp),\n                                    strokeWidth = 2.dp,\n                                    color = MaterialTheme.colorScheme.primary\n                                )\n                            }\n                        }\n                        Text(\n                            text = job.bookmarkTitle,\n                            style = MaterialTheme.typography.bodySmall,\n                            color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),\n                            maxLines = 1,\n                            overflow = TextOverflow.Ellipsis\n                        )\n                    }\n                    Text(\n                        text = job.state,\n                        style = MaterialTheme.typography.labelSmall,\n                        color = when (job.state.uppercase()) {\n                            \"RUNNING\", \"ENQUEUED\" -> MaterialTheme.colorScheme.primary\n                            \"BLOCKED\", \"FAILED\" -> MaterialTheme.colorScheme.error\n                            else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)\n                        }\n                    )\n                }\n                HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f))\n            }\n        }\n        Spacer(modifier = Modifier.height(16.dp))\n        Row(\n            modifier = Modifier.fillMaxWidth(),\n            horizontalArrangement = Arrangement.spacedBy(8.dp)\n        ) {\n            Button(\n                onClick = onRetryAll,\n                modifier = Modifier.weight(1f)\n            ) {\n                Text(\"Retry All\")\n            }\n            Button(\n                onClick = onDismiss,\n                modifier = Modifier.weight(1f)\n            ) {\n                Text(\"Close\")\n            }\n        }\n    }\n}\n\n@Preview(showBackground = true)\n@Composable\nfun SyncJobsBottomSheetContentPreview() {\n    SyncJobsBottomSheetContent(\n        pendingJobs = listOf(\n            PendingJob(operationType = SyncOperationType.CREATE, state = \"Pending\", bookmarkId = 1, \"Bookmark 1\"),\n            PendingJob(operationType = SyncOperationType.UPDATE, state = \"Failed\", bookmarkId = 2, \"Bookmark 2\"),\n            PendingJob(operationType = SyncOperationType.DELETE, state = \"In Progress\", bookmarkId = 3, \"Bookmark 3\")\n        ),\n        onDismiss = {},\n        onRetryAll = {}\n    )\n}\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Preview(showBackground = true)\n@Composable\nfun TopBarPreview() {\n    MaterialTheme {\n        TopBar(\n            toggleCategoryVisibility = { },\n            toggleSearchBarVisibility = { },\n            onSettingsClick = { },\n            onAddManuallyClick = { },\n            scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(),\n            hasBookmarks = true,\n            selectedTagsCount = 2,\n            showOnlyHiddenTag = false,\n            pendingJobsCount = 0,\n            onSyncButtonClick = { },\n            pendingJobs = emptyList(),\n        )\n    }\n}\n\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/login/LoginButton.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.login\n\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.MutableState\nimport androidx.compose.ui.Modifier\n\n\n@Composable\nfun LoginButton(\n    user: MutableState<String>,\n    userErrorState: MutableState<Boolean>,\n    password: MutableState<String>,\n    passwordErrorState: MutableState<Boolean>,\n    onClickLoginButton: () -> Unit,\n    serverErrorState: MutableState<Boolean>\n) {\n    Button(\n        onClick = {\n            if (user.value.isEmpty()) {\n                userErrorState.value = true\n            }\n            if (password.value.isEmpty()) {\n                passwordErrorState.value = true\n            }\n            if (user.value.isNotEmpty() && password.value.isNotEmpty() && !serverErrorState.value) {\n                passwordErrorState.value = false\n                userErrorState.value = false\n                onClickLoginButton.invoke()\n            }\n        },\n        modifier = Modifier.fillMaxWidth(),\n        content = {\n            Text(\"Login\")\n        },\n    )\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/login/LoginScreen.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.login\n\nimport android.content.res.Configuration\nimport android.util.Log\nimport androidx.compose.foundation.Image\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.*\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.ColorFilter\nimport androidx.compose.ui.layout.ContentScale\nimport androidx.compose.ui.res.painterResource\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.unit.dp\nimport com.desarrollodroide.pagekeeper.R\nimport com.desarrollodroide.pagekeeper.ui.components.ConfirmDialog\nimport com.desarrollodroide.pagekeeper.ui.components.InfiniteProgressDialog\nimport com.desarrollodroide.pagekeeper.ui.theme.ShioriTheme\nimport androidx.lifecycle.compose.collectAsStateWithLifecycle\nimport com.desarrollodroide.pagekeeper.ui.components.UiState\nimport com.desarrollodroide.model.User\nimport androidx.compose.runtime.getValue\nimport com.desarrollodroide.data.helpers.SHIORI_GITHUB_URL\nimport com.desarrollodroide.model.LivenessResponse\nimport com.desarrollodroide.pagekeeper.ui.settings.LinkableText\n\n@Composable\nfun LoginScreen(\n    loginViewModel: LoginViewModel,\n    onSuccess: (User) -> Unit,\n) {\n    val loginUiState: UiState<User> by loginViewModel.userUiState.collectAsStateWithLifecycle()\n    val livenessUiState: UiState<LivenessResponse> by loginViewModel.livenessUiState.collectAsStateWithLifecycle()\n    val serverAvailabilityUiState: UiState<LivenessResponse> by loginViewModel.serverAvailabilityUiState.collectAsStateWithLifecycle()\n    Box(\n        modifier = Modifier\n            .fillMaxSize()\n    ) {\n        LoginContent(\n            loginUiState = loginUiState,\n            checked = loginViewModel.rememberSession,\n            userErrorState = loginViewModel.userNameError,\n            passwordErrorState = loginViewModel.passwordError,\n            urlErrorState = loginViewModel.urlError,\n            onClickLoginButton = {\n                loginViewModel.checkSystemLiveness()\n            },\n            onCheckedRememberSessionChange = {\n                loginViewModel.rememberSession.value = it\n            },\n            onSuccess = {\n                loginViewModel.clearState()\n                onSuccess.invoke(it)\n            },\n            user = loginViewModel.userName,\n            password = loginViewModel.password,\n            serverUrl = loginViewModel.serverUrl,\n            onClearError = {\n                loginViewModel.clearState()\n            },\n            livenessUiState = livenessUiState,\n            serverAvailabilityUiState = serverAvailabilityUiState,\n            onClickTestButton = {\n                loginViewModel.checkServerAvailability()\n            },\n            resetServerAvailabilityState = {\n                loginViewModel.resetServerAvailabilityUiState()\n            }\n        )\n    }\n}\n\n@Composable\nfun LoginContent(\n    user: MutableState<String>,\n    password: MutableState<String>,\n    serverUrl: MutableState<String>,\n    checked: MutableState<Boolean>,\n    urlErrorState: MutableState<Boolean>,\n    userErrorState: MutableState<Boolean>,\n    passwordErrorState: MutableState<Boolean>,\n    onSuccess: (User) -> Unit,\n    onClickLoginButton: () -> Unit,\n    onClickTestButton: () -> Unit,\n    onClearError: () -> Unit,\n    onCheckedRememberSessionChange: (Boolean) -> Unit,\n    loginUiState: UiState<User>,\n    livenessUiState: UiState<LivenessResponse>,\n    serverAvailabilityUiState: UiState<LivenessResponse>,\n    resetServerAvailabilityState: () -> Unit\n) {\n    if (loginUiState.isLoading || livenessUiState.isLoading) {\n        InfiniteProgressDialog(onDismissRequest = {})\n    }\n    if (!livenessUiState.error.isNullOrEmpty()) {\n        ConfirmDialog(\n            icon = Icons.Default.Error,\n            title = \"Error\",\n            content = livenessUiState.error,\n            openDialog = remember { mutableStateOf(true) },\n            onConfirm = {\n                onClearError.invoke()\n            }\n        )\n        Log.v(\"loginUiState\", \"Error\")\n    }\n    if (!loginUiState.error.isNullOrEmpty()) {\n        ConfirmDialog(\n            icon = Icons.Default.Error,\n            title = \"Error\",\n            content = loginUiState.error,\n            openDialog = remember { mutableStateOf(true) },\n            onConfirm = {\n                onClearError.invoke()\n            }\n        )\n        Log.v(\"loginUiState\", \"Error\")\n    } else if (loginUiState.data == null && !loginUiState.idle) {\n        ContentViews(\n            serverUrl = serverUrl,\n            urlErrorState = urlErrorState,\n            user = user,\n            userErrorState = userErrorState,\n            password = password,\n            passwordErrorState = passwordErrorState,\n            onClickLoginButton = onClickLoginButton,\n            checked = checked,\n            onCheckedRememberSessionChange = onCheckedRememberSessionChange,\n            isTestingServer = serverAvailabilityUiState.isLoading,\n            onClickTestButton = onClickTestButton,\n            serverAvailabilityUiState = serverAvailabilityUiState,\n            serverVersion = serverAvailabilityUiState.data?.message?.version ?: \"\",\n            resetServerAvailabilityState = resetServerAvailabilityState\n        )\n    } else if (loginUiState.data != null) {\n        LaunchedEffect(Unit) {\n            onSuccess.invoke(loginUiState.data)\n        }\n    }\n}\n\n@Composable\nprivate fun ContentViews(\n    serverUrl: MutableState<String>,\n    urlErrorState: MutableState<Boolean>,\n    user: MutableState<String>,\n    userErrorState: MutableState<Boolean>,\n    password: MutableState<String>,\n    passwordErrorState: MutableState<Boolean>,\n    isTestingServer: Boolean,\n    onClickLoginButton: () -> Unit,\n    onClickTestButton: () -> Unit,\n    checked: MutableState<Boolean>,\n    onCheckedRememberSessionChange: (Boolean) -> Unit,\n    serverAvailabilityUiState: UiState<LivenessResponse>,\n    serverVersion: String,\n    resetServerAvailabilityState: () -> Unit\n) {\n    Box(modifier = Modifier.fillMaxSize()) {\n        Image(\n            painter = painterResource(id = R.drawable.ic_logo),\n            contentDescription = null,\n            contentScale = ContentScale.FillHeight,\n            modifier = Modifier\n                .fillMaxWidth()\n                .padding(top = 20.dp)\n                .height(120.dp)\n        )\n        Image(\n            painter = painterResource(id = R.drawable.curved_wave_bottom),\n            contentDescription = null,\n            colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)),\n            contentScale = ContentScale.Crop,\n            modifier = Modifier\n                .fillMaxWidth()\n                .padding(top = 10.dp)\n                .height(150.dp)\n                .align(Alignment.BottomCenter)\n        )\n        Column(\n            modifier = Modifier\n                .padding(16.dp)\n                .fillMaxWidth()\n                .align(Alignment.Center),\n            verticalArrangement = Arrangement.Bottom,\n        ) {\n            ServerUrlTextField(\n                modifier = Modifier,\n                serverUrl = serverUrl,\n                serverErrorState = urlErrorState,\n                serverAvailabilityUiState = serverAvailabilityUiState,\n                serverVersion = serverVersion,\n                resetServerAvailabilityState = resetServerAvailabilityState,\n                onClick = onClickTestButton,\n                isTestingServer = isTestingServer\n            )\n            Spacer(modifier = Modifier.height(10.dp))\n            UserTextField(\n                user = user,\n                userErrorState = userErrorState\n            )\n            Spacer(modifier = Modifier.height(10.dp))\n            PasswordTextField(\n                password = password,\n                passwordErrorState = passwordErrorState\n            )\n            Spacer(Modifier.size(14.dp))\n            LoginButton(\n                user = user,\n                userErrorState = userErrorState,\n                password = password,\n                passwordErrorState = passwordErrorState,\n                onClickLoginButton = onClickLoginButton,\n                serverErrorState = urlErrorState\n            )\n            RememberSessionSection(\n                checked = checked,\n                onCheckedChange = onCheckedRememberSessionChange\n            )\n            Box(\n                modifier = Modifier\n                    .fillMaxWidth()\n                    .padding(10.dp),\n                contentAlignment = Alignment.Center\n            ) {\n                LinkableText(\n                    text = \"Server Setup Guide\",\n                    url = SHIORI_GITHUB_URL\n                )\n            }\n        }\n    }\n}\n\n@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, showSystemUi = true)\n@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showSystemUi = true)\n@Composable\nfun DefaultPreview() {\n    ShioriTheme(\n        dynamicColor = false\n    ) {\n        LoginContent(\n            user = remember { mutableStateOf(\"User\") },\n            password = remember { mutableStateOf(\"Pass\") },\n            serverUrl = remember { mutableStateOf(\"ServerUrl\") },\n            checked = remember { mutableStateOf(true) },\n            urlErrorState = remember { mutableStateOf(true) },\n            userErrorState = remember { mutableStateOf(true) },\n            passwordErrorState = remember { mutableStateOf(true) },\n            onSuccess = {},\n            onClickLoginButton = {},\n            onCheckedRememberSessionChange = {},\n            onClearError = {},\n            loginUiState = UiState(data = null, idle = false),\n            livenessUiState = UiState(false),\n            serverAvailabilityUiState = UiState(data = null, idle = false),\n            onClickTestButton = {},\n            resetServerAvailabilityState = {}\n        )\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/login/LoginViewModel.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.login\n\nimport android.util.Log\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport com.desarrollodroide.pagekeeper.ui.components.UiState\nimport com.desarrollodroide.pagekeeper.ui.components.error\nimport com.desarrollodroide.pagekeeper.ui.components.isLoading\nimport com.desarrollodroide.pagekeeper.ui.components.success\nimport com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource\nimport com.desarrollodroide.domain.usecase.SendLoginUseCase\nimport com.desarrollodroide.model.User\nimport kotlinx.coroutines.flow.*\nimport kotlinx.coroutines.launch\nimport com.desarrollodroide.common.result.Result\nimport com.desarrollodroide.domain.usecase.SystemLivenessUseCase\nimport com.desarrollodroide.model.LivenessResponse\nimport com.desarrollodroide.pagekeeper.ui.components.idle\nimport kotlinx.coroutines.delay\n\nclass LoginViewModel(\n    private val settingsPreferenceDataSource: SettingsPreferenceDataSource,\n    private val loginUseCase: SendLoginUseCase,\n    private val livenessUseCase: SystemLivenessUseCase,\n) : ViewModel() {\n\n    var rememberSession = mutableStateOf(false)\n    // Oracle\n//    var userName = mutableStateOf(\"Test\")\n//    var password = mutableStateOf(\"Test\")\n//    var serverUrl = mutableStateOf(\"https://shiori.desarrollodroide.es/\")\n\n    // v1.6\n//    var userName = mutableStateOf(\"Test\")\n//    var password = mutableStateOf(\"Test\")\n//    var serverUrl = mutableStateOf(\"http://192.168.1.12:8080/\")\n\n    // Synology\n//    var userName = mutableStateOf(\"Test\")\n//    var password = mutableStateOf(\"Test\")\n//    var serverUrl = mutableStateOf(\"http://192.168.1.68:18080/\")\n\n    // localhost\n//    var userName = mutableStateOf(\"shiori\")\n//    var password = mutableStateOf(\"gopher\")\n//    var serverUrl = mutableStateOf(\"http://192.168.1.12:8080/\")\n\n    var serverUrl = mutableStateOf(\"\")\n    var userName = mutableStateOf(\"\")\n    var password = mutableStateOf(\"\")\n\n    val userNameError = mutableStateOf(false)\n    val passwordError = mutableStateOf(false)\n    val urlError = mutableStateOf(false)\n\n    private val _userUiState = MutableStateFlow(UiState<User>(idle = true))\n    val userUiState = _userUiState.asStateFlow()\n\n    private val _livenessUiState = MutableStateFlow(UiState<LivenessResponse>(idle = true))\n    val livenessUiState = _livenessUiState.asStateFlow()\n\n    private val _serverAvailabilityUiState = MutableStateFlow(UiState<LivenessResponse>(idle = true))\n    val serverAvailabilityUiState = _serverAvailabilityUiState.asStateFlow()\n\n    init {\n        viewModelScope.launch {\n            getUser()\n            getRememberUser()\n        }\n    }\n\n    fun sendLogin() {\n        viewModelScope.launch {\n            loginUseCase.invoke(\n                username = userName.value,\n                password = password.value,\n                serverUrl = serverUrl.value,\n            )\n                .collect { result ->\n                    when (result) {\n                        is Result.Error -> {\n                            val error = result.error?.throwable?.message?:result.error?.message?:\"Unknown error\"\n                            _userUiState.error(\n                                errorMessage = error\n                            )\n                        }\n\n                        is Result.Loading -> {\n                            _userUiState.isLoading(true)\n                        }\n\n                        is Result.Success -> {\n                            if (result.data != null && result.data?.hasSession() == true) {\n                                if (rememberSession.value) {\n                                    settingsPreferenceDataSource.saveRememberUser(\n                                        url = serverUrl.value,\n                                        userName = userName.value,\n                                        password = password.value\n                                        )\n                                } else {\n                                    userName.value = \"\"\n                                    password.value = \"\"\n                                    serverUrl.value = \"\"\n                                    settingsPreferenceDataSource.resetRememberUser()\n                                }\n                                _userUiState.success(result.data)\n                            } else {\n                                settingsPreferenceDataSource.resetData()\n                            }\n                        }\n                    }\n                }\n        }\n    }\n\n    fun checkSystemLiveness(){\n        viewModelScope.launch {\n            livenessUseCase.invoke(serverUrl.value)\n                .collect { result ->\n                    when (result) {\n                        is Result.Error -> {\n                            if (result.error?.statusCode == 404){\n                                // Liveness not supported, versión < 1.6\n                                sendLogin()\n                                Log.v(\"LoginViewModel\", \"Liveness not supported\")\n                            } else if (result.error is Result.ErrorType.IOError) {\n                                // Error connecting to server\n                                Log.v(\"LoginViewModel\", \"Error connecting to server\")\n                                val error = result.error?.throwable?.message?:result.error?.message?:\"Unknown error\"\n                                _livenessUiState.error(errorMessage = error)\n                            }\n                        }\n\n                        is Result.Loading -> {\n                            _livenessUiState.isLoading(true)\n                        }\n\n                        is Result.Success -> {\n                            Log.v(\"LoginViewModel\", \"Liveness: ${result.data}\")\n                            settingsPreferenceDataSource.setServerVersion(result.data?.message?.version?:\"\")\n                            _livenessUiState.success(result.data)\n                            sendLogin()\n                        }\n                    }\n                }\n        }\n    }\n\n    fun checkServerAvailability(){\n        viewModelScope.launch {\n            livenessUseCase.invoke(serverUrl.value)\n                .collect { result ->\n                    when (result) {\n                        is Result.Error -> {\n                            Log.v(\"LoginViewModel\", \"Server Availability error\")\n                            val error = result.error?.throwable?.message?:result.error?.message?:\"Unknown error\"\n                            _serverAvailabilityUiState.error(errorMessage = error)\n                        }\n                        is Result.Loading -> {\n                            _serverAvailabilityUiState.isLoading(true)\n                        }\n\n                        is Result.Success -> {\n                            Log.v(\"LoginViewModel\", \"Server Availability: ${result.data}\")\n                            delay(1000)\n                            _serverAvailabilityUiState.success(result.data)\n                        }\n                    }\n                }\n        }\n    }\n\n    fun clearState() {\n        _userUiState.success(null)\n        _livenessUiState.success(null)\n    }\n\n    private suspend fun getUser() {\n        val user = settingsPreferenceDataSource.getUser().first()\n        if (user.hasSession()) {\n            _userUiState.success(user)\n        } else {\n            _userUiState.success(null)\n        }\n    }\n\n    private suspend fun getRememberUser() {\n        val rememberUser = settingsPreferenceDataSource.getRememberUser().first()\n        if (rememberUser.userName.isNotEmpty() && rememberUser.password.isNotEmpty()) {\n            serverUrl.value = rememberUser.serverUrl\n            userName.value = rememberUser.userName\n            password.value = rememberUser.password\n            rememberSession.value = true\n        }\n    }\n\n    fun resetServerAvailabilityUiState() {\n        _serverAvailabilityUiState.idle(true)\n    }\n}\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/login/PasswordTextField.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.login\n\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Lock\nimport androidx.compose.material.icons.filled.Visibility\nimport androidx.compose.material.icons.filled.VisibilityOff\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.OutlinedTextField\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.MutableState\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.text.input.PasswordVisualTransformation\nimport androidx.compose.ui.text.input.VisualTransformation\n\n\n@Composable\nfun PasswordTextField(\n    password: MutableState<String>,\n    passwordErrorState: MutableState<Boolean>\n) {\n    Column() {\n        val passwordVisibility = remember { mutableStateOf(true) }\n        OutlinedTextField(\n            value = password.value,\n            leadingIcon = {\n                Icon(imageVector = Icons.Filled.Lock, contentDescription = null)\n            },\n            onValueChange = {\n                if (passwordErrorState.value) {\n                    passwordErrorState.value = false\n                }\n                password.value = it\n            },\n            isError = passwordErrorState.value,\n            modifier = Modifier.fillMaxWidth(),\n            label = {\n                Text(text = \"Password\")\n            },\n            trailingIcon = {\n                IconButton(onClick = {\n                    passwordVisibility.value = !passwordVisibility.value\n                }) {\n                    Icon(\n                        imageVector = if (passwordVisibility.value) Icons.Default.VisibilityOff else Icons.Default.Visibility,\n                        contentDescription = \"visibility\",\n                        tint = Color.Gray\n                    )\n                }\n            },\n            visualTransformation = if (passwordVisibility.value) PasswordVisualTransformation() else VisualTransformation.None\n        )\n        if (passwordErrorState.value) {\n            Text(\n                text = \"Required\",\n                color = Color.Red,\n                modifier = Modifier.align(Alignment.End)\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/login/RememberSessionSection.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.login\n\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material3.Checkbox\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.MutableState\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.unit.dp\n\n@Composable\nfun RememberSessionSection(\n    checked: MutableState<Boolean>,\n    onCheckedChange: ((Boolean) -> Unit),\n) {\n    Row(\n        modifier = Modifier\n            .fillMaxWidth()\n            .clickable {\n                onCheckedChange(!checked.value)\n            }\n            .padding(horizontal = 10.dp),\n        verticalAlignment = Alignment.CenterVertically\n    ) {\n        Checkbox(\n            checked = checked.value,\n            onCheckedChange = {\n                onCheckedChange.invoke(it)\n            }\n        )\n        Text(\n            text = \"Remember\",\n            modifier = Modifier\n                .padding(horizontal = 10.dp)\n        )\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/login/ServerUrlTextField.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.login\n\nimport android.webkit.URLUtil\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Check\nimport androidx.compose.material.icons.filled.CheckCircle\nimport androidx.compose.material.icons.filled.Link\nimport androidx.compose.material3.CircularProgressIndicator\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.LocalContentColor\nimport androidx.compose.material3.OutlinedTextField\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.MutableState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.focus.onFocusChanged\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.unit.dp\nimport com.desarrollodroide.model.LivenessResponse\nimport com.desarrollodroide.pagekeeper.ui.components.UiState\n\n@Composable\nfun ServerUrlTextField(\n    modifier: Modifier,\n    serverAvailabilityUiState: UiState<LivenessResponse>,\n    serverUrl: MutableState<String>,\n    serverErrorState: MutableState<Boolean>,\n    serverVersion: String,\n    resetServerAvailabilityState: () -> Unit,\n    onClick: () -> Unit,\n    isTestingServer: Boolean\n) {\n    val serverUrlAvailable = serverAvailabilityUiState.data?.ok == true\n    var isFocused by remember { mutableStateOf(false) }\n\n    Column(\n        modifier = modifier\n    ) {\n        OutlinedTextField(\n            value = serverUrl.value,\n            leadingIcon = {\n                Icon(\n                    imageVector = Icons.Filled.Link,\n                    contentDescription = null\n                )\n            },\n            trailingIcon = {\n                if (isTestingServer) {\n                    CircularProgressIndicator(\n                        modifier = Modifier.size(24.dp),\n                        strokeWidth = 2.dp\n                    )\n                } else if (serverUrlAvailable) {\n                    Icon(\n                        imageVector = Icons.Default.CheckCircle,\n                        contentDescription = \"visibility\",\n                    )\n                }\n            },\n            onValueChange = {\n                serverErrorState.value = !URLUtil.isValidUrl(it)\n                serverUrl.value = it\n                if (serverUrlAvailable || serverAvailabilityUiState.error != null) {\n                    resetServerAvailabilityState()\n                }\n            },\n            isError = serverErrorState.value,\n            modifier = Modifier\n                .fillMaxWidth()\n                .onFocusChanged { focusState ->\n                    if (isFocused && !focusState.isFocused && URLUtil.isValidUrl(serverUrl.value)) {\n                        onClick()\n                    }\n                    isFocused = focusState.isFocused\n                },\n            label = {\n                Text(text = \"Server url\")\n            },\n            singleLine = true,\n            maxLines = 1\n        )\n\n        AnimatedVisibility(visible = serverErrorState.value) {\n            Text(\n                modifier = Modifier.align(Alignment.End),\n                color = Color.Red,\n                text = \"Invalid url\"\n            )\n        }\n\n        AnimatedVisibility(visible = serverUrlAvailable && serverVersion.isNotEmpty()) {\n            Text(\n                modifier = Modifier.align(Alignment.Start),\n                text = \"Server v$serverVersion\"\n            )\n        }\n\n        AnimatedVisibility(visible = serverAvailabilityUiState.error != null) {\n            Text(\n                modifier = Modifier.align(Alignment.End),\n                color = Color.Red,\n                text = serverAvailabilityUiState.error ?: \"\"\n            )\n        }\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/login/UserTextField.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.login\n\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Person\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.OutlinedTextField\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.MutableState\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\n\n@Composable\nfun UserTextField(\n    user: MutableState<String>,\n    userErrorState: MutableState<Boolean>\n) {\n    Column {\n        OutlinedTextField(\n            value = user.value,\n            leadingIcon = {\n                Icon(imageVector = Icons.Filled.Person, contentDescription = null)\n            },\n            onValueChange = {\n                if (userErrorState.value) {\n                    userErrorState.value = false\n                }\n                user.value = it\n            },\n            isError = userErrorState.value,\n            modifier = Modifier.fillMaxWidth(),\n            label = {\n                Text(text = \"UserName\")\n            },\n        )\n        if (userErrorState.value) {\n            Text(\n                modifier = Modifier.align(Alignment.End),\n                color = Color.Red,\n                text = \"Invalid username\"\n            )\n        }\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/readablecontent/ErrorView.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.readablecontent\n\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Error\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\n\n@Composable\nfun ErrorView(errorMessage: String) {\n    Box(\n        modifier = Modifier\n            .fillMaxSize()\n            .padding(16.dp),\n        contentAlignment = Alignment.Center\n    ) {\n        Column(\n            horizontalAlignment = Alignment.CenterHorizontally\n        ) {\n            Icon(\n                imageVector = Icons.Default.Error,\n                contentDescription = null,\n                modifier = Modifier.size(64.dp)\n            )\n            Spacer(modifier = Modifier.height(16.dp))\n            Text(\n                text = \"Error\",\n                fontSize = 24.sp,\n                fontWeight = FontWeight.Bold,\n                textAlign = TextAlign.Center\n            )\n            Spacer(modifier = Modifier.height(8.dp))\n            Text(\n                text = errorMessage,\n                fontSize = 16.sp,\n                textAlign = TextAlign.Center\n            )\n            Spacer(modifier = Modifier.height(16.dp))\n        }\n    }\n}\n\n@Preview(showBackground = true)\n@Composable\nprivate fun ErrorViewPreview() {\n    MaterialTheme {\n        ErrorView(errorMessage = \"Something went wrong. Please try again later.\")\n    }\n}\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/readablecontent/ReadableContentScreen.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.readablecontent\n\nimport android.content.Intent\nimport android.os.Build\nimport android.webkit.WebResourceRequest\nimport android.webkit.WebView\nimport android.webkit.WebViewClient\nimport androidx.activity.compose.BackHandler\nimport androidx.annotation.RequiresApi\nimport androidx.compose.foundation.isSystemInDarkTheme\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.ArrowBack\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.viewinterop.AndroidView\nimport com.desarrollodroide.data.helpers.ThemeMode\nimport com.desarrollodroide.pagekeeper.ui.components.InfiniteProgressDialog\n\n@RequiresApi(Build.VERSION_CODES.N)\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun ReadableContentScreen(\n    readableContentViewModel: ReadableContentViewModel,\n    onBack: () -> Unit,\n    bookmarkUrl: String,\n    bookmarkId: Int,\n    openUrlInBrowser: (String) -> Unit,\n    bookmarkDate: String,\n    bookmarkTitle: String,\n    isRtl: Boolean\n) {\n    BackHandler { onBack() }\n\n    LaunchedEffect(Unit) {\n        readableContentViewModel.loadInitialData()\n        readableContentViewModel.getBookmarkReadableContent(bookmarkId = bookmarkId, bookmarkUrl = bookmarkUrl)\n    }\n\n    val themeMode by readableContentViewModel.themeMode.collectAsState()\n    val isDarkTheme = when (themeMode) {\n        ThemeMode.DARK -> true\n        ThemeMode.LIGHT -> false\n        ThemeMode.AUTO -> isSystemInDarkTheme()\n    }\n\n    val themeCss = if (isDarkTheme) DARK_THEME_CSS else LIGHT_THEME_CSS\n    val directionCss = if (isRtl) RTL_CSS else LTR_CSS\n\n    val readableContentState by readableContentViewModel.readableContentState.collectAsState()\n\n    Scaffold(\n        topBar = {\n            CenterAlignedTopAppBar(\n                title = { Text(\"Content\", style = MaterialTheme.typography.titleLarge) },\n                navigationIcon = {\n                    IconButton(onClick = onBack) {\n                        Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = \"Back\")\n                    }\n                },\n                colors = TopAppBarDefaults.centerAlignedTopAppBarColors(\n                    containerColor = MaterialTheme.colorScheme.background\n                )\n            )\n        },\n        containerColor = MaterialTheme.colorScheme.background\n    ) { paddingValues ->\n        Box(modifier = Modifier.padding(paddingValues).fillMaxWidth()) {\n            if (readableContentState.isLoading) {\n                InfiniteProgressDialog(onDismissRequest = {})\n            } else {\n                LazyColumn(modifier = Modifier.fillMaxWidth()) {\n                    item {\n                        TopSection(\n                            title = bookmarkTitle,\n                            date = bookmarkDate,\n                            onClick = { openUrlInBrowser(bookmarkUrl) }\n                        )\n                    }\n                    item {\n                        readableContentState.error?.let { error ->\n                            ErrorView(errorMessage = error)\n                        } ?: readableContentState.data?.let { readableMessage ->\n                            AndroidView(factory = { context ->\n                                WebView(context).apply {\n                                    webViewClient = object : WebViewClient() {\n                                        override fun onPageFinished(view: WebView?, url: String?) {\n                                            super.onPageFinished(view, url)\n                                            val css = \"\"\"\n                                                (function() {\n                                                    var style = document.createElement('style');\n                                                    style.innerHTML = `\n                                                        img {\n                                                            max-width: 100%;\n                                                            height: auto;\n                                                        }\n                                                        $directionCss\n                                                        $themeCss\n                                                    `;\n                                                    document.head.appendChild(style);\n                                                })();\n                                            \"\"\".trimIndent()\n                                            view?.evaluateJavascript(css, null)\n                                        }\n\n                                        override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {\n                                            request?.url?.let { url ->\n                                                val intent = Intent(Intent.ACTION_VIEW, url)\n                                                context.startActivity(intent)\n                                                return true\n                                            }\n                                            return false\n                                        }\n                                    }\n                                    settings.javaScriptEnabled = true\n                                    setBackgroundColor(if (isDarkTheme) 0xFF121212.toInt() else 0xFFFFFFFF.toInt())\n                                    loadDataWithBaseURL(null, readableMessage.html, \"text/html\", \"UTF-8\", null)\n                                }\n                            })\n                        }\n                    }\n                }\n            }\n        }\n    }\n}\n\nprivate const val DARK_THEME_CSS = \"\"\"\n    body {\n        background-color: #121212;\n        color: #ffffff;\n    }\n    a {\n        color: #bb86fc;\n    }\n\"\"\"\n\nprivate const val LIGHT_THEME_CSS = \"\"\"\n    body {\n        background-color: #ffffff;\n        color: #000000;\n    }\n    a {\n        color: #1a0dab;\n    }\n\"\"\"\n\nprivate const val RTL_CSS = \"\"\"\n    body {\n        direction: rtl;\n        text-align: right;\n    }\n\"\"\"\n\nprivate const val LTR_CSS = \"\"\"\n    body {\n        direction: ltr;\n        text-align: left;\n    }\n\"\"\"\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/readablecontent/ReadableContentViewModel.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.readablecontent\n\nimport android.util.Log\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport com.desarrollodroide.common.result.Result\nimport com.desarrollodroide.data.helpers.ThemeMode\nimport com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource\nimport com.desarrollodroide.data.local.room.dao.BookmarkHtmlDao\nimport com.desarrollodroide.data.local.room.dao.BookmarksDao\nimport com.desarrollodroide.data.local.room.entity.BookmarkHtmlEntity\nimport com.desarrollodroide.domain.usecase.GetBookmarkReadableContentUseCase\nimport com.desarrollodroide.model.ReadableMessage\nimport com.desarrollodroide.pagekeeper.ui.components.UiState\nimport com.desarrollodroide.pagekeeper.ui.components.error\nimport com.desarrollodroide.pagekeeper.ui.components.isLoading\nimport com.desarrollodroide.pagekeeper.ui.components.success\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.asStateFlow\nimport kotlinx.coroutines.flow.distinctUntilChanged\nimport kotlinx.coroutines.launch\n\nclass ReadableContentViewModel(\n    private val settingsPreferenceDataSource: SettingsPreferenceDataSource,\n    private val getBookmarkReadableContentUseCase: GetBookmarkReadableContentUseCase,\n    private val bookmarksDao: BookmarksDao,\n    private val bookmarkHtmlDao: BookmarkHtmlDao\n) : ViewModel() {\n\n    private var serverUrl = \"\"\n    private var token = \"\"\n\n    private val _readableContentState = MutableStateFlow(UiState<ReadableMessage>(idle = true))\n    val readableContentState = _readableContentState.asStateFlow()\n\n    val themeMode = MutableStateFlow<ThemeMode>(ThemeMode.AUTO)\n\n    fun loadInitialData() {\n        viewModelScope.launch {\n            serverUrl = settingsPreferenceDataSource.getUrl()\n            token = settingsPreferenceDataSource.getToken()\n            themeMode.value = settingsPreferenceDataSource.getThemeMode()\n        }\n    }\n\n    fun getBookmarkReadableContent(\n        bookmarkId: Int,\n        bookmarkUrl: String,\n    ) {\n        viewModelScope.launch {\n            getBookmarkReadableContentUseCase.invoke(\n                serverUrl = serverUrl,\n                token = token,\n                bookmarkId = bookmarkId\n            )\n                .distinctUntilChanged()\n                .collect() { result ->\n                    when (result) {\n                        is Result.Error -> {\n                            Log.v( \"ReadableContent\",\"Error getting bookmark readable content: ${result.error?.message}\")\n                            getLocalHtmlContent(bookmarkId)\n                        }\n                        is Result.Loading -> {\n                            Log.v(  \"ReadableContent\",\"Loading, getting bookmark readable content...\")\n                            _readableContentState.isLoading(true)\n                        }\n\n                        is Result.Success -> {\n                            Log.v(\"ReadableContent\", \"Get bookmark readable content successfully.\")\n                            result.data?.let {\n                                _readableContentState.success(it.message)\n                                saveHtmlContent(\n                                    bookmarkId = bookmarkId,\n                                    url = bookmarkUrl,\n                                    html = it.message.html)\n                            }\n                        }\n                        else -> {}\n                    }\n                }\n        }\n    }\n    private fun saveHtmlContent(bookmarkId: Int, url: String, html: String) {\n        viewModelScope.launch {\n            val bookmarkHtml = BookmarkHtmlEntity(id = bookmarkId, url = url, readableContentHtml = html)\n            bookmarkHtmlDao.insertOrUpdate(bookmarkHtml)\n        }\n    }\n\n    private fun getLocalHtmlContent(bookmarkId: Int) {\n        viewModelScope.launch {\n            val bookmarkHtml = bookmarkHtmlDao.getBookmarkHtml(bookmarkId)\n            if (bookmarkHtml != null) {\n                val readableMessage = ReadableMessage(content = \"\", html = bookmarkHtml.readableContentHtml)\n                _readableContentState.success(readableMessage)\n            } else {\n                _readableContentState.error(errorMessage = \"No local content available\")\n            }\n        }\n    }\n\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/readablecontent/TopSection.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.readablecontent\n\nimport android.os.Build\nimport androidx.annotation.RequiresApi\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.HorizontalDivider\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\n\n@Composable\nfun TopSection(\n    title: String,\n    date: String,\n    onClick: () -> Unit\n) {\n    Column(\n        modifier = Modifier\n            .fillMaxWidth()\n            .padding(horizontal = 16.dp)\n    ) {\n        Text(\n            text = date,\n            fontSize = 14.sp,\n            modifier = Modifier.fillMaxWidth(),\n            textAlign = TextAlign.Center\n        )\n        Spacer(modifier = Modifier.height(8.dp))\n        Text(\n            text = title,\n            fontSize = 24.sp,\n            modifier = Modifier.fillMaxWidth(),\n            textAlign = TextAlign.Center\n        )\n        Spacer(modifier = Modifier.height(8.dp))\n        Row(\n            modifier = Modifier.fillMaxWidth(),\n            horizontalArrangement = Arrangement.Center\n        ) {\n            Button(\n                onClick = onClick,\n            ) {\n                Text(\"View Original\", color = Color.White)\n            }\n        }\n        HorizontalDivider(modifier = Modifier.padding(vertical = 10.dp))\n    }\n}\n\n@RequiresApi(Build.VERSION_CODES.N)\n@Preview(showBackground = true)\n@Composable\nfun TopSectionPreview() {\n    MaterialTheme {\n        TopSection(\n            title = \"A Developer’s Roadmap to Predictive Back (Views)\",\n            date = \"Added 27 May 2024, 16:41:09\",\n            onClick = {}\n        )\n    }\n}\n\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/AccountSection.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.settings\n\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.Logout\nimport androidx.compose.material.icons.filled.Code\nimport androidx.compose.material.icons.filled.Dns\nimport androidx.compose.material.icons.filled.Feedback\nimport androidx.compose.material.icons.filled.Gavel\nimport androidx.compose.material.icons.filled.Security\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.unit.dp\n\n@Composable\nfun AccountSection(\n    serverUrl: String,\n    onLogout: () -> Unit,\n    onNavigateToTermsOfUse: () -> Unit,\n    onNavigateToPrivacyPolicy: () -> Unit,\n    onNavigateToSeverSettings: () -> Unit,\n    onSendFeedbackEmail: () -> Unit,\n    onNavigateToSourceCode: () -> Unit\n) {\n    Column(\n        modifier = Modifier\n            .fillMaxWidth()\n            .padding(horizontal = 16.dp)\n            .padding(top = 12.dp, bottom = 5.dp)\n    ) {\n        Text(\n            text = \"Account\",\n            style = MaterialTheme.typography.titleSmall\n        )\n        Spacer(modifier = Modifier.height(5.dp))\n        ClickableOption(\n            Item(\n                title = \"Logout\",\n                subtitle = serverUrl,\n                icon = Icons.AutoMirrored.Filled.Logout,\n                onClick = onLogout\n            ),\n        )\n        ClickableOption(\n            Item(\n                title = \"Server Settings Guide\",\n                icon = Icons.Filled.Dns,\n                onClick = onNavigateToSeverSettings\n            )\n        )\n        ClickableOption(\n            item = Item(\n                title = \"Source Code\",\n                icon = Icons.Filled.Code,\n                onClick = onNavigateToSourceCode\n            )\n        )\n        ClickableOption(\n            Item(\n                title = \"Send Feedback\",\n                icon = Icons.Filled.Feedback,\n                onClick = onSendFeedbackEmail\n            )\n        )\n        ClickableOption(\n            Item(\n                title = \"Terms of Use\",\n                icon = Icons.Filled.Gavel,\n                onClick = onNavigateToTermsOfUse\n            )\n        )\n        ClickableOption(\n            Item(\n                title = \"Privacy policy\",\n                icon = Icons.Filled.Security,\n                onClick = onNavigateToPrivacyPolicy\n            )\n        )\n    }\n}\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/ClickableOption.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.settings\n\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Person\nimport androidx.compose.material.icons.filled.Settings\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.unit.dp\n\n@Composable\nfun ClickableOption(\n    item: Item,\n) {\n    Row(\n        modifier = Modifier\n            .fillMaxWidth()\n            .clickable { item.onClick() },\n        verticalAlignment = Alignment.CenterVertically,\n        horizontalArrangement = Arrangement.Start\n    ) {\n        Icon(item.icon, contentDescription = item.title)\n        Column(\n            modifier = Modifier\n                .weight(1f)\n                .padding(vertical = 10.dp)\n        ) {\n            Text(\n                modifier = Modifier\n                    .padding(horizontal = 10.dp),\n                text = item.title\n            )\n            if (item.subtitle.isNotEmpty()){\n                Text(\n                    modifier = Modifier\n                        .padding(horizontal = 10.dp),\n                    text = item.subtitle,\n                    style = MaterialTheme.typography.labelSmall)\n            }\n        }\n    }\n}\n\n@Composable\nfun ClickableOption(\n    title: String,\n    icon: ImageVector,\n    subtitle: String = \"\",\n    onClick: () -> Unit\n) {\n    Row(\n        modifier = Modifier\n            .fillMaxWidth()\n            .clickable(onClick = onClick),\n        verticalAlignment = Alignment.CenterVertically,\n        horizontalArrangement = Arrangement.Start\n    ) {\n        Icon(icon, contentDescription = title)\n        Column(\n            modifier = Modifier\n                .weight(1f)\n                .padding(vertical = 10.dp)\n        ) {\n            Text(\n                modifier = Modifier\n                    .padding(horizontal = 10.dp),\n                text = title\n            )\n            if (subtitle.isNotEmpty()) {\n                Text(\n                    modifier = Modifier\n                        .padding(horizontal = 10.dp),\n                    text = subtitle,\n                    style = MaterialTheme.typography.labelSmall\n                )\n            }\n        }\n    }\n}\n\n@Preview(showBackground = true)\n@Composable\nfun ClickableOptionPreviewWithSubtitle() {\n    MaterialTheme {\n        ClickableOption(\n            item = Item(\n                title = \"Settings\",\n                subtitle = \"Set your preferences\",\n                icon = Icons.Default.Settings,\n                onClick = {}\n            )\n        )\n    }\n}\n\n@Preview(showBackground = true)\n@Composable\nfun ClickableOptionPreviewWithoutSubtitle() {\n    MaterialTheme {\n        ClickableOption(\n            item = Item(\n                title = \"Profile\",\n                icon = Icons.Default.Person,\n                onClick = {}\n            )\n        )\n    }\n}\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/DataSection.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.settings\n\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Delete\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.unit.dp\nimport kotlinx.coroutines.flow.StateFlow\n\n@Composable\nfun DataSection(\n    cacheSize: StateFlow<String>,\n    onClearCache: () -> Unit\n) {\n    Column(\n        modifier = Modifier\n            .fillMaxWidth()\n            .padding(horizontal = 16.dp)\n            .padding(top = 12.dp, bottom = 5.dp)\n    ) {\n        Text(text = \"Data\", style = MaterialTheme.typography.titleSmall)\n        Spacer(modifier = Modifier.height(8.dp))\n\n        val currentCacheSize by cacheSize.collectAsState()\n\n        ClickableOption(\n            title = \"Clear Image Cache\",\n            icon = Icons.Default.Delete,\n            subtitle = \"Cache size: $currentCacheSize\",\n            onClick = onClearCache\n        )\n    }\n}\n\n\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/DebugSection.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.settings\n\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.BugReport\nimport androidx.compose.material.icons.filled.Code\nimport androidx.compose.material.icons.filled.Delete\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.unit.dp\n\n@Composable\nfun DebugSection(\n    onNavigateToLogs: () -> Unit,\n    onViewLastCrash: () -> Unit,\n) {\n    Column(\n        modifier = Modifier\n            .fillMaxWidth()\n            .padding(horizontal = 16.dp)\n            .padding(top = 12.dp, bottom = 5.dp)\n    ) {\n        Text(text = \"Debug\", style = MaterialTheme.typography.titleSmall)\n        Spacer(modifier = Modifier.height(8.dp))\n        ClickableOption(\n            title = \"View network logs\",\n            icon = Icons.Default.Code,\n            onClick = onNavigateToLogs\n        )\n        ClickableOption(\n            title = \"View last crash\",\n            icon = Icons.Default.BugReport,\n            onClick = onViewLastCrash\n        )\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/DefaultsSection.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.settings\n\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Archive\nimport androidx.compose.material.icons.filled.Book\nimport androidx.compose.material.icons.filled.BookmarkAdd\nimport androidx.compose.material.icons.filled.Public\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.unit.dp\n\n@Composable\nfun DefaultsSection(\n    makeArchivePublic: Boolean,\n    onMakeArchivePublicChanged: (Boolean) -> Unit,\n    createEbook: Boolean,\n    onCreateEbookChanged: (Boolean) -> Unit,\n    createArchive: Boolean,\n    onCreateArchiveChanged: (Boolean) -> Unit,\n    autoAddBookmark: Boolean,\n    onAutoAddBookmarkChanged: (Boolean) -> Unit,\n) {\n    Column(\n        modifier = Modifier\n            .fillMaxWidth()\n            .padding(horizontal = 16.dp)\n            .padding(top = 12.dp, bottom = 5.dp)\n    ) {\n        Text(text = \"Defaults\", style = MaterialTheme.typography.titleSmall)\n        Spacer(modifier = Modifier.height(5.dp))\n        SwitchOption(\n            title = \"Make bookmark publicly available\",\n            icon = Icons.Filled.Public,\n            checked = makeArchivePublic,\n            onCheckedChange = onMakeArchivePublicChanged\n        )\n        SwitchOption(\n            title = \"Create archive\",\n            icon = Icons.Filled.Archive,\n            checked = createArchive,\n            onCheckedChange = onCreateArchiveChanged\n        )\n        SwitchOption(\n            title = \"Create Ebook\",\n            icon = Icons.Filled.Book,\n            checked = createEbook,\n            onCheckedChange = onCreateEbookChanged\n        )\n        SwitchOption(\n            title = \"Add bookmark automatically\",\n            icon = Icons.Filled.BookmarkAdd,\n            checked = autoAddBookmark,\n            onCheckedChange = onAutoAddBookmarkChanged\n        )\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/FeedSection.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.settings\n\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Sell\nimport androidx.compose.material.icons.filled.ViewCompactAlt\nimport androidx.compose.material3.BottomSheetDefaults\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.ModalBottomSheet\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.rememberModalBottomSheetState\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.unit.dp\nimport com.desarrollodroide.model.Tag\nimport com.desarrollodroide.pagekeeper.ui.components.InfiniteProgressDialog\nimport com.desarrollodroide.pagekeeper.ui.components.UiState\nimport kotlinx.coroutines.launch\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun FeedSection(\n    compactView: Boolean,\n    onCompactViewChanged: (Boolean) -> Unit,\n    onClickHideDialogOption: () -> Unit,\n    onHideTagChanged: (Tag?) -> Unit,\n    tagsUiState: UiState<List<Tag>>,\n    hideTag: Tag?,\n    ) {\n    val isCategoriesVisible = remember { mutableStateOf(false) }\n\n    Column(\n        modifier = Modifier\n            .fillMaxWidth()\n            .padding(horizontal = 16.dp)\n            .padding(top = 12.dp, bottom = 5.dp)\n    ) {\n        Text(\n            text = \"Bookmark list\",\n            style = MaterialTheme.typography.titleSmall\n        )\n        Spacer(modifier = Modifier.height(5.dp))\n        SwitchOption(\n            title = \"Compact view\",\n            icon = Icons.Filled.ViewCompactAlt,\n            checked = compactView,\n            onCheckedChange = onCompactViewChanged\n        )\n        ClickableOption(\n            title = \"Hide tag\",\n            icon = Icons.Filled.Sell,\n            subtitle = hideTag?.name ?: \"None\",\n            onClick = onClickHideDialogOption\n        )\n\n        if (tagsUiState.isLoading) {\n            InfiniteProgressDialog(onDismissRequest = {})\n        }\n\n        val sheetStateCategories = rememberModalBottomSheetState(\n            skipPartiallyExpanded = false\n        )\n\n        LaunchedEffect(tagsUiState) {\n            if (tagsUiState.data != null) {\n                isCategoriesVisible.value = true\n            }\n        }\n\n        if (isCategoriesVisible.value) {\n            val scope = rememberCoroutineScope()\n            ModalBottomSheet(\n                shape = BottomSheetDefaults.ExpandedShape,\n                onDismissRequest = {\n                    isCategoriesVisible.value = false\n                },\n                sheetState = sheetStateCategories,\n            ) {\n                val categories: List<Tag> = tagsUiState.data ?: emptyList()\n                HideCategoryOptionView(\n                    hideTag = hideTag,\n                    uniqueCategories = categories,\n                    onApply = { selectedTag ->\n                        scope.launch {\n                            sheetStateCategories.hide()\n                            isCategoriesVisible.value = false\n                            onHideTagChanged(selectedTag)\n                        }\n                    },\n                )\n            }\n        }\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/HideCategoryOptionView.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.settings\n\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.outlined.Sell\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.MutableState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.unit.dp\nimport com.desarrollodroide.model.Tag\nimport com.desarrollodroide.pagekeeper.ui.components.Categories\nimport com.desarrollodroide.pagekeeper.ui.components.CategoriesType\n\n@Composable\nfun HideCategoryOptionView(\n    onApply: (Tag?) -> Unit,\n    uniqueCategories: List<Tag>,\n    hideTag: Tag?\n) {\n    var selectedTag by remember { mutableStateOf(hideTag) }\n\n    Column(\n        modifier = Modifier\n            .fillMaxWidth()\n            .padding(16.dp),\n        horizontalAlignment = Alignment.CenterHorizontally\n    ) {\n        Text(\"Select category to hide\", style = MaterialTheme.typography.headlineSmall)\n        Spacer(Modifier.height(8.dp))\n        if (uniqueCategories.isEmpty()) {\n            Row(\n                horizontalArrangement = Arrangement.Center,\n                verticalAlignment = Alignment.CenterVertically,\n                modifier = Modifier\n                    .padding(vertical = 16.dp)\n                    .fillMaxWidth()\n            ) {\n                Icon(\n                    imageVector = Icons.Outlined.Sell,\n                    contentDescription = \"No categories available\",\n                    modifier = Modifier.size(24.dp)\n                )\n                Spacer(Modifier.width(8.dp))\n                Text(\n                    \"No categories available\",\n                    style = MaterialTheme.typography.bodyLarge\n                )\n            }\n        } else {\n            Categories(\n                categoriesType = CategoriesType.SELECTABLES,\n                showCategories = true,\n                uniqueCategories = uniqueCategories,\n                selectedTags = listOfNotNull(selectedTag),\n                onCategorySelected = { tag ->\n                    selectedTag = tag\n                },\n                onCategoryDeselected = {\n                    selectedTag = null\n                },\n                singleSelection = true\n            )\n        }\n\n        Spacer(Modifier.height(24.dp))\n        Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {\n            Button(\n                onClick = {\n                    selectedTag = null\n                    onApply(null)\n                },\n                modifier = Modifier.weight(1f)\n            ) {\n                Text(\"None\")\n            }\n            Spacer(Modifier.width(8.dp))\n            Button(\n                onClick = {\n                    onApply(selectedTag)\n                },\n                modifier = Modifier.weight(1f),\n                enabled = uniqueCategories.isNotEmpty()\n            ) {\n                Text(\"Apply\")\n            }\n        }\n        Spacer(modifier = Modifier.height(20.dp))\n    }\n}\n\n@Preview(showBackground = true)\n@Composable\nfun SortAndFilterScreenPreview() {\n    val regionOptions =\n            listOf(\n                Tag(id = 1, name = \"Northern Europe\"), Tag(id = 2, name = \"Western Europe\"),\n                Tag(id = 3, name = \"Southern Europe\"), Tag(id = 4, name = \"Southeast Europe\"),\n                Tag(id = 5, name = \"Central Europe\"), Tag(id = 6, name = \"Eastern Europe\")\n            )\n\n    MaterialTheme {\n        HideCategoryOptionView(\n            onApply = {},\n            uniqueCategories = regionOptions,\n            hideTag = null\n        )\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/LinkableText.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.settings\n\nimport androidx.compose.foundation.text.ClickableText\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.text.SpanStyle\nimport androidx.compose.ui.text.buildAnnotatedString\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.text.style.TextDecoration\nimport androidx.compose.ui.unit.sp\nimport com.desarrollodroide.pagekeeper.extensions.openUrlInBrowser\n\n@Composable\nfun LinkableText(\n    text: String,\n    url: String\n) {\n    val annotatedText = buildAnnotatedString {\n        pushStyle(\n            style = SpanStyle(\n                color = MaterialTheme.colorScheme.primary,\n                textDecoration = TextDecoration.Underline,\n                fontSize = 18.sp,\n                fontWeight = FontWeight.Bold\n            )\n        )\n        append(text)\n        pop()\n    }\n    val context = LocalContext.current\n    ClickableText(\n        text = annotatedText,\n        onClick = {\n            context.openUrlInBrowser(url)\n        }\n    )\n}\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/PrivacyPolicyScreen.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.settings\n\nimport androidx.activity.compose.BackHandler\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.ArrowBack\nimport androidx.compose.material3.CenterAlignedTopAppBar\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Scaffold\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TopAppBarDefaults\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.text.SpanStyle\nimport androidx.compose.ui.text.buildAnnotatedString\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.text.withStyle\nimport androidx.compose.ui.unit.dp\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun PrivacyPolicyScreen(\n    onBack: () -> Unit\n) {\n    BackHandler {\n        onBack()\n    }\n    Scaffold(\n        topBar = {\n            CenterAlignedTopAppBar(\n                title = { Text(\"Privacy policy\") },\n                navigationIcon = {\n                    IconButton(onClick = onBack) {\n                        Icon(\n                            Icons.Filled.ArrowBack,\n                            contentDescription = \"Back\"\n                        )\n                    }\n                },\n                colors = TopAppBarDefaults.centerAlignedTopAppBarColors(\n                    containerColor = MaterialTheme.colorScheme.background\n                )\n            )\n        },\n        containerColor = MaterialTheme.colorScheme.background\n    ) { padding ->\n        Box(\n            modifier = Modifier\n                .fillMaxSize()\n                .padding(padding)\n        ) {\n            Column(\n                modifier = Modifier\n                    .padding(start = 16.dp, end = 16.dp, top = 16.dp)\n                    .verticalScroll(rememberScrollState())\n            ) {\n                val annotatedText = buildAnnotatedString {\n                    withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {\n                        append(\"Privacy Policy of Shiori\\n\\n\")\n                    }\n                    append(\"Effective as of [Insert Date Here].\\n\\n\")\n                    append(\"This Privacy Policy outlines our policies and procedures on the collection, use, and disclosure of personal information. Shiori values your privacy and is committed to protecting it through our compliance with this policy. Our app is designed not to collect or store any personal data from our users, ensuring your privacy and security.\\n\\n\")\n\n                    withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {\n                        append(\"Data Collection and Use\\n\\n\")\n                    }\n                    append(\"As a commitment to your privacy, Shiori does not gather, store, or process any personal data. This includes but is not limited to personal identifiers, contact details, usage data, and location information.\\n\\n\")\n\n                    withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {\n                        append(\"Third-Party Services\\n\\n\")\n                    }\n                    append(\"Shiori does not share any personal data with third parties as no personal data is collected. However, users should be aware that third-party services used by the app may collect their own data.\\n\\n\")\n\n                    withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {\n                        append(\"Security\\n\\n\")\n                    }\n                    append(\"The security of your personal information is important to us. As we do not collect personal data, there is no risk of your personal information being accessed from our app.\\n\\n\")\n\n                    withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {\n                        append(\"Changes to This Privacy Policy\\n\\n\")\n                    }\n                    append(\"We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the 'Effective as of' date at the top of this policy. We may also provide notice to you in other ways in our discretion, such as through contact information you have provided.\\n\\n\")\n\n                    withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {\n                        append(\"Contact Us\\n\\n\")\n                    }\n                    append(\"For questions or concerns about this Privacy Policy, please contact us via email at desarrollodroide@gmail.com or through other communication channels as provided in our app.\\n\")\n                }\n\n                Text(\n                    text = annotatedText,\n                    style = MaterialTheme.typography.bodyLarge,\n                )\n            }\n        }\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/SettingsScreen.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.settings\n\nimport android.util.Log\nimport androidx.activity.compose.BackHandler\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.ArrowBack\nimport androidx.compose.material.icons.filled.Smartphone\nimport androidx.compose.material.icons.filled.Storage\nimport androidx.compose.material3.CenterAlignedTopAppBar\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.HorizontalDivider\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Scaffold\nimport androidx.compose.material3.TopAppBarDefaults\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.lifecycle.compose.collectAsStateWithLifecycle\nimport com.desarrollodroide.data.helpers.SHIORI_GITHUB_URL\nimport com.desarrollodroide.data.helpers.ThemeMode\nimport com.desarrollodroide.model.Tag\nimport com.desarrollodroide.pagekeeper.extensions.openUrlInBrowser\nimport com.desarrollodroide.pagekeeper.ui.components.ErrorDialog\nimport com.desarrollodroide.pagekeeper.ui.components.InfiniteProgressDialog\nimport com.desarrollodroide.pagekeeper.ui.components.UiState\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport com.desarrollodroide.pagekeeper.BuildConfig\nimport com.desarrollodroide.pagekeeper.extensions.sendFeedbackEmail\nimport kotlinx.coroutines.flow.StateFlow\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun SettingsScreen(\n    settingsViewModel: SettingsViewModel,\n    onNavigateToTermsOfUse: () -> Unit,\n    onNavigateToPrivacyPolicy: () -> Unit,\n    onNavigateToSourceCode: () -> Unit,\n    onNavigateToLogs: () -> Unit,\n    onViewLastCrash: () -> Unit,\n    goToLogin: () -> Unit,\n    onBack: () -> Unit\n) {\n    BackHandler {\n        onBack()\n    }\n    val settingsUiState by settingsViewModel.settingsUiState.collectAsStateWithLifecycle()\n    val tagsUiState by settingsViewModel.tagsState.collectAsStateWithLifecycle()\n    val tagToHide by settingsViewModel.tagToHide.collectAsStateWithLifecycle()\n    val compactView by settingsViewModel.compactView.collectAsStateWithLifecycle()\n    val makeArchivePublic by settingsViewModel.makeArchivePublic.collectAsStateWithLifecycle()\n    val createEbook by settingsViewModel.createEbook.collectAsStateWithLifecycle()\n    val autoAddBookmark by settingsViewModel.autoAddBookmark.collectAsStateWithLifecycle()\n    val createArchive by settingsViewModel.createArchive.collectAsStateWithLifecycle()\n\n    Scaffold(\n        topBar = {\n            CenterAlignedTopAppBar(\n                title = { Text(\"Settings\", style = MaterialTheme.typography.titleLarge) },\n                navigationIcon = {\n                    IconButton(onClick = onBack) {\n                        Icon(\n                            Icons.AutoMirrored.Filled.ArrowBack,\n                            contentDescription = \"Back\"\n                        )\n                    }\n                },\n                colors = TopAppBarDefaults.centerAlignedTopAppBarColors(\n                    containerColor = MaterialTheme.colorScheme.background\n                )\n            )\n        },\n        containerColor = MaterialTheme.colorScheme.background\n    ) { paddingValues ->\n\n        Box(\n            modifier = Modifier\n                .padding(paddingValues)\n        ) {\n            SettingsContent(\n                settingsUiState = settingsUiState,\n                tagsUiState = tagsUiState,\n                onLogout = { settingsViewModel.logout() },\n                goToLogin = {\n                    settingsViewModel.clearImageCache()\n                    goToLogin.invoke()\n                },\n                themeMode = settingsViewModel.themeMode,\n                makeArchivePublic = makeArchivePublic,\n                onMakeArchivePublicChanged = { isPublic ->\n                    settingsViewModel.setMakeArchivePublic(isPublic)\n                },\n                createEbook = createEbook,\n                onCreateEbookChanged = { isEbook ->\n                    settingsViewModel.setCreateEbook(isEbook)\n                },\n                createArchive = createArchive,\n                onCreateArchiveChanged = { isArchive ->\n                    settingsViewModel.setCreateArchive(isArchive)\n                },\n                compactView = compactView,\n                onCompactViewChanged = { isCompact ->\n                    settingsViewModel.setCompactView(isCompact)\n                },\n                autoAddBookmark = autoAddBookmark,\n                onAutoAddBookmarkChanged = { isAuto ->\n                    settingsViewModel.setAutoAddBookmark(isAuto)\n                },\n                onNavigateToTermsOfUse = onNavigateToTermsOfUse,\n                onNavigateToPrivacyPolicy = onNavigateToPrivacyPolicy,\n                onNavigateToSourceCode = onNavigateToSourceCode,\n                onNavigateToLogs = onNavigateToLogs,\n                onViewLastCrash = onViewLastCrash,\n                useDynamicColors = settingsViewModel.useDynamicColors,\n                onClickHideDialogOption = settingsViewModel::getTags,\n                onHideTagChanged = settingsViewModel::setHideTag,\n                hideTag = tagToHide,\n                cacheSize = settingsViewModel.cacheSize,\n                onClearCache = settingsViewModel::clearImageCache,\n                serverVersion = settingsViewModel.getServerVersion(),\n                serverUrl = settingsViewModel.getServerUrl()\n            )\n        }\n    }\n}\n\n@Composable\nfun SettingsContent(\n    settingsUiState: UiState<String>,\n    makeArchivePublic: Boolean,\n    onMakeArchivePublicChanged: (Boolean) -> Unit,\n    createEbook: Boolean,\n    onCreateEbookChanged: (Boolean) -> Unit,\n    createArchive: Boolean,\n    onCreateArchiveChanged: (Boolean) -> Unit,\n    autoAddBookmark: Boolean,\n    onAutoAddBookmarkChanged: (Boolean) -> Unit,\n    compactView: Boolean,\n    onCompactViewChanged: (Boolean) -> Unit,\n    onLogout: () -> Unit,\n    onNavigateToSourceCode: () -> Unit,\n    onNavigateToTermsOfUse: () -> Unit,\n    onNavigateToPrivacyPolicy: () -> Unit,\n    onNavigateToLogs: () -> Unit,\n    onViewLastCrash: () -> Unit,\n    themeMode: MutableStateFlow<ThemeMode>,\n    goToLogin: () -> Unit,\n    useDynamicColors: MutableStateFlow<Boolean>,\n    tagsUiState: UiState<List<Tag>>,\n    onClickHideDialogOption: () -> Unit,\n    onHideTagChanged: (Tag?) -> Unit,\n    hideTag: Tag?,\n    cacheSize: StateFlow<String>,\n    onClearCache: () -> Unit,\n    serverVersion: String,\n    serverUrl: String,\n    ) {\n    val context = LocalContext.current\n    if (settingsUiState.isLoading) {\n        InfiniteProgressDialog(onDismissRequest = {})\n        Log.v(\"SettingsContent!!\", \"settingsUiState.isLoading\")\n    }\n    if (!settingsUiState.error.isNullOrEmpty()) {\n        ErrorDialog(\n            title = \"Error\",\n            content = settingsUiState.error,\n            openDialog = remember { mutableStateOf(true) },\n            onConfirm = {\n                goToLogin()\n            }\n        )\n        Log.v(\"SettingsContent!!\", settingsUiState.error)\n    } else if (settingsUiState.data == null) {\n        Log.v(\"SettingsContent!!\", \"settingsUiState.data is null\")\n    } else {\n        Log.v(\"SettingsContent!!\", \"settingsUiState.data is not null\")\n        LaunchedEffect(Unit) {\n            goToLogin()\n        }\n    }\n    LazyColumn(\n        modifier = Modifier.padding(horizontal = 16.dp)\n    ) {\n        item {\n            Spacer(modifier = Modifier.height(8.dp))\n            VisualSection(\n                themeMode = themeMode,\n                dynamicColors = useDynamicColors\n            )\n            HorizontalDivider()\n            Spacer(modifier = Modifier.height(18.dp))\n            FeedSection(\n                compactView = compactView,\n                onCompactViewChanged = onCompactViewChanged,\n                tagsUiState = tagsUiState,\n                onHideTagChanged = onHideTagChanged,\n                onClickHideDialogOption = onClickHideDialogOption,\n                hideTag = hideTag\n            )\n            HorizontalDivider()\n            Spacer(modifier = Modifier.height(18.dp))\n            DefaultsSection(\n                makeArchivePublic = makeArchivePublic,\n                onMakeArchivePublicChanged = onMakeArchivePublicChanged,\n                createEbook = createEbook,\n                onCreateEbookChanged = onCreateEbookChanged,\n                createArchive = createArchive,\n                onCreateArchiveChanged = onCreateArchiveChanged,\n                autoAddBookmark = autoAddBookmark,\n                onAutoAddBookmarkChanged = onAutoAddBookmarkChanged\n            )\n            HorizontalDivider()\n            Spacer(modifier = Modifier.height(18.dp))\n            DataSection(\n                cacheSize = cacheSize,\n                onClearCache = onClearCache\n            )\n            if (BuildConfig.FLAVOR == \"staging\") {\n                HorizontalDivider()\n                Spacer(modifier = Modifier.height(18.dp))\n                DebugSection (\n                    onNavigateToLogs = onNavigateToLogs,\n                    onViewLastCrash = onViewLastCrash\n                )\n            }\n            HorizontalDivider()\n            Spacer(modifier = Modifier.height(18.dp))\n            AccountSection(\n                serverUrl = serverUrl,\n                onLogout = onLogout,\n                onNavigateToTermsOfUse = onNavigateToTermsOfUse,\n                onNavigateToPrivacyPolicy = onNavigateToPrivacyPolicy,\n                onNavigateToSeverSettings = {\n                    context.openUrlInBrowser(SHIORI_GITHUB_URL)\n                },\n                onSendFeedbackEmail = {\n                    context.sendFeedbackEmail()\n                },\n                onNavigateToSourceCode = onNavigateToSourceCode\n            )\n            Spacer(modifier = Modifier.height(18.dp))\n            Row(\n                modifier = Modifier\n                    .fillMaxWidth()\n                    .padding(horizontal = 8.dp),\n                horizontalArrangement = Arrangement.SpaceBetween\n            ) {\n                if (serverVersion.isNotEmpty()) {\n                    Row(\n                        verticalAlignment = Alignment.CenterVertically,\n                        horizontalArrangement = Arrangement.Start\n                    ) {\n                        Icon(\n                            imageVector = Icons.Default.Storage,\n                            contentDescription = \"Server version\",\n                            modifier = Modifier.size(16.dp),\n                            tint = MaterialTheme.colorScheme.onSurfaceVariant\n                        )\n                        Spacer(modifier = Modifier.width(4.dp))\n                        Text(\n                            text = \"Server v${serverVersion}\",\n                            style = MaterialTheme.typography.labelMedium,\n                            color = MaterialTheme.colorScheme.onSurfaceVariant\n                        )\n                    }\n                }\n                Row(\n                    verticalAlignment = Alignment.CenterVertically,\n                    horizontalArrangement = Arrangement.End\n                ) {\n                    Icon(\n                        imageVector = Icons.Default.Smartphone,\n                        contentDescription = \"App version\",\n                        modifier = Modifier.size(16.dp),\n                        tint = MaterialTheme.colorScheme.onSurfaceVariant\n                    )\n                    Spacer(modifier = Modifier.width(4.dp))\n                    Text(\n                        text = \"App v${BuildConfig.VERSION_NAME}\",\n                        style = MaterialTheme.typography.labelMedium,\n                        color = MaterialTheme.colorScheme.onSurfaceVariant\n                    )\n                }\n            }\n            Spacer(modifier = Modifier.height(18.dp))\n        }\n    }\n}\n\n@Composable\nprivate fun HorizontalDivider(){\n    HorizontalDivider(\n        modifier = Modifier\n            .height(1.dp)\n            .padding(horizontal = 6.dp,),\n        color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)\n    )\n}\n\ndata class Item(\n    val title: String,\n    val icon: ImageVector,\n    val subtitle: String = \"\",\n    val onClick: () -> Unit = {},\n    val switchState: MutableStateFlow<Boolean> = MutableStateFlow(false)\n)\n\n\n@Preview(showBackground = true)\n@Composable\nfun SettingsScreenPreview() {\n    SettingsContent(\n        settingsUiState = UiState(isLoading = false),\n        makeArchivePublic = false,\n        onMakeArchivePublicChanged = {},\n        createEbook = false,\n        onCreateEbookChanged = {},\n        createArchive = false,\n        onCreateArchiveChanged = {},\n        autoAddBookmark = false,\n        onAutoAddBookmarkChanged = { },\n        compactView = false,\n        onCompactViewChanged = {},\n        onLogout = {},\n        onNavigateToSourceCode = {},\n        onNavigateToTermsOfUse = {},\n        onNavigateToPrivacyPolicy = {},\n        onNavigateToLogs = {},\n        onViewLastCrash = {},\n        themeMode = remember { MutableStateFlow(ThemeMode.AUTO)},\n        goToLogin = {},\n        useDynamicColors = remember { MutableStateFlow(false) },\n        tagsUiState = UiState(isLoading = false),\n        onClickHideDialogOption = {},\n        onHideTagChanged = {},\n        hideTag = null,\n        cacheSize = MutableStateFlow(\"Calculating...\"),\n        onClearCache = {},\n        serverVersion = \"1.0.0\",\n        serverUrl = \"192.168.1.66:8888\"\n    )\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/SettingsSectionState.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.settings\n\nimport com.desarrollodroide.data.helpers.ThemeMode\nimport com.desarrollodroide.model.Tag\nimport com.desarrollodroide.pagekeeper.ui.components.UiState\n\nsealed class SettingsSectionState {\n\n    /**\n     * Represents the visual settings section.\n     */\n    data class Visual(\n        val themeMode: ThemeMode,\n        val useDynamicColors: Boolean\n    ) : SettingsSectionState()\n\n    /**\n     * Represents the feed settings section.\n     */\n    data class Feed(\n        val compactView: Boolean,\n        val hideTag: Tag?,\n        val tagsUiState: UiState<List<Tag>>\n    ) : SettingsSectionState()\n\n    /**\n     * Represents the defaults settings section.\n     */\n    data class Defaults(\n        val makeArchivePublic: Boolean,\n        val createEbook: Boolean,\n        val createArchive: Boolean,\n        val autoAddBookmark: Boolean\n    ) : SettingsSectionState()\n\n    /**\n     * Represents the data settings section.\n     */\n    data class Data(\n        val cacheSize: String\n    ) : SettingsSectionState()\n\n    /**\n     * Represents an error state within the settings.\n     */\n    data class Error(val message: String) : SettingsSectionState()\n\n    /**\n     * Represents a loading state within the settings.\n     */\n    object Loading : SettingsSectionState()\n}\n\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/SettingsViewModel.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.settings\n\nimport android.util.Log\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport coil.ImageLoader\nimport coil.annotation.ExperimentalCoilApi\nimport com.desarrollodroide.pagekeeper.helpers.ThemeManager\nimport com.desarrollodroide.pagekeeper.ui.components.UiState\nimport com.desarrollodroide.pagekeeper.ui.components.error\nimport com.desarrollodroide.pagekeeper.ui.components.isLoading\nimport com.desarrollodroide.pagekeeper.ui.components.success\nimport com.desarrollodroide.common.result.Result\nimport com.desarrollodroide.data.helpers.ThemeMode\nimport com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource\nimport com.desarrollodroide.data.repository.BookmarksRepository\nimport com.desarrollodroide.domain.usecase.GetTagsUseCase\nimport com.desarrollodroide.domain.usecase.SendLogoutUseCase\nimport com.desarrollodroide.model.Tag\nimport com.desarrollodroide.pagekeeper.extensions.bytesToDisplaySize\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.asStateFlow\nimport kotlinx.coroutines.flow.distinctUntilChanged\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\n\nclass SettingsViewModel(\n    private val sendLogoutUseCase: SendLogoutUseCase,\n    private val bookmarksRepository: BookmarksRepository,\n    private val settingsPreferenceDataSource: SettingsPreferenceDataSource,\n    private val themeManager: ThemeManager,\n    private val getTagsUseCase: GetTagsUseCase,\n    private val imageLoader: ImageLoader,\n    ) : ViewModel() {\n\n    private val _settingsUiState = MutableStateFlow(UiState<String>(isLoading = false))\n    val settingsUiState = _settingsUiState.asStateFlow()\n\n    private val _tagsState = MutableStateFlow(UiState<List<Tag>>(idle = true))\n    val tagsState = _tagsState.asStateFlow()\n\n    private val _cacheSize = MutableStateFlow(\"Calculating...\")\n    val cacheSize: StateFlow<String> = _cacheSize.asStateFlow()\n\n    val useDynamicColors = MutableStateFlow(false)\n    val themeMode = MutableStateFlow(ThemeMode.AUTO)\n    private var _token = \"\"\n    private var _serverVersion = \"\"\n    private var _serverUrl: String = \"\"\n\n    val compactView: StateFlow<Boolean> = settingsPreferenceDataSource.compactViewFlow\n        .stateIn(viewModelScope, SharingStarted.Eagerly, false)\n\n    val makeArchivePublic: StateFlow<Boolean> = settingsPreferenceDataSource.makeArchivePublicFlow\n        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)\n\n    val createEbook: StateFlow<Boolean> = settingsPreferenceDataSource.createEbookFlow\n        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)\n\n    val autoAddBookmark: StateFlow<Boolean> = settingsPreferenceDataSource.autoAddBookmarkFlow\n        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)\n\n    val createArchive: StateFlow<Boolean> = settingsPreferenceDataSource.createArchiveFlow\n        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)\n\n    val tagToHide: StateFlow<Tag?> = settingsPreferenceDataSource.hideTagFlow\n        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)\n\n\n    fun setAutoAddBookmark(value: Boolean) {\n        viewModelScope.launch {\n            settingsPreferenceDataSource.setAutoAddBookmark(value)\n        }\n    }\n\n    fun setCompactView(isCompact: Boolean) {\n        viewModelScope.launch {\n            settingsPreferenceDataSource.setCompactView(isCompact)\n        }\n    }\n\n    fun setMakeArchivePublic(isPublic: Boolean) {\n        viewModelScope.launch {\n            settingsPreferenceDataSource.setMakeArchivePublic(isPublic)\n        }\n    }\n\n    fun setCreateEbook(ebook: Boolean) {\n        viewModelScope.launch {\n            settingsPreferenceDataSource.setCreateEbook(ebook)\n        }\n    }\n\n    fun setCreateArchive(archive: Boolean) {\n        viewModelScope.launch {\n            settingsPreferenceDataSource.setCreateArchive(archive)\n        }\n    }\n\n    fun setHideTag(tag: Tag?) {\n        viewModelScope.launch {\n            settingsPreferenceDataSource.setHideTag(tag)\n        }\n    }\n\n    init {\n        loadSettings()\n        observeDefaultsSettings()\n        updateCacheSize()\n    }\n    fun logout() {\n        viewModelScope.launch {\n            sendLogoutUseCase(\n                serverUrl = settingsPreferenceDataSource.getUrl(),\n                xSession = settingsPreferenceDataSource.getSession()\n            ).collect { result ->\n                when (result) {\n                    is Result.Error -> {\n                        _settingsUiState.error(errorMessage = result.error?.throwable?.message?: \"\")\n                    }\n                    is Result.Loading -> {\n                        _settingsUiState.isLoading(true)\n                    }\n                    is Result.Success -> {\n                        _settingsUiState.success(result.data)\n                    }\n                }\n            }\n        }\n    }\n\n    private fun loadSettings() {\n        viewModelScope.launch {\n            useDynamicColors.value = settingsPreferenceDataSource.getUseDynamicColors()\n            themeMode.value = settingsPreferenceDataSource.getThemeMode()\n            _token = settingsPreferenceDataSource.getToken()\n            _serverVersion = settingsPreferenceDataSource.getServerVersion()\n            _serverUrl = settingsPreferenceDataSource.getUrl()\n        }\n    }\n\n    fun getTags() {\n      viewModelScope.launch {\n            getTagsUseCase.invoke(\n                serverUrl = settingsPreferenceDataSource.getUrl(),\n                token = _token,\n            )\n                .distinctUntilChanged()\n                .collect { result ->\n                    when (result) {\n                        is Result.Error -> {\n                            Log.v(\"FeedViewModel\", \"Error getting tags: ${result.error?.message}\")\n                        }\n                        is Result.Loading -> {\n                            Log.v(\"FeedViewModel\", \"Loading, updating tags from cache...\")\n                            _tagsState.isLoading(true)\n                        }\n                        is Result.Success -> {\n                            Log.v(\"FeedViewModel\", \"Tags loaded successfully.\")\n                            _tagsState.success(result.data)\n                        }\n                    }\n                }\n        }\n    }\n\n    @OptIn(ExperimentalCoilApi::class)\n    private fun updateCacheSize() {\n        viewModelScope.launch {\n            val size = imageLoader.diskCache?.size ?: 0L\n            _cacheSize.value = size.bytesToDisplaySize()\n        }\n    }\n\n    @OptIn(ExperimentalCoilApi::class)\n    fun clearImageCache() {\n        viewModelScope.launch {\n            imageLoader.memoryCache?.clear()\n            imageLoader.diskCache?.clear()\n            updateCacheSize()\n        }\n    }\n\n    private fun observeDefaultsSettings() {\n        viewModelScope.launch {\n            useDynamicColors.collect { newValue ->\n                settingsPreferenceDataSource.setUseDynamicColors(newValue)\n                themeManager.useDynamicColors.value = newValue\n            }\n        }\n        viewModelScope.launch {\n            themeMode.collect { newValue ->\n                settingsPreferenceDataSource.setTheme(newValue)\n                themeManager.themeMode.value = newValue\n            }\n        }\n    }\n\n    fun getServerUrl(): String = _serverUrl\n\n    fun getServerVersion(): String = _serverVersion\n\n}\n\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/SwitchOption.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.settings\n\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Switch\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.compose.ui.unit.dp\nimport androidx.lifecycle.compose.collectAsStateWithLifecycle\nimport kotlinx.coroutines.flow.MutableStateFlow\n\n@Composable\nfun SwitchOption(\n    item: Item,\n    switchState: MutableStateFlow<Boolean>,\n) {\n    val switchValue by switchState.collectAsState()\n\n    Row(\n        modifier = Modifier\n            .fillMaxWidth()\n            .clickable { switchState.value = !switchValue },\n        verticalAlignment = Alignment.CenterVertically,\n        horizontalArrangement = Arrangement.Start\n    ) {\n        Icon(item.icon, contentDescription = item.title)\n        Spacer(modifier = Modifier.width(12.dp))\n        Text(text = item.title, modifier = Modifier.weight(1f))\n        Spacer(modifier = Modifier.width(5.dp))\n        Switch(\n            checked = switchValue,\n            onCheckedChange = { newValue ->\n                switchState.value = newValue\n            }\n        )\n    }\n}\n\n@Composable\nfun SwitchOption(\n    title: String,\n    icon: ImageVector,\n    checked: Boolean,\n    onCheckedChange: (Boolean) -> Unit\n) {\n    Row(\n        modifier = Modifier\n            .fillMaxWidth()\n            .clickable { onCheckedChange(!checked) } // Permite hacer clic en cualquier parte de la fila\n            .padding(vertical = 8.dp),\n        verticalAlignment = Alignment.CenterVertically\n    ) {\n        Icon(\n            imageVector = icon,\n            contentDescription = title\n        )\n        Spacer(modifier = Modifier.width(16.dp))\n        Text(\n            text = title,\n            style = MaterialTheme.typography.bodyLarge,\n            modifier = Modifier.weight(1f)\n        )\n        Switch(\n            checked = checked,\n            onCheckedChange = onCheckedChange\n        )\n    }\n}\n\n\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/TermsOfUseScreen.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.settings\n\nimport androidx.activity.compose.BackHandler\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.ArrowBack\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.text.SpanStyle\nimport androidx.compose.ui.text.buildAnnotatedString\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.text.withStyle\nimport androidx.compose.ui.unit.dp\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun TermsOfUseScreen(\n    onBack: () -> Unit\n) {\n    BackHandler {\n        onBack()\n    }\n    Scaffold(\n        topBar = {\n            CenterAlignedTopAppBar(\n                title = { Text(\"Terms of Use\") },\n                navigationIcon = {\n                    IconButton(onClick = onBack) {\n                        Icon(\n                            Icons.AutoMirrored.Filled.ArrowBack,\n                            contentDescription = \"Back\"\n                        )\n                    }\n                },\n                colors = TopAppBarDefaults.centerAlignedTopAppBarColors(\n                    containerColor = MaterialTheme.colorScheme.background\n                )\n            )\n        },\n        containerColor = MaterialTheme.colorScheme.background\n    ) { padding ->\n        Box(\n            modifier = Modifier\n                .fillMaxSize()\n                .padding(padding)\n        ) {\n            Column(\n                modifier = Modifier\n                    .padding(start = 16.dp, end = 16.dp, top = 16.dp)\n                    .verticalScroll(rememberScrollState())\n            ) {\n                val termsText = buildAnnotatedString {\n                    append(\"1. \")\n                    withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {\n                        append(\"Acceptance of Terms\\n\\n\")\n                    }\n                    append(\"By accessing and using Shiori, you agree to be bound by these Terms of Use.\\n\\n\")\n                    append(\"2. \")\n                    withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {\n                        append(\"License for the App\\n\\n\")\n                    }\n                    append(\"Shiori is provided under the Apache 2.0 License, allowing personal and commercial use, redistribution, and modification under the terms specified in the LICENSE file included with the source code.\\n\\n\")\n                    append(\"3. \")\n                    withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {\n                        append(\"Disclaimer\\n\\n\")\n                    }\n                    append(\"Shiori is provided 'as is', without any warranties, expressed or implied, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose.\\n\\n\")\n                    append(\"4. \")\n                    withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {\n                        append(\"Limitations of Liability\\n\\n\")\n                    }\n                    append(\"In no event shall Shiori, or its contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage.\\n\\n\")\n                    append(\"5. \")\n                    withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {\n                        append(\"Modifications to Terms\\n\\n\")\n                    }\n                    append(\"We may revise these terms of use for Shiori at any time without notice. By using this app, you are agreeing to be bound by the then current version of these terms of use.\\n\\n\")\n                    append(\"6. \")\n                    withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {\n                        append(\"Governing Law\\n\\n\")\n                    }\n                    append(\"Any claim relating to Shiori shall be governed by the laws of the app owner's jurisdiction without regard to its conflict of law provisions.\\n\")\n                }\n\n\n                Text(\n                    text = termsText,\n                    style = MaterialTheme.typography.bodyLarge,\n                )\n            }\n        }\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/VisualSection.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.settings\n\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.DarkMode\nimport androidx.compose.material.icons.filled.FormatColorFill\nimport androidx.compose.material.icons.filled.HdrAuto\nimport androidx.compose.material.icons.filled.LightMode\nimport androidx.compose.material.icons.filled.Palette\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.unit.dp\nimport com.desarrollodroide.data.helpers.ThemeMode\nimport kotlinx.coroutines.flow.MutableStateFlow\n\n@Composable\nfun VisualSection(\n    themeMode: MutableStateFlow<ThemeMode>,\n    dynamicColors: MutableStateFlow<Boolean>,\n) {\n    Column(\n        modifier = Modifier\n            .fillMaxWidth()\n            .padding(horizontal = 16.dp)\n            .padding(top = 12.dp, bottom = 5.dp)\n    ) {\n        Text(text = \"Visual\", style = MaterialTheme.typography.titleSmall)\n        Spacer(modifier = Modifier.height(5.dp))\n        ThemeOption(\n            item = Item(\"Theme\", Icons.Filled.Palette, onClick = {}),\n            initialThemeMode = themeMode\n        )\n        val dynamicColorItem = Item(\n            title = \"Use dynamic colors\",\n            icon = Icons.Filled.FormatColorFill,\n            switchState = dynamicColors\n        )\n        SwitchOption(\n            item = dynamicColorItem,\n            switchState = dynamicColors\n        )\n    }\n}\n\n@Composable\nfun ThemeOption(\n    item: Item,\n    initialThemeMode: MutableStateFlow<ThemeMode>,\n) {\n    val themeMode by initialThemeMode.collectAsState()\n\n    Row(\n        modifier = Modifier\n            .fillMaxWidth()\n            .clickable {\n                val newMode = when (themeMode) {\n                    ThemeMode.DARK -> ThemeMode.LIGHT\n                    ThemeMode.LIGHT -> ThemeMode.AUTO\n                    ThemeMode.AUTO -> ThemeMode.DARK\n                }\n                initialThemeMode.value = newMode\n                item.onClick()\n            },\n        verticalAlignment = Alignment.CenterVertically,\n        horizontalArrangement = Arrangement.Start\n    ) {\n        Icon(item.icon, contentDescription = \"Change theme\")\n        Spacer(modifier = Modifier.width(12.dp))\n        Text(\n            text = item.title, modifier = Modifier\n                .weight(1f)\n                .padding(vertical = 10.dp)\n        )\n\n        val themeIcon = when (themeMode) {\n            ThemeMode.DARK -> Icons.Filled.DarkMode\n            ThemeMode.LIGHT -> Icons.Filled.LightMode\n            ThemeMode.AUTO -> Icons.Filled.HdrAuto\n        }\n        Icon(themeIcon, contentDescription = \"Current theme icon\")\n    }\n}\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/crash/CrashLogScreen.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.settings.crash\n\n\nimport androidx.activity.compose.BackHandler\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material.icons.automirrored.filled.ArrowBack\nimport androidx.compose.material.icons.filled.Delete\nimport androidx.compose.material.icons.filled.Share\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.lifecycle.compose.collectAsStateWithLifecycle\nimport org.koin.androidx.compose.getViewModel\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material3.CenterAlignedTopAppBar\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Scaffold\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TopAppBarDefaults\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.Alignment\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun CrashLogScreen(\n    onBack: () -> Unit,\n    onShare:(String) -> Unit\n) {\n    val viewModel: CrashLogViewModel = getViewModel()\n    val crashLog by viewModel.crashLog.collectAsStateWithLifecycle()\n\n    BackHandler {\n        onBack()\n    }\n\n    Scaffold(\n        topBar = {\n            CenterAlignedTopAppBar(\n                title = { Text(\"Last Crash Log\") },\n                navigationIcon = {\n                    IconButton(onClick = onBack) {\n                        Icon(\n                            Icons.AutoMirrored.Filled.ArrowBack,\n                            contentDescription = \"Back\"\n                        )\n                    }\n                },\n                actions = {\n                    IconButton(onClick = { viewModel.clearCrashLog() }) {\n                        Icon(\n                            Icons.Default.Delete,\n                            contentDescription = \"Clear crash log\"\n                        )\n                    }\n                    IconButton(onClick = { onShare.invoke(viewModel.shareCrashLog()) }) {\n                        Icon(\n                            Icons.Default.Share,\n                            contentDescription = \"Share crash log\"\n                        )\n                    }\n                },\n                colors = TopAppBarDefaults.centerAlignedTopAppBarColors(\n                    containerColor = MaterialTheme.colorScheme.background\n                )\n            )\n        },\n        containerColor = MaterialTheme.colorScheme.background\n    ) { padding ->\n        Box(\n            modifier = Modifier\n                .fillMaxSize()\n                .padding(padding)\n        ) {\n            if (crashLog == null || crashLog?.isEmpty() == true) {\n                Box(\n                    modifier = Modifier.fillMaxSize(),\n                    contentAlignment = Alignment.Center\n                ) {\n                    Text(\n                        text = \"No crash log available\",\n                        style = MaterialTheme.typography.bodyLarge,\n                        color = MaterialTheme.colorScheme.onSurfaceVariant\n                    )\n                }\n            } else {\n                LazyColumn(\n                    modifier = Modifier.padding(16.dp)\n                ) {\n                    item {\n                        CrashLogContent(crashLog = crashLog ?: \"\")\n                    }\n                }\n            }\n        }\n    }\n}\n\n@Composable\nfun CrashLogContent(\n    crashLog: String\n) {\n    Column(\n        modifier = Modifier\n            .fillMaxWidth()\n            .background(\n                MaterialTheme.colorScheme.surfaceVariant,\n                shape = RoundedCornerShape(8.dp)\n            )\n            .padding(8.dp)\n    ) {\n        Text(\n            text = crashLog,\n            style = MaterialTheme.typography.bodyMedium,\n            color = MaterialTheme.colorScheme.onSurfaceVariant\n        )\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/crash/CrashLogViewModel.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.settings.crash\n\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.asStateFlow\nimport kotlinx.coroutines.launch\n\nclass CrashLogViewModel(\n    private val settingsPreferenceDataSource: SettingsPreferenceDataSource,\n) : ViewModel() {\n\n    private val _crashLog = MutableStateFlow<String?>(null)\n    val crashLog = _crashLog.asStateFlow()\n\n    init {\n        loadLastCrash()\n    }\n\n    private fun loadLastCrash() {\n        viewModelScope.launch {\n            _crashLog.value = settingsPreferenceDataSource.getLastCrashLog()\n        }\n    }\n\n    fun clearCrashLog() {\n        viewModelScope.launch {\n            settingsPreferenceDataSource.clearLastCrashLog()\n            _crashLog.value = \"\"\n        }\n    }\n\n    fun shareCrashLog(): String = crashLog.value ?: \"\"\n\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/logcat/NetworkLogScreen.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.settings.logcat\n\nimport androidx.activity.compose.BackHandler\nimport androidx.compose.animation.animateContentSize\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material.icons.automirrored.filled.ArrowBack\nimport androidx.compose.material.icons.filled.Delete\nimport androidx.compose.material.icons.filled.Share\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.lifecycle.compose.collectAsStateWithLifecycle\nimport com.desarrollodroide.common.result.NetworkLogEntry\nimport org.koin.androidx.compose.getViewModel\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material3.CenterAlignedTopAppBar\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Scaffold\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TopAppBarDefaults\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.text.TextLayoutResult\nimport androidx.compose.ui.text.style.TextOverflow\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun NetworkLogScreen(\n    onBack: () -> Unit,\n    onShare:(String) -> Unit\n) {\n    val viewModel: NetworkLogViewModel = getViewModel()\n    val logs by viewModel.logs.collectAsStateWithLifecycle()\n\n    BackHandler {\n        onBack()\n    }\n\n    Scaffold(\n        topBar = {\n            CenterAlignedTopAppBar(\n                title = { Text(\"Network Logs\") },\n                navigationIcon = {\n                    IconButton(onClick = onBack) {\n                        Icon(\n                            Icons.AutoMirrored.Filled.ArrowBack,\n                            contentDescription = \"Back\"\n                        )\n                    }\n                },\n                actions = {\n                    IconButton(onClick = { viewModel.clearLogs() }) {\n                        Icon(\n                            Icons.Default.Delete,\n                            contentDescription = \"Clear logs\"\n                        )\n                    }\n                    IconButton(onClick = { onShare.invoke(viewModel.shareLogs()) }) {\n                        Icon(\n                            Icons.Default.Share,\n                            contentDescription = \"Share logs\"\n                        )\n                    }\n                },\n                colors = TopAppBarDefaults.centerAlignedTopAppBarColors(\n                    containerColor = MaterialTheme.colorScheme.background\n                )\n            )\n        },\n        containerColor = MaterialTheme.colorScheme.background\n    ) { padding ->\n        Box(\n            modifier = Modifier\n                .fillMaxSize()\n                .padding(padding)\n        ) {\n            LazyColumn(\n                modifier = Modifier\n                    .padding(start = 16.dp, end = 16.dp, top = 16.dp),\n                reverseLayout = true\n            ) {\n                items(logs) { logEntry ->\n                    NetworkLogEntryItem(logEntry)\n                    Spacer(modifier = Modifier.height(8.dp))\n                }\n            }\n        }\n    }\n}\n\n@Composable\nfun NetworkLogEntryItem(\n    logEntry: NetworkLogEntry\n) {\n    var isExpanded by remember { mutableStateOf(false) }\n    var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }\n    var hasMoreContent by remember { mutableStateOf(false) }\n\n    Column(\n        modifier = Modifier\n            .fillMaxWidth()\n            .background(\n                MaterialTheme.colorScheme.surfaceVariant,\n                shape = RoundedCornerShape(8.dp)\n            )\n            .padding(8.dp)\n    ) {\n        Row(\n            modifier = Modifier.fillMaxWidth(),\n            horizontalArrangement = Arrangement.SpaceBetween\n        ) {\n            Text(\n                text = logEntry.timestamp,\n                style = MaterialTheme.typography.labelSmall,\n                color = MaterialTheme.colorScheme.onSurfaceVariant\n            )\n            Text(\n                text = when(logEntry.priority) {\n                    \"I\" -> \"→\"\n                    \"S\" -> \"←\"\n                    else -> \"⚠\"\n                },\n                style = MaterialTheme.typography.labelSmall,\n                color = when(logEntry.priority) {\n                    \"I\" -> MaterialTheme.colorScheme.primary\n                    \"S\" -> MaterialTheme.colorScheme.secondary\n                    else -> MaterialTheme.colorScheme.error\n                }\n            )\n        }\n\n        Spacer(modifier = Modifier.height(4.dp))\n\n        Text(\n            text = logEntry.url,\n            style = MaterialTheme.typography.labelMedium,\n            color = MaterialTheme.colorScheme.onSurfaceVariant,\n            maxLines = 1,\n            overflow = TextOverflow.Ellipsis\n        )\n\n        Spacer(modifier = Modifier.height(2.dp))\n\n        Column {\n            Text(\n                text = logEntry.message,\n                style = MaterialTheme.typography.bodyMedium,\n                color = MaterialTheme.colorScheme.onSurfaceVariant,\n                maxLines = if (isExpanded) Int.MAX_VALUE else 3,\n                overflow = TextOverflow.Ellipsis,\n                onTextLayout = {\n                    textLayoutResult = it\n                    if (!isExpanded) {\n                        hasMoreContent = it.hasVisualOverflow\n                    }\n                },\n                modifier = Modifier.animateContentSize()\n            )\n\n            if (!isExpanded && hasMoreContent) {\n                Text(\n                    text = \"View more...\",\n                    style = MaterialTheme.typography.labelSmall,\n                    color = MaterialTheme.colorScheme.primary,\n                    modifier = Modifier\n                        .padding(top = 4.dp)\n                        .clickable { isExpanded = true }\n                )\n            } else if (isExpanded && hasMoreContent) {\n                Text(\n                    text = \"Show less\",\n                    style = MaterialTheme.typography.labelSmall,\n                    color = MaterialTheme.colorScheme.primary,\n                    modifier = Modifier\n                        .padding(top = 4.dp)\n                        .clickable { isExpanded = false }\n                )\n            }\n        }\n    }\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/logcat/NetworkLogViewModel.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.settings.logcat\n\nimport androidx.lifecycle.ViewModel\nimport com.desarrollodroide.network.retrofit.NetworkLoggerInterceptor\n\nclass NetworkLogViewModel(\n    private val logger: NetworkLoggerInterceptor,\n) : ViewModel() {\n\n    val logs = logger.logs\n\n    fun clearLogs() {\n        logger.clearLogs()\n    }\n\n    fun shareLogs() = logs.value.joinToString(\"\\n\") {\n            \"${it.timestamp} ${it.priority}/${it.url}: ${it.message}\"\n        }\n\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/theme/Color.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.theme\nimport androidx.compose.ui.graphics.Color\n\nval md_theme_light_primary = Color(0xFF006B5E)\nval md_theme_light_onPrimary = Color(0xFFFFFFFF)\nval md_theme_light_primaryContainer = Color(0xFF76F8E1)\nval md_theme_light_onPrimaryContainer = Color(0xFF00201B)\nval md_theme_light_secondary = Color(0xFF4A635E)\nval md_theme_light_onSecondary = Color(0xFFFFFFFF)\nval md_theme_light_secondaryContainer = Color(0xFFCDE8E1)\nval md_theme_light_onSecondaryContainer = Color(0xFF06201B)\nval md_theme_light_tertiary = Color(0xFF446279)\nval md_theme_light_onTertiary = Color(0xFFFFFFFF)\nval md_theme_light_tertiaryContainer = Color(0xFFCAE6FF)\nval md_theme_light_onTertiaryContainer = Color(0xFF001E30)\nval md_theme_light_error = Color(0xFFBA1A1A)\nval md_theme_light_errorContainer = Color(0xFFFFDAD6)\nval md_theme_light_onError = Color(0xFFFFFFFF)\nval md_theme_light_onErrorContainer = Color(0xFF410002)\nval md_theme_light_background = Color(0xFFFAFDFA)\nval md_theme_light_onBackground = Color(0xFF191C1B)\nval md_theme_light_surface = Color(0xFFFAFDFA)\nval md_theme_light_onSurface = Color(0xFF191C1B)\nval md_theme_light_surfaceVariant = Color(0xFFDAE5E1)\nval md_theme_light_onSurfaceVariant = Color(0xFF3F4946)\nval md_theme_light_outline = Color(0xFF6F7976)\nval md_theme_light_inverseOnSurface = Color(0xFFEFF1EF)\nval md_theme_light_inverseSurface = Color(0xFF2D3130)\nval md_theme_light_inversePrimary = Color(0xFF56DBC5)\nval md_theme_light_shadow = Color(0xFF000000)\nval md_theme_light_surfaceTint = Color(0xFF006B5E)\nval md_theme_light_outlineVariant = Color(0xFFBEC9C5)\nval md_theme_light_scrim = Color(0xFF000000)\n\nval md_theme_dark_primary = Color(0xFF56DBC5)\nval md_theme_dark_onPrimary = Color(0xFF003730)\nval md_theme_dark_primaryContainer = Color(0xFF005046)\nval md_theme_dark_onPrimaryContainer = Color(0xFF76F8E1)\nval md_theme_dark_secondary = Color(0xFFB1CCC5)\nval md_theme_dark_onSecondary = Color(0xFF1C3530)\nval md_theme_dark_secondaryContainer = Color(0xFF334B46)\nval md_theme_dark_onSecondaryContainer = Color(0xFFCDE8E1)\nval md_theme_dark_tertiary = Color(0xFFACCAE5)\nval md_theme_dark_onTertiary = Color(0xFF133348)\nval md_theme_dark_tertiaryContainer = Color(0xFF2C4A60)\nval md_theme_dark_onTertiaryContainer = Color(0xFFCAE6FF)\nval md_theme_dark_error = Color(0xFFFFB4AB)\nval md_theme_dark_errorContainer = Color(0xFF93000A)\nval md_theme_dark_onError = Color(0xFF690005)\nval md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)\nval md_theme_dark_background = Color(0xFF191C1B)\nval md_theme_dark_onBackground = Color(0xFFE0E3E1)\nval md_theme_dark_surface = Color(0xFF191C1B)\nval md_theme_dark_onSurface = Color(0xFFE0E3E1)\nval md_theme_dark_surfaceVariant = Color(0xFF3F4946)\nval md_theme_dark_onSurfaceVariant = Color(0xFFBEC9C5)\nval md_theme_dark_outline = Color(0xFF899390)\nval md_theme_dark_inverseOnSurface = Color(0xFF191C1B)\nval md_theme_dark_inverseSurface = Color(0xFFE0E3E1)\nval md_theme_dark_inversePrimary = Color(0xFF006B5E)\nval md_theme_dark_shadow = Color(0xFF000000)\nval md_theme_dark_surfaceTint = Color(0xFF56DBC5)\nval md_theme_dark_outlineVariant = Color(0xFF3F4946)\nval md_theme_dark_scrim = Color(0xFF000000)\n\n\nval seed = Color(0xFF00584D)\n\n\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/theme/Shape.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.theme\n\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material3.Shapes\nimport androidx.compose.ui.unit.dp\n\nval shapes = Shapes(\n    extraSmall = RoundedCornerShape(4.dp),\n    small = RoundedCornerShape(8.dp),\n    medium = RoundedCornerShape(16.dp),\n    large = RoundedCornerShape(24.dp),\n    extraLarge = RoundedCornerShape(32.dp)\n)\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/theme/Theme.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.theme\n\nimport android.app.Activity\nimport android.os.Build\nimport androidx.compose.foundation.isSystemInDarkTheme\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.SideEffect\nimport androidx.compose.ui.graphics.toArgb\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalView\nimport androidx.core.view.WindowCompat\n\nprivate val lightColors = lightColorScheme(\n    primary = md_theme_light_primary,\n    onPrimary = md_theme_light_onPrimary,\n    primaryContainer = md_theme_light_primaryContainer,\n    onPrimaryContainer = md_theme_light_onPrimaryContainer,\n    secondary = md_theme_light_secondary,\n    onSecondary = md_theme_light_onSecondary,\n    secondaryContainer = md_theme_light_secondaryContainer,\n    onSecondaryContainer = md_theme_light_onSecondaryContainer,\n    tertiary = md_theme_light_tertiary,\n    onTertiary = md_theme_light_onTertiary,\n    tertiaryContainer = md_theme_light_tertiaryContainer,\n    onTertiaryContainer = md_theme_light_onTertiaryContainer,\n    error = md_theme_light_error,\n    errorContainer = md_theme_light_errorContainer,\n    onError = md_theme_light_onError,\n    onErrorContainer = md_theme_light_onErrorContainer,\n    background = md_theme_light_background,\n    onBackground = md_theme_light_onBackground,\n    surface = md_theme_light_surface,\n    onSurface = md_theme_light_onSurface,\n    surfaceVariant = md_theme_light_surfaceVariant,\n    onSurfaceVariant = md_theme_light_onSurfaceVariant,\n    outline = md_theme_light_outline,\n    inverseOnSurface = md_theme_light_inverseOnSurface,\n    inverseSurface = md_theme_light_inverseSurface,\n    inversePrimary = md_theme_light_inversePrimary,\n    surfaceTint = md_theme_light_surfaceTint,\n    outlineVariant = md_theme_light_outlineVariant,\n    scrim = md_theme_light_scrim,\n)\n\n\nprivate val darkColors = darkColorScheme(\n    primary = md_theme_dark_primary,\n    onPrimary = md_theme_dark_onPrimary,\n    primaryContainer = md_theme_dark_primaryContainer,\n    onPrimaryContainer = md_theme_dark_onPrimaryContainer,\n    secondary = md_theme_dark_secondary,\n    onSecondary = md_theme_dark_onSecondary,\n    secondaryContainer = md_theme_dark_secondaryContainer,\n    onSecondaryContainer = md_theme_dark_onSecondaryContainer,\n    tertiary = md_theme_dark_tertiary,\n    onTertiary = md_theme_dark_onTertiary,\n    tertiaryContainer = md_theme_dark_tertiaryContainer,\n    onTertiaryContainer = md_theme_dark_onTertiaryContainer,\n    error = md_theme_dark_error,\n    errorContainer = md_theme_dark_errorContainer,\n    onError = md_theme_dark_onError,\n    onErrorContainer = md_theme_dark_onErrorContainer,\n    background = md_theme_dark_background,\n    onBackground = md_theme_dark_onBackground,\n    surface = md_theme_dark_surface,\n    onSurface = md_theme_dark_onSurface,\n    surfaceVariant = md_theme_dark_surfaceVariant,\n    onSurfaceVariant = md_theme_dark_onSurfaceVariant,\n    outline = md_theme_dark_outline,\n    inverseOnSurface = md_theme_dark_inverseOnSurface,\n    inverseSurface = md_theme_dark_inverseSurface,\n    inversePrimary = md_theme_dark_inversePrimary,\n    surfaceTint = md_theme_dark_surfaceTint,\n    outlineVariant = md_theme_dark_outlineVariant,\n    scrim = md_theme_dark_scrim,\n)\n\n@Composable\nfun ShioriTheme(\n    darkTheme: Boolean = isSystemInDarkTheme(),\n    dynamicColor: Boolean = true,\n    content: @Composable() () -> Unit\n) {\n    val colorScheme = when {\n        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {\n            val context = LocalContext.current\n            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)\n        }\n        darkTheme -> darkColors\n        else -> lightColors\n    }\n    val view = LocalView.current\n    if (!view.isInEditMode) {\n        SideEffect {\n            val window = (view.context as Activity).window\n            window.statusBarColor = colorScheme.background.toArgb()\n            WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme\n        }\n    }\n\n    MaterialTheme(\n        colorScheme = colorScheme,\n        typography = shioriTypography,\n        shapes = shapes,\n        content = content\n    )\n}"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/theme/Type.kt",
    "content": "package com.desarrollodroide.pagekeeper.ui.theme\n\nimport androidx.compose.material3.Typography\nimport androidx.compose.ui.text.TextStyle\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.sp\n\nval shioriTypography = Typography(\n    headlineLarge = TextStyle(\n        fontWeight = FontWeight.SemiBold,\n        fontSize = 32.sp,\n        lineHeight = 40.sp,\n        letterSpacing = 0.sp\n    ),\n    headlineMedium = TextStyle(\n        fontWeight = FontWeight.SemiBold,\n        fontSize = 28.sp,\n        lineHeight = 36.sp,\n        letterSpacing = 0.sp\n    ),\n    headlineSmall = TextStyle(\n        fontWeight = FontWeight.SemiBold,\n        fontSize = 24.sp,\n        lineHeight = 32.sp,\n        letterSpacing = 0.sp\n    ),\n    titleLarge = TextStyle(\n        fontWeight = FontWeight.SemiBold,\n        fontSize = 22.sp,\n        lineHeight = 28.sp,\n        letterSpacing = 0.sp\n    ),\n    titleMedium = TextStyle(\n        fontWeight = FontWeight.SemiBold,\n        fontSize = 16.sp,\n        lineHeight = 24.sp,\n        letterSpacing = 0.15.sp\n    ),\n    titleSmall = TextStyle(\n        fontWeight = FontWeight.Bold,\n        fontSize = 14.sp,\n        lineHeight = 20.sp,\n        letterSpacing = 0.1.sp\n    ),\n    bodyLarge = TextStyle(\n        fontWeight = FontWeight.Normal,\n        fontSize = 16.sp,\n        lineHeight = 24.sp,\n        letterSpacing = 0.15.sp\n    ),\n    bodyMedium = TextStyle(\n        fontWeight = FontWeight.Medium,\n        fontSize = 14.sp,\n        lineHeight = 20.sp,\n        letterSpacing = 0.25.sp\n    ),\n    bodySmall = TextStyle(\n        fontWeight = FontWeight.Bold,\n        fontSize = 12.sp,\n        lineHeight = 16.sp,\n        letterSpacing = 0.4.sp\n    ),\n    labelLarge = TextStyle(\n        fontWeight = FontWeight.SemiBold,\n        fontSize = 14.sp,\n        lineHeight = 20.sp,\n        letterSpacing = 0.1.sp\n    ),\n    labelMedium = TextStyle(\n        fontWeight = FontWeight.SemiBold,\n        fontSize = 12.sp,\n        lineHeight = 16.sp,\n        letterSpacing = 0.5.sp\n    ),\n    labelSmall = TextStyle(\n        fontWeight = FontWeight.SemiBold,\n        fontSize = 11.sp,\n        lineHeight = 16.sp,\n        letterSpacing = 0.5.sp\n    )\n)\n"
  },
  {
    "path": "presentation/src/main/res/drawable/curved_wave_bottom.xml",
    "content": "<vector android:height=\"99.55556dp\" android:viewportHeight=\"560\"\n    android:viewportWidth=\"1440\" android:width=\"256dp\" xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <path android:fillColor=\"#000000\" android:pathData=\"m0,10c144,56.2 432,253.8 720,281 288,27.2 576,-116 720,-145v414h-1440z\"/>\n</vector>\n"
  },
  {
    "path": "presentation/src/main/res/drawable/curved_wave_top.xml",
    "content": "<vector android:height=\"99.55556dp\" android:viewportHeight=\"560\"\n    android:viewportWidth=\"1440\" android:width=\"256dp\" xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <path android:fillColor=\"#000000\" android:pathData=\"m1440,1c-144,99 -432,458.2 -720,495 -288,36.8 -576,-248.8 -720,-311v-185h1440z\"/>\n</vector>\n"
  },
  {
    "path": "presentation/src/main/res/drawable/ic_book.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n    android:viewportWidth=\"960\"\n    android:viewportHeight=\"960\"\n    android:tint=\"?attr/colorControlNormal\">\n  <path\n      android:fillColor=\"@android:color/white\"\n      android:pathData=\"M240,880Q190,880 155,845Q120,810 120,760L120,200Q120,150 155,115Q190,80 240,80L680,80L680,720L240,720Q223,720 211.5,731.5Q200,743 200,760Q200,777 211.5,788.5Q223,800 240,800L760,800L760,160L840,160L840,880L240,880ZM360,640L600,640L600,160L360,160L360,640ZM280,640L280,160L240,160Q223,160 211.5,171.5Q200,183 200,200L200,647Q210,644 219.5,642Q229,640 240,640L280,640ZM200,160L200,160Q200,160 200,160Q200,160 200,160L200,647Q200,647 200,647Q200,647 200,647L200,647L200,160Z\"/>\n</vector>\n"
  },
  {
    "path": "presentation/src/main/res/drawable/ic_empty_list.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"512dp\"\n    android:height=\"512dp\"\n    android:viewportWidth=\"512\"\n    android:viewportHeight=\"512\">\n  <path\n      android:pathData=\"M98.5,63.6c-51.7,28.3 -94.8,52.3 -95.8,53.3 -1,1 -2.1,3.7 -2.4,6l-0.6,4.2 28.6,28.7 28.7,28.7 -28.1,28.1c-25.7,25.7 -28.1,28.4 -28.6,32 -0.4,3.3 -0.1,4.6 1.8,6.8 1.3,1.4 15,9.6 30.4,18.1l28,15.3 0.5,53.6 0.5,53.6 2.5,2.3c1.4,1.4 44.5,25.4 95.7,53.6 65,35.6 94.2,51.1 96.3,51.1 2.1,-0 31.3,-15.5 96.3,-51.1 51.2,-28.2 94.3,-52.2 95.7,-53.6l2.5,-2.3 0.5,-53.6 0.5,-53.6 28,-15.3c15.4,-8.5 29,-16.6 30.3,-18 2.5,-3 2.8,-6.4 0.8,-10.3 -0.8,-1.5 -13.6,-14.8 -28.5,-29.7l-27.1,-27 28.6,-28.7 28.7,-28.7 -0.6,-4.2c-0.3,-2.3 -1.4,-5 -2.4,-6 -3.4,-3.3 -67.7,-38.1 -71.4,-38.6 -3,-0.4 -4.2,-0 -6.8,2.3 -4.4,4.1 -5.1,9 -1.8,13 1.5,1.7 13.3,8.8 29.1,17.4 14.6,8 26.6,14.7 26.6,15 0,0.3 -10.3,10.8 -22.9,23.4l-22.9,22.9 -82.3,-45.2c-45.3,-24.9 -82.7,-45.6 -83.1,-46 -0.4,-0.3 9.6,-11 22.2,-23.6l23.1,-23 6.2,3.4c46.9,25.9 46.3,25.6 51.8,20.2 3.8,-3.9 4.1,-10.1 0.5,-13.4 -3.8,-3.6 -57.9,-32.7 -60.7,-32.7 -1.3,-0 -3.6,0.7 -4.9,1.6 -1.4,0.9 -14.5,13.6 -29.2,28.3l-26.8,26.6 -26.7,-26.6c-14.8,-14.7 -27.9,-27.4 -29.3,-28.3 -1.3,-0.9 -3.6,-1.6 -5,-1.6 -1.5,-0 -41.3,21.3 -96.5,51.6zM216.4,57.9l22.8,22.8 -4.4,2.5c-2.3,1.4 -39.8,22 -83.2,45.8l-78.8,43.3 -23.2,-23.3c-12.7,-12.8 -22.6,-23.4 -22.1,-23.6 0.6,-0.3 37.7,-20.7 82.5,-45.4 44.8,-24.7 82,-44.9 82.5,-44.9 0.6,-0.1 11.3,10.2 23.9,22.8zM338.3,139.6c44.6,24.6 81.3,44.7 81.5,44.9 0.5,0.4 -162.5,89.5 -163.8,89.5 -0.7,-0 -38,-20.1 -82.8,-44.7l-81.4,-44.7 3.4,-1.7c1.8,-1 38.4,-21.2 81.3,-44.8 42.9,-23.6 78.6,-43 79.3,-43 0.6,-0.1 37.8,20 82.5,44.5zM156,242.7l83.2,45.6 -22.9,22.9c-12.5,12.5 -23.2,22.8 -23.7,22.8 -0.8,-0 -160,-87 -164.6,-90 -1.2,-0.8 3.2,-5.6 21.5,-24 12.6,-12.6 23.1,-23 23.2,-23 0.1,-0 37.6,20.5 83.3,45.7zM462.5,220c12.6,12.6 22.7,23.1 22.4,23.4 -1,1 -163.2,89.9 -164.8,90.3 -1,0.2 -9.5,-7.5 -24.4,-22.5l-22.9,-22.9 82.9,-45.6c45.5,-25 83,-45.6 83.3,-45.6 0.3,-0.1 10.8,10.2 23.5,22.9zM139.7,328.4c30.7,16.8 53.3,28.6 54.8,28.6 1.5,-0 3.6,-0.5 4.8,-1.1 1.2,-0.6 14.5,-13.3 29.5,-28.2l27.2,-27.2 27.3,27.2c14.9,14.9 28.2,27.6 29.4,28.2 1.2,0.6 3.3,1.1 4.8,1.1 2,-0 93.7,-49.2 110.3,-59.2l3.2,-2 0,43 0,43 -81.7,44.8c-44.9,24.7 -82,45 -82.5,45.2 -0.4,0.2 -0.9,-23.5 -1,-52.7l-0.3,-53.1 -2.8,-2.7c-2,-2.1 -3.7,-2.8 -6.7,-2.8 -3,-0 -4.7,0.7 -6.7,2.8l-2.8,2.7 -0.3,53.1c-0.1,29.2 -0.6,52.9 -1,52.7 -0.5,-0.2 -37.6,-20.5 -82.5,-45.2l-81.7,-44.8 0,-43 0,-43 3.3,2c1.7,1.1 26.7,14.8 55.4,30.6z\"\n      android:fillColor=\"#000000\"\n      android:strokeColor=\"#00000000\"/>\n  <path\n      android:pathData=\"M251.4,328.1c-5.7,2.8 -6.7,11.4 -1.8,16 5.4,5.1 14.6,1.8 16,-5.7 1.4,-7.7 -7,-13.8 -14.2,-10.3z\"\n      android:fillColor=\"#000000\"\n      android:strokeColor=\"#00000000\"/>\n  <path\n      android:pathData=\"M400.4,61.6c-3.5,1.7 -5.4,5 -5.4,9.4 0,4.1 5.1,9 9.5,9 4,-0 9,-3.8 9.9,-7.5 1,-4 -1.8,-9.4 -5.8,-11 -4.3,-1.8 -4.4,-1.8 -8.2,0.1z\"\n      android:fillColor=\"#000000\"\n      android:strokeColor=\"#00000000\"/>\n</vector>\n"
  },
  {
    "path": "presentation/src/main/res/drawable/img_authentication_failed.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"1024dp\"\n    android:height=\"1024dp\"\n    android:viewportWidth=\"1024\"\n    android:viewportHeight=\"1024\">\n  <path\n      android:fillColor=\"#FF000000\"\n      android:pathData=\"M512,992a29.1,29.1 0,0 1,-7.9 -0.7c-59,-13.1 -154.7,-56.9 -236.7,-124.7C166,783.5 112,686.5 112,586.6v-419.2a35.9,35.9 0,0 1,36 -36.4,695.5 695.5,0 0,0 348.2,-94 35.3,35.3 0,0 1,36 0,687.1 687.1,0 0,0 343.9,94A35.9,35.9 0,0 1,912 167.4v419.2c0,99.9 -53.2,196.8 -153.2,280a35.9,35.9 0,0 1,-50.4 -5.1,37 37,0 0,1 5,-51c58.3,-48.1 127.3,-125.4 127.3,-223.1V203.2A761,761 0,0 1,514.9 110.6a770,770 0,0 1,-330.2 93.3v384.2c0,180.1 240.3,311.3 336,333.2a36.7,36.7 0,0 1,27.3 43.7A38.1,38.1 0,0 1,512 992z\"/>\n  <path\n      android:fillColor=\"#FF000000\"\n      android:pathData=\"M448,726.1a58,58 0,1 0,116 0,58 58,0 1,0 -116,0Z\"/>\n  <path\n      android:fillColor=\"#FF000000\"\n      android:pathData=\"M506,595.2a38.8,38.8 0,0 1,-39.2 -39.2v-276.8a39.2,39.2 0,0 1,78.4 0v276.8a39.3,39.3 0,0 1,-39.2 39.2z\"/>\n</vector>\n"
  },
  {
    "path": "presentation/src/main/res/mipmap-anydpi-v26/ic_launcher.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n  <background android:drawable=\"@mipmap/ic_launcher_adaptive_back\"/>\n  <foreground android:drawable=\"@mipmap/ic_launcher_adaptive_fore\"/>\n</adaptive-icon>"
  },
  {
    "path": "presentation/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n  <background android:drawable=\"@mipmap/ic_launcher_round_adaptive_back\"/>\n  <foreground android:drawable=\"@mipmap/ic_launcher_round_adaptive_fore\"/>\n</adaptive-icon>"
  },
  {
    "path": "presentation/src/main/res/values/dimens.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n\n    // Progress Dialog\n    <dimen name=\"progressDialog_size\">70dp</dimen>\n    <dimen name=\"progressDialog_margin\">20dp</dimen>\n    <dimen name=\"progressDialog_stroke\">6dp</dimen>\n\n</resources>"
  },
  {
    "path": "presentation/src/main/res/values/strings.xml",
    "content": "<resources>\n    <string name=\"app_name\">Shiori</string>\n</resources>"
  },
  {
    "path": "presentation/src/main/res/values/themes.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n\n    <style name=\"Theme.Shiori\" parent=\"android:Theme.Material.Light.NoActionBar\"/>\n\n</resources>"
  },
  {
    "path": "presentation/src/main/res/values-large/dimens.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n\n    //Progress Dialog\n    <dimen name=\"progressDialog_size\">100dp</dimen>\n    <dimen name=\"progressDialog_margin\">25dp</dimen>\n    <dimen name=\"progressDialog_stroke\">8dp</dimen>\n\n</resources>"
  },
  {
    "path": "presentation/src/main/res/xml/data_extraction_rules.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?><!--\n   Sample data extraction rules file; uncomment and customize as necessary.\n   See https://developer.android.com/about/versions/12/backup-restore#xml-changes\n   for details.\n-->\n<data-extraction-rules>\n    <cloud-backup>\n        <!-- TODO: Use <include> and <exclude> to control what is backed up.\n        <include .../>\n        <exclude .../>\n        -->\n    </cloud-backup>\n    <!--\n    <device-transfer>\n        <include .../>\n        <exclude .../>\n    </device-transfer>\n    -->\n</data-extraction-rules>"
  },
  {
    "path": "presentation/src/main/res/xml/file_paths.xml",
    "content": "<paths xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <external-path name=\"external_files\" path=\".\"/>\n</paths>"
  },
  {
    "path": "presentation/src/test/java/com/desarrollodroide/pagekeeper/extensions/StringExtensionsKtTest.kt",
    "content": "package com.desarrollodroide.pagekeeper.extensions\n\nimport org.junit.jupiter.api.Assertions.*\nimport org.junit.jupiter.api.Test\n\nclass StringExtensionsKtTest {\n    @Test\n    fun `isRTLText should return true for Arabic text`() {\n        val arabicText = \"هذا نص عربي يحتوي على أكثر من مئة حرف عربي. هذا نص عربي يحتوي على أكثر من مئة حرف عربي.\"\n        assertTrue(arabicText.isRTLText())\n    }\n\n    @Test\n    fun `isRTLText should return false for non-Arabic text`() {\n        val nonArabicText = \"This is a long enough text with more than one hundred characters to test non-Arabic text. This is a long enough text with more than one hundred characters to test non-Arabic text.\"\n        assertFalse(nonArabicText.isRTLText())\n    }\n\n    @Test\n    fun `isRTLText should return true for mixed text with more than half Arabic characters`() {\n        val mixedText = \"هذا نص عربي مع المزيد من النص العربي لضمان أن يحتوي على أكثر من مئة حرف. Some English text included.\"\n        assertTrue(mixedText.isRTLText())\n    }\n\n\n    @Test\n    fun `isRTLText should return false for mixed text with less than half Arabic characters`() {\n        val mixedText = \"This is some English text mixed with عربي قليل to ensure the length is more than one hundred characters.\"\n        assertFalse(mixedText.isRTLText())\n    }\n\n    @Test\n    fun `isRTLText should work with empty string`() {\n        val emptyString = \"\"\n        assertFalse(emptyString.isRTLText())\n    }\n}\n"
  },
  {
    "path": "settings.gradle",
    "content": "pluginManagement {\n    repositories {\n        gradlePluginPortal()\n        google()\n        mavenCentral()\n    }\n}\ndependencyResolutionManagement {\n    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)\n    repositories {\n        google()\n        mavenCentral()\n    }\n}\nrootProject.name = \"Shiori\"\ninclude ':presentation'\ninclude ':data'\ninclude ':domain'\ninclude ':network'\ninclude ':model'\ninclude ':common'\n"
  }
]