diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..6be9a3e6 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: ethran # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: rethran +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 98493af0..9fc952ed 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -1,8 +1,10 @@ name: Build and Release Preview - on: push: - branches: ["main"] + branches: ["dev"] + paths: + - 'app/**' + workflow_dispatch: env: MAVEN_OPTS: >- @@ -23,7 +25,7 @@ jobs: - uses: gradle/gradle-build-action@v2 with: - gradle-version: 7.5 + gradle-version: 8.5 - name: Decode Keystore id: decode_keystore @@ -33,15 +35,28 @@ jobs: fileName: "my.keystore" encodedString: ${{ secrets.KEYSTORE_FILE }} + - name: Remove `google-services.json` + run: rm ${{ github.workspace }}/app/google-services.json + + - name: Decode and Replace `google-services.json` + env: + FIREBASE_CONFIG: ${{ secrets.FIREBASE_CONFIG }} + run: | + echo $FIREBASE_CONFIG | base64 --decode > ${{ github.workspace }}/app/google-services.json + wc -l ${{ github.workspace }}/app/google-services.json + - name: Execute Gradle build run: | + export STORE_FILE="../${{ steps.decode_keystore.outputs.filePath }}" + export STORE_PASSWORD="${{ secrets.KEYSTORE_PASSWORD }}" + export KEY_ALIAS="${{ secrets.KEY_ALIAS }}" + export KEY_PASSWORD="${{ secrets.KEY_PASSWORD }}" + export SHIPBOOK_APP_ID="${{ secrets.SHIPBOOK_APP_ID }}" + export SHIPBOOK_APP_KEY="${{ secrets.SHIPBOOK_APP_KEY }}" + ./gradlew \ - -PDEBUG_STORE_FILE="../${{ steps.decode_keystore.outputs.filePath }}" \ - -PDEBUG_STORE_PASSWORD=${{ secrets.KEYSTORE_PASSWORD }} \ - -PDEBUG_KEY_ALIAS=${{ secrets.KEY_ALIAS }} \ - -PDEBUG_KEY_PASSWORD=${{ secrets.KEY_PASSWORD }} \ - -PIS_NEXT=true \ - assembleDebug + -PIS_NEXT=true \ + assembleDebug # - name: Cache Gradle packages # uses: actions/cache@v1 @@ -59,5 +74,5 @@ jobs: tag_name: next name: next prerelease: true - body: "Preview version corresponding to the latest build on main" + body: "Preview version built from branch: ${{ github.ref_name }}" token: ${{ secrets.TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d85230a0..e0b5f539 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,7 @@ name: Build and Release Version on: - push: - branches: ["release"] + workflow_dispatch: env: MAVEN_OPTS: >- @@ -23,7 +22,7 @@ jobs: - uses: gradle/gradle-build-action@v2 with: - gradle-version: 7.5 + gradle-version: 8.5 - name: Decode Keystore id: decode_keystore @@ -33,14 +32,28 @@ jobs: fileName: "my.keystore" encodedString: ${{ secrets.KEYSTORE_FILE }} + - name: Decode and Replace `google-services.json` + env: + FIREBASE_CONFIG: ${{ secrets.FIREBASE_CONFIG }} + run: | + echo $FIREBASE_CONFIG | base64 --decode > ${{ github.workspace }}/app/google-services.json + - name: Execute Gradle build + env: + STORE_FILE: ${{ github.workspace }}/secrets/my.keystore + STORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} + SHIPBOOK_APP_ID: ${{ secrets.SHIPBOOK_APP_ID }} + SHIPBOOK_APP_KEY: ${{ secrets.SHIPBOOK_APP_KEY }} run: | - ./gradlew \ - -PDEBUG_STORE_FILE="../${{ steps.decode_keystore.outputs.filePath }}" \ - -PDEBUG_STORE_PASSWORD=${{ secrets.KEYSTORE_PASSWORD }} \ - -PDEBUG_KEY_ALIAS=${{ secrets.KEY_ALIAS }} \ - -PDEBUG_KEY_PASSWORD=${{ secrets.KEY_PASSWORD }} \ - assembleDebug + ./gradlew assembleRelease \ + -PSTORE_FILE="$STORE_FILE" \ + -PSTORE_PASSWORD="$STORE_PASSWORD" \ + -PKEY_ALIAS="$KEY_ALIAS" \ + -PKEY_PASSWORD="$KEY_PASSWORD" + + # - name: Cache Gradle packages # uses: actions/cache@v1 @@ -49,25 +62,30 @@ jobs: # key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} # restore-keys: ${{ runner.os }}-gradle + - name: Verify APK Files + run: find ${{ github.workspace }}/app/build/outputs/apk/ -name "*.apk" + - name: Retrieve Version + env: + STORE_FILE: ${{ github.workspace }}/secrets/my.keystore + STORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} + SHIPBOOK_APP_ID: ${{ secrets.SHIPBOOK_APP_ID }} + SHIPBOOK_APP_KEY: ${{ secrets.SHIPBOOK_APP_KEY }} run: | - echo "::set-output name=VERSION_NAME::$(${{github.workspace}}/gradlew \ - -PDEBUG_STORE_FILE="../${{ steps.decode_keystore.outputs.filePath }}" \ - -PDEBUG_STORE_PASSWORD=${{ secrets.KEYSTORE_PASSWORD }} \ - -PDEBUG_KEY_ALIAS=${{ secrets.KEY_ALIAS }} \ - -PDEBUG_KEY_PASSWORD=${{ secrets.KEY_PASSWORD }} \ - -q printVersionName)" + ./gradlew -q printVersionName + VERSION_NAME=$(./gradlew -q printVersionName) + echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV id: android_version - - name: Get version - run: | - echo "version_name=${{steps.android_version.outputs.VERSION_NAME}}" >> $GITHUB_ENV - - run: mv ${{ github.workspace }}/app/build/outputs/apk/debug/app-debug.apk ${{ github.workspace }}/app/build/outputs/apk/debug/notable-${{ env.version_name }}.apk + - name: Rename APK + run: mv ${{ github.workspace }}/app/build/outputs/apk/release/app-release.apk ${{ github.workspace }}/app/build/outputs/apk/release/notable-${{ env.VERSION_NAME }}.apk - name: Release uses: softprops/action-gh-release@v1 with: - files: ${{ github.workspace }}/app/build/outputs/apk/debug/notable-${{ env.version_name }}.apk - tag_name: v${{env.version_name}} + files: ${{ github.workspace }}/app/build/outputs/apk/release/notable-${{ env.VERSION_NAME }}.apk + tag_name: v${{env.VERSION_NAME}} token: ${{ secrets.TOKEN }} diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 00000000..4a53bee8 --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml index fb7f4a8a..b86273d9 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 00000000..b268ef36 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index a2d7c213..639c779c 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -4,9 +4,9 @@ - - + + diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 103e00cb..00000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 00000000..c22b6fa9 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 00000000..f8051a6f --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index b1f8730f..74dd639e 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,7 +1,7 @@ - + diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 00000000..16660f1d --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml new file mode 100644 index 00000000..539e3b80 --- /dev/null +++ b/.idea/studiobot.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index e5dc8fb5..28ef10ee 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,18 +5,25 @@ plugins { id 'kotlinx-serialization' id 'kotlin-parcelize' id 'com.google.gms.google-services' + id 'org.jetbrains.kotlin.plugin.compose' + + id 'org.jetbrains.kotlin.plugin.serialization' } android { - compileSdk 33 + compileSdk 35 defaultConfig { - applicationId "com.olup.notable" + applicationId "com.ethran.notable" minSdk 29 - targetSdk 33 + targetSdk 35 - versionCode 10 - versionName "0.0.10" + versionCode 14 + versionName '0.0.14' + if (project.hasProperty('IS_NEXT') && project.IS_NEXT.toBoolean()) { + def timestamp = new Date().format('dd.MM.YYYY-HH:mm') + versionName = "${versionName}-next-${timestamp}" + } testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -30,15 +37,32 @@ android { signingConfigs { debug { - storeFile file(DEBUG_STORE_FILE) - storePassword DEBUG_STORE_PASSWORD - keyAlias DEBUG_KEY_ALIAS - keyPassword DEBUG_KEY_PASSWORD - - // Optional, specify signing versions used - v1SigningEnabled true - v2SigningEnabled true + if (System.getenv("STORE_FILE") != null) { + storeFile file(System.getenv("STORE_FILE")) + storePassword System.getenv("STORE_PASSWORD") + keyAlias System.getenv("KEY_ALIAS") + keyPassword System.getenv("KEY_PASSWORD") + + v1SigningEnabled true + v2SigningEnabled true + } else { + println "Running locally, skipping release signing..." + } } + release { + if (System.getenv("STORE_FILE") != null) { + storeFile file(System.getenv("STORE_FILE")) + storePassword System.getenv("STORE_PASSWORD") + keyAlias System.getenv("KEY_ALIAS") + keyPassword System.getenv("KEY_PASSWORD") + + v1SigningEnabled true + v2SigningEnabled true + } else { + println "Running locally, skipping release signing..." + } + } + } @@ -46,58 +70,76 @@ android { debug { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - signingConfig signingConfigs.debug + // signingConfig signingConfigs.debug buildConfigField("boolean", "IS_NEXT", IS_NEXT) + buildConfigField "String", "SHIPBOOK_APP_ID", "\"${System.getenv("SHIPBOOK_APP_ID") ?: "default-secret"}\"" + buildConfigField "String", "SHIPBOOK_APP_KEY", "\"${System.getenv("SHIPBOOK_APP_KEY") ?: "default-secret2"}\"" + if (System.getenv("STORE_FILE") != null) { + signingConfig signingConfigs.release + } } release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + buildConfigField("boolean", "IS_NEXT", IS_NEXT) + buildConfigField "String", "SHIPBOOK_APP_ID", "\"${System.getenv("SHIPBOOK_APP_ID") ?: "default-secret"}\"" + buildConfigField "String", "SHIPBOOK_APP_KEY", "\"${System.getenv("SHIPBOOK_APP_KEY") ?: "default-secret2"}\"" + if (System.getenv("STORE_FILE") != null) { + signingConfig signingConfigs.release + } } } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = '17' } buildFeatures { compose true + buildConfig true } composeOptions { kotlinCompilerExtensionVersion compose_version } packagingOptions { - pickFirst '**/*.so' resources { excludes += '/META-INF/{AL2.0,LGPL2.1}' } + jniLibs { + pickFirsts += ['**/*.so'] + } } - namespace 'com.olup.notable' + namespace 'com.ethran.notable' } dependencies { - implementation 'androidx.core:core-ktx:1.7.0' + implementation 'androidx.core:core-ktx:1.15.0' implementation "androidx.compose.ui:ui:$compose_version" implementation "androidx.compose.material:material:$compose_version" implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" - implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' - implementation 'androidx.activity:activity-compose:1.3.1' + implementation "androidx.compose.material:material-icons-extended:$compose_version" + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.7' + implementation 'androidx.activity:activity-compose:1.10.1' + implementation 'androidx.fragment:fragment-ktx:1.8.6' //implementation fileTree(dir: 'libs', include: ['*.aar']) - implementation('com.onyx.android.sdk:onyxsdk-device:1.2.26') { - exclude group: 'com.android.support', module: 'support-compat' + implementation('com.onyx.android.sdk:onyxsdk-device:1.2.32') { + exclude group: 'com.android.support', module: 'support-compat' } - implementation('com.onyx.android.sdk:onyxsdk-pen:1.4.8') { + implementation('com.onyx.android.sdk:onyxsdk-pen:1.4.12') { exclude group: 'com.android.support', module: 'support-compat' exclude group: 'com.android.support', module: 'appcompat-v7' } - implementation('com.onyx.android.sdk:onyxsdk-base:1.6.42') { + implementation('com.onyx.android.sdk:onyxsdk-base:1.7.8') { exclude group: 'com.android.support', module: 'support-compat' exclude group: 'com.android.support', module: 'appcompat-v7' } + + // Temporary (?) fix for https://github.com/gaborauth/toolsboox-android/issues/305 implementation("org.lsposed.hiddenapibypass:hiddenapibypass:4.3") // required by onyx sdk // used in RawInputManager. @@ -105,18 +147,18 @@ dependencies { implementation group: 'io.reactivex.rxjava2', name: 'rxandroid', version: '2.1.1' testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" debugImplementation "androidx.compose.ui:ui-tooling:$compose_version" debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" implementation "androidx.compose.runtime:runtime-livedata:$compose_version" implementation "androidx.compose.runtime:runtime:$compose_version" - implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0' + implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0' - implementation "androidx.navigation:navigation-compose:2.4.2" + implementation "androidx.navigation:navigation-compose:2.8.9" - def room_version = "2.5.0" + def room_version = "2.6.1" implementation "androidx.room:room-runtime:$room_version" implementation "androidx.room:room-ktx:$room_version" @@ -125,21 +167,23 @@ dependencies { implementation "io.coil-kt:coil-compose:2.2.2" - implementation "com.google.accompanist:accompanist-navigation-animation:0.29.1-alpha" implementation 'com.aventrix.jnanoid:jnanoid:2.0.0' - implementation platform('com.google.firebase:firebase-bom:31.2.3') + implementation platform('com.google.firebase:firebase-bom:33.11.0') implementation 'com.google.firebase:firebase-analytics-ktx' implementation 'br.com.devsrsouza.compose.icons.android:feather:1.0.0' implementation "com.beust:klaxon:5.5" + //noinspection GradleDynamicVersion implementation 'io.shipbook:shipbooksdk:1.+' - + // For xopp file format + implementation("org.apache.commons:commons-compress:1.27.1") // GZip support + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0" } -task printVersionName { +tasks.register('printVersionName') { println android.defaultConfig.versionName } diff --git a/app/google-services.json b/app/google-services.json index a3af6924..7510a9a9 100644 --- a/app/google-services.json +++ b/app/google-services.json @@ -9,7 +9,7 @@ "client_info": { "mobilesdk_app_id": "1:634526780078:android:ba68346a8716b90e74bb3f", "android_client_info": { - "package_name": "com.olup.notable" + "package_name": "com.ethran.notable" } }, "oauth_client": [ diff --git a/app/gradle.properties b/app/gradle.properties index 57eb1fc2..848c6cc5 100644 --- a/app/gradle.properties +++ b/app/gradle.properties @@ -1,4 +1,4 @@ -DEBUG_STORE_FILE=/Users/louptopalian/.android/debug.keystore +DEBUG_STORE_FILE=/home/wiktor/.android/debug.keystore DEBUG_STORE_PASSWORD=android DEBUG_KEY_ALIAS=androiddebugkey DEBUG_KEY_PASSWORD=android diff --git a/app/schemas/com.olup.notable.db.AppDatabase/18.json b/app/schemas/com.ethran.notable.db.AppDatabase/18.json similarity index 100% rename from app/schemas/com.olup.notable.db.AppDatabase/18.json rename to app/schemas/com.ethran.notable.db.AppDatabase/18.json diff --git a/app/schemas/com.olup.notable.db.AppDatabase/19.json b/app/schemas/com.ethran.notable.db.AppDatabase/19.json similarity index 100% rename from app/schemas/com.olup.notable.db.AppDatabase/19.json rename to app/schemas/com.ethran.notable.db.AppDatabase/19.json diff --git a/app/schemas/com.olup.notable.db.AppDatabase/20.json b/app/schemas/com.ethran.notable.db.AppDatabase/20.json similarity index 100% rename from app/schemas/com.olup.notable.db.AppDatabase/20.json rename to app/schemas/com.ethran.notable.db.AppDatabase/20.json diff --git a/app/schemas/com.olup.notable.db.AppDatabase/21.json b/app/schemas/com.ethran.notable.db.AppDatabase/21.json similarity index 100% rename from app/schemas/com.olup.notable.db.AppDatabase/21.json rename to app/schemas/com.ethran.notable.db.AppDatabase/21.json diff --git a/app/schemas/com.olup.notable.db.AppDatabase/22.json b/app/schemas/com.ethran.notable.db.AppDatabase/22.json similarity index 100% rename from app/schemas/com.olup.notable.db.AppDatabase/22.json rename to app/schemas/com.ethran.notable.db.AppDatabase/22.json diff --git a/app/schemas/com.olup.notable.db.AppDatabase/23.json b/app/schemas/com.ethran.notable.db.AppDatabase/23.json similarity index 100% rename from app/schemas/com.olup.notable.db.AppDatabase/23.json rename to app/schemas/com.ethran.notable.db.AppDatabase/23.json diff --git a/app/schemas/com.olup.notable.db.AppDatabase/24.json b/app/schemas/com.ethran.notable.db.AppDatabase/24.json similarity index 100% rename from app/schemas/com.olup.notable.db.AppDatabase/24.json rename to app/schemas/com.ethran.notable.db.AppDatabase/24.json diff --git a/app/schemas/com.olup.notable.db.AppDatabase/25.json b/app/schemas/com.ethran.notable.db.AppDatabase/25.json similarity index 100% rename from app/schemas/com.olup.notable.db.AppDatabase/25.json rename to app/schemas/com.ethran.notable.db.AppDatabase/25.json diff --git a/app/schemas/com.olup.notable.db.AppDatabase/26.json b/app/schemas/com.ethran.notable.db.AppDatabase/26.json similarity index 100% rename from app/schemas/com.olup.notable.db.AppDatabase/26.json rename to app/schemas/com.ethran.notable.db.AppDatabase/26.json diff --git a/app/schemas/com.olup.notable.db.AppDatabase/27.json b/app/schemas/com.ethran.notable.db.AppDatabase/27.json similarity index 100% rename from app/schemas/com.olup.notable.db.AppDatabase/27.json rename to app/schemas/com.ethran.notable.db.AppDatabase/27.json diff --git a/app/schemas/com.olup.notable.db.AppDatabase/28.json b/app/schemas/com.ethran.notable.db.AppDatabase/28.json similarity index 94% rename from app/schemas/com.olup.notable.db.AppDatabase/28.json rename to app/schemas/com.ethran.notable.db.AppDatabase/28.json index 9c3bee36..15f2f097 100644 --- a/app/schemas/com.olup.notable.db.AppDatabase/28.json +++ b/app/schemas/com.ethran.notable.db.AppDatabase/28.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 28, - "identityHash": "8075c4660d325bb60532e94fcc75ea1a", + "identityHash": "4fa832c706cf63132d83d26f9f00845e", "entities": [ { "tableName": "Folder", @@ -256,7 +256,7 @@ }, { "tableName": "Stroke", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `size` REAL NOT NULL, `pen` TEXT NOT NULL, `top` REAL NOT NULL, `bottom` REAL NOT NULL, `left` REAL NOT NULL, `right` REAL NOT NULL, `points` TEXT NOT NULL, `pageId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`pageId`) REFERENCES `Page`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `size` REAL NOT NULL, `pen` TEXT NOT NULL, `color` INTEGER NOT NULL DEFAULT 0xFF000000, `top` REAL NOT NULL, `bottom` REAL NOT NULL, `left` REAL NOT NULL, `right` REAL NOT NULL, `points` TEXT NOT NULL, `pageId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`pageId`) REFERENCES `Page`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", @@ -276,6 +276,13 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0xFF000000" + }, { "fieldPath": "top", "columnName": "top", @@ -386,7 +393,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8075c4660d325bb60532e94fcc75ea1a')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4fa832c706cf63132d83d26f9f00845e')" ] } } \ No newline at end of file diff --git a/app/schemas/com.ethran.notable.db.AppDatabase/29.json b/app/schemas/com.ethran.notable.db.AppDatabase/29.json new file mode 100644 index 00000000..cb3b3dc1 --- /dev/null +++ b/app/schemas/com.ethran.notable.db.AppDatabase/29.json @@ -0,0 +1,399 @@ +{ + "formatVersion": 1, + "database": { + "version": 29, + "identityHash": "4fa832c706cf63132d83d26f9f00845e", + "entities": [ + { + "tableName": "Folder", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `parentFolderId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`parentFolderId`) REFERENCES `Folder`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentFolderId", + "columnName": "parentFolderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Folder_parentFolderId", + "unique": false, + "columnNames": [ + "parentFolderId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Folder_parentFolderId` ON `${TABLE_NAME}` (`parentFolderId`)" + } + ], + "foreignKeys": [ + { + "table": "Folder", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parentFolderId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Notebook", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `openPageId` TEXT, `pageIds` TEXT NOT NULL, `parentFolderId` TEXT, `defaultNativeTemplate` TEXT NOT NULL DEFAULT 'blank', `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`parentFolderId`) REFERENCES `Folder`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "openPageId", + "columnName": "openPageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pageIds", + "columnName": "pageIds", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentFolderId", + "columnName": "parentFolderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "defaultNativeTemplate", + "columnName": "defaultNativeTemplate", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'blank'" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Notebook_parentFolderId", + "unique": false, + "columnNames": [ + "parentFolderId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Notebook_parentFolderId` ON `${TABLE_NAME}` (`parentFolderId`)" + } + ], + "foreignKeys": [ + { + "table": "Folder", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parentFolderId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Page", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `scroll` INTEGER NOT NULL, `notebookId` TEXT, `nativeTemplate` TEXT NOT NULL DEFAULT 'blank', `parentFolderId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`parentFolderId`) REFERENCES `Folder`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`notebookId`) REFERENCES `Notebook`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scroll", + "columnName": "scroll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notebookId", + "columnName": "notebookId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nativeTemplate", + "columnName": "nativeTemplate", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'blank'" + }, + { + "fieldPath": "parentFolderId", + "columnName": "parentFolderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Page_notebookId", + "unique": false, + "columnNames": [ + "notebookId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Page_notebookId` ON `${TABLE_NAME}` (`notebookId`)" + }, + { + "name": "index_Page_parentFolderId", + "unique": false, + "columnNames": [ + "parentFolderId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Page_parentFolderId` ON `${TABLE_NAME}` (`parentFolderId`)" + } + ], + "foreignKeys": [ + { + "table": "Folder", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parentFolderId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Notebook", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "notebookId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Stroke", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `size` REAL NOT NULL, `pen` TEXT NOT NULL, `color` INTEGER NOT NULL DEFAULT 0xFF000000, `top` REAL NOT NULL, `bottom` REAL NOT NULL, `left` REAL NOT NULL, `right` REAL NOT NULL, `points` TEXT NOT NULL, `pageId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`pageId`) REFERENCES `Page`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "pen", + "columnName": "pen", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0xFF000000" + }, + { + "fieldPath": "top", + "columnName": "top", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "bottom", + "columnName": "bottom", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "left", + "columnName": "left", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "right", + "columnName": "right", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "points", + "columnName": "points", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pageId", + "columnName": "pageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Stroke_pageId", + "unique": false, + "columnNames": [ + "pageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Stroke_pageId` ON `${TABLE_NAME}` (`pageId`)" + } + ], + "foreignKeys": [ + { + "table": "Page", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Kv", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4fa832c706cf63132d83d26f9f00845e')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ethran.notable.db.AppDatabase/30.json b/app/schemas/com.ethran.notable.db.AppDatabase/30.json new file mode 100644 index 00000000..2f8add14 --- /dev/null +++ b/app/schemas/com.ethran.notable.db.AppDatabase/30.json @@ -0,0 +1,489 @@ +{ + "formatVersion": 1, + "database": { + "version": 30, + "identityHash": "23e6e69f7546dc89ebef80e79d398979", + "entities": [ + { + "tableName": "Folder", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `parentFolderId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`parentFolderId`) REFERENCES `Folder`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentFolderId", + "columnName": "parentFolderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Folder_parentFolderId", + "unique": false, + "columnNames": [ + "parentFolderId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Folder_parentFolderId` ON `${TABLE_NAME}` (`parentFolderId`)" + } + ], + "foreignKeys": [ + { + "table": "Folder", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parentFolderId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Notebook", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `openPageId` TEXT, `pageIds` TEXT NOT NULL, `parentFolderId` TEXT, `defaultNativeTemplate` TEXT NOT NULL DEFAULT 'blank', `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`parentFolderId`) REFERENCES `Folder`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "openPageId", + "columnName": "openPageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pageIds", + "columnName": "pageIds", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentFolderId", + "columnName": "parentFolderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "defaultNativeTemplate", + "columnName": "defaultNativeTemplate", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'blank'" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Notebook_parentFolderId", + "unique": false, + "columnNames": [ + "parentFolderId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Notebook_parentFolderId` ON `${TABLE_NAME}` (`parentFolderId`)" + } + ], + "foreignKeys": [ + { + "table": "Folder", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parentFolderId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Page", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `scroll` INTEGER NOT NULL, `notebookId` TEXT, `nativeTemplate` TEXT NOT NULL DEFAULT 'blank', `parentFolderId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`parentFolderId`) REFERENCES `Folder`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`notebookId`) REFERENCES `Notebook`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scroll", + "columnName": "scroll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notebookId", + "columnName": "notebookId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nativeTemplate", + "columnName": "nativeTemplate", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'blank'" + }, + { + "fieldPath": "parentFolderId", + "columnName": "parentFolderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Page_notebookId", + "unique": false, + "columnNames": [ + "notebookId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Page_notebookId` ON `${TABLE_NAME}` (`notebookId`)" + }, + { + "name": "index_Page_parentFolderId", + "unique": false, + "columnNames": [ + "parentFolderId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Page_parentFolderId` ON `${TABLE_NAME}` (`parentFolderId`)" + } + ], + "foreignKeys": [ + { + "table": "Folder", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parentFolderId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Notebook", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "notebookId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Stroke", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `size` REAL NOT NULL, `pen` TEXT NOT NULL, `color` INTEGER NOT NULL DEFAULT 0xFF000000, `top` REAL NOT NULL, `bottom` REAL NOT NULL, `left` REAL NOT NULL, `right` REAL NOT NULL, `points` TEXT NOT NULL, `pageId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`pageId`) REFERENCES `Page`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "pen", + "columnName": "pen", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0xFF000000" + }, + { + "fieldPath": "top", + "columnName": "top", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "bottom", + "columnName": "bottom", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "left", + "columnName": "left", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "right", + "columnName": "right", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "points", + "columnName": "points", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pageId", + "columnName": "pageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Stroke_pageId", + "unique": false, + "columnNames": [ + "pageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Stroke_pageId` ON `${TABLE_NAME}` (`pageId`)" + } + ], + "foreignKeys": [ + { + "table": "Page", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Image", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `x` INTEGER NOT NULL, `y` INTEGER NOT NULL, `height` INTEGER NOT NULL, `width` INTEGER NOT NULL, `uri` TEXT, `pageId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`pageId`) REFERENCES `Page`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "x", + "columnName": "x", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "y", + "columnName": "y", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "width", + "columnName": "width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pageId", + "columnName": "pageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Image_pageId", + "unique": false, + "columnNames": [ + "pageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Image_pageId` ON `${TABLE_NAME}` (`pageId`)" + } + ], + "foreignKeys": [ + { + "table": "Page", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Kv", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '23e6e69f7546dc89ebef80e79d398979')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/olup/notable/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/ethran/notable/ExampleInstrumentedTest.kt similarity index 85% rename from app/src/androidTest/java/com/olup/notable/ExampleInstrumentedTest.kt rename to app/src/androidTest/java/com/ethran/notable/ExampleInstrumentedTest.kt index 0be2c493..55c1d493 100644 --- a/app/src/androidTest/java/com/olup/notable/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/ethran/notable/ExampleInstrumentedTest.kt @@ -1,4 +1,4 @@ -package com.olup.notable +package com.ethran.notable import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry @@ -17,6 +17,6 @@ class ExampleInstrumentedTest { fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.olup.notable", appContext.packageName) + assertEquals("com.ethran.notable", appContext.packageName) } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 93e48ebb..7cde67c6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,11 @@ + + + + + + + android:theme="@style/Theme.Inka" + android:hardwareAccelerated="true"> + + + + + + + + + + diff --git a/app/src/main/java/com/ethran/notable/FloatingEditorActivity.kt b/app/src/main/java/com/ethran/notable/FloatingEditorActivity.kt new file mode 100644 index 00000000..433cd712 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/FloatingEditorActivity.kt @@ -0,0 +1,140 @@ +package com.ethran.notable + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import androidx.navigation.compose.rememberNavController +import com.ethran.notable.classes.AppRepository +import com.ethran.notable.db.Page +import com.ethran.notable.modals.GlobalAppSettings +import com.ethran.notable.ui.theme.InkaTheme +import com.ethran.notable.utils.exportBook +import com.ethran.notable.utils.exportPageToPng +import com.ethran.notable.views.FloatingEditorView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class FloatingEditorActivity : ComponentActivity() { + private lateinit var appRepository: AppRepository + private var pageId: String? = null + private var bookId: String? = null + + private val overlayPermissionLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (Settings.canDrawOverlays(this)) { + showEditor = true + } + } + + private var showEditor by mutableStateOf(false) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val data = intent.data?.lastPathSegment + if (data == null) { + finish() + return + } + + if (data.startsWith("page-")) { + pageId = data.removePrefix("page-") + } else if (data.startsWith("book-")) { + bookId = data.removePrefix("book-") + } else { + pageId = data + return + } + + + appRepository = AppRepository(this) + + setContent { + InkaTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colors.background + ) { + val navController = rememberNavController() + + LaunchedEffect(Unit) { + if (!Settings.canDrawOverlays(this@FloatingEditorActivity)) { + val intent = Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:$packageName") + ) + overlayPermissionLauncher.launch(intent) + } else { + showEditor = true + } + } + + if (showEditor) { + FloatingEditorContent(navController, pageId, bookId) + } + } + } + } + } + + @Composable + private fun FloatingEditorContent( + navController: androidx.navigation.NavController, + pageId: String? = null, + bookId: String? = null + ) { + if (pageId != null) { + var page = appRepository.pageRepository.getById(pageId) + if (page == null) { + page = Page( + id = pageId, + notebookId = null, + parentFolderId = null, + nativeTemplate = GlobalAppSettings.current.defaultNativeTemplate + ) + appRepository.pageRepository.create(page) + } + + FloatingEditorView( + navController = navController, + pageId = pageId, + onDismissRequest = { finish() } + ) + } else if (bookId != null) { + FloatingEditorView( + navController = navController, + bookId = bookId, + onDismissRequest = { finish() } + ) + } + } + + override fun onDestroy() { + super.onDestroy() + pageId?.let { id -> + val context = this + lifecycleScope.launch(Dispatchers.IO) { + exportPageToPng(context, id) + } + } + bookId?.let { id -> + lifecycleScope.launch(Dispatchers.IO) { + exportBook(this@FloatingEditorActivity, id) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/MainActivity.kt b/app/src/main/java/com/ethran/notable/MainActivity.kt new file mode 100644 index 00000000..77838882 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/MainActivity.kt @@ -0,0 +1,222 @@ +package com.ethran.notable + + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.provider.Settings +import android.view.View +import android.view.WindowInsets +import android.view.WindowInsetsController +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.annotation.RequiresApi +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.view.WindowCompat +import androidx.lifecycle.lifecycleScope +import com.ethran.notable.classes.DrawCanvas +import com.ethran.notable.classes.LocalSnackContext +import com.ethran.notable.classes.SnackBar +import com.ethran.notable.classes.SnackState +import com.ethran.notable.datastore.EditorSettingCacheManager +import com.ethran.notable.db.KvProxy +import com.ethran.notable.modals.AppSettings +import com.ethran.notable.modals.GlobalAppSettings +import com.ethran.notable.ui.theme.InkaTheme +import com.ethran.notable.views.Router +import com.onyx.android.sdk.api.device.epd.EpdController +import io.shipbook.shipbooksdk.Log +import io.shipbook.shipbooksdk.ShipBook +import kotlinx.coroutines.launch + + +var SCREEN_WIDTH = EpdController.getEpdHeight().toInt() +var SCREEN_HEIGHT = EpdController.getEpdWidth().toInt() + +var TAG = "MainActivity" + +@ExperimentalAnimationApi +@ExperimentalComposeUiApi +@ExperimentalFoundationApi +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableFullScreen() + requestPermissions() + + + ShipBook.start( + this.application, BuildConfig.SHIPBOOK_APP_ID, BuildConfig.SHIPBOOK_APP_KEY + ) + + Log.i(TAG, "Notable started") + + + if (SCREEN_WIDTH == 0) { + SCREEN_WIDTH = applicationContext.resources.displayMetrics.widthPixels + SCREEN_HEIGHT = applicationContext.resources.displayMetrics.heightPixels + } + + val snackState = SnackState() + snackState.registerGlobalSnackObserver() + snackState.registerCancelGlobalSnackObserver() + + // Refactor - we prob don't need this + EditorSettingCacheManager.init(applicationContext) + + GlobalAppSettings.update( + KvProxy(this).get("APP_SETTINGS", AppSettings.serializer()) + ?: AppSettings(version = 1) + ) + + //EpdDeviceManager.enterAnimationUpdate(true); + + val intentData = intent.data?.lastPathSegment + setContent { + InkaTheme { + CompositionLocalProvider(LocalSnackContext provides snackState) { + Box( + Modifier + .background(Color.White) + ) { + Router() + } + Box( + Modifier + .fillMaxWidth() + .height(1.dp) + .background(Color.Black) + ) + SnackBar(state = snackState) + } + } + } + } + + + override fun onRestart() { + super.onRestart() + // redraw after device sleep + this.lifecycleScope.launch { + DrawCanvas.restartAfterConfChange.emit(Unit) + } + } + + override fun onPause() { + super.onPause() + this.lifecycleScope.launch { + Log.d("QuickSettings", "App is paused - maybe quick settings opened?") + + DrawCanvas.refreshUi.emit(Unit) + } + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + // It is really necessary? + if (hasFocus) { + enableFullScreen() // Re-apply full-screen mode when focus is regained + this.lifecycleScope.launch { + DrawCanvas.refreshUi.emit(Unit) + } + } else { + lifecycleScope.launch { + DrawCanvas.isDrawing.emit(false) + } + } + + } + + // when the screen orientation is changed, set new screen width restart is not necessary, + // as we need first to update page dimensions which is done in EditorView + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) { + Log.i(TAG, "Switched to Landscape") + } else if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) { + Log.i(TAG, "Switched to Portrait") + } + SCREEN_WIDTH = applicationContext.resources.displayMetrics.widthPixels + SCREEN_HEIGHT = applicationContext.resources.displayMetrics.heightPixels +// this.lifecycleScope.launch { +// DrawCanvas.restartAfterConfChange.emit(Unit) +// } + } + + private fun requestPermissions() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + if (ContextCompat.checkSelfPermission( + this, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), + 1001 + ) + } + } else if (!Environment.isExternalStorageManager()) { + requestManageAllFilesPermission() + } + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun requestManageAllFilesPermission() { + val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) + intent.data = Uri.fromParts("package", packageName, null) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + startActivity(intent) + } + + // written by GPT, but it works + // needs to be checked if it is ok approach. + private fun enableFullScreen() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // For Android 11 and above + // 'setDecorFitsSystemWindows(Boolean): Unit' is deprecated. Deprecated in Java +// window.setDecorFitsSystemWindows(false) + WindowCompat.setDecorFitsSystemWindows(window, false) +// if (window.insetsController != null) { +// window.insetsController!!.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars()) +// window.insetsController!!.systemBarsBehavior = +// WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE +// } + // Safely access the WindowInsetsController + val controller = window.decorView.windowInsetsController + if (controller != null) { + controller.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars()) + controller.systemBarsBehavior = + WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } else { + Log.e(TAG, "WindowInsetsController is null") + } + } else { + // For Android 10 and below + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + ) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/olup/notable/NotableApp.kt b/app/src/main/java/com/ethran/notable/NotableApp.kt similarity index 91% rename from app/src/main/java/com/olup/notable/NotableApp.kt rename to app/src/main/java/com/ethran/notable/NotableApp.kt index 2d3f049a..411948de 100644 --- a/app/src/main/java/com/olup/notable/NotableApp.kt +++ b/app/src/main/java/com/ethran/notable/NotableApp.kt @@ -1,4 +1,4 @@ -package com.olup.notable +package com.ethran.notable import android.app.Application import com.onyx.android.sdk.rx.RxManager diff --git a/app/src/main/java/com/olup/notable/classes/AppRepository.kt b/app/src/main/java/com/ethran/notable/classes/AppRepository.kt similarity index 61% rename from app/src/main/java/com/olup/notable/classes/AppRepository.kt rename to app/src/main/java/com/ethran/notable/classes/AppRepository.kt index 629de99f..02420692 100644 --- a/app/src/main/java/com/olup/notable/classes/AppRepository.kt +++ b/app/src/main/java/com/ethran/notable/classes/AppRepository.kt @@ -1,8 +1,16 @@ -package com.olup.notable +package com.ethran.notable.classes import android.content.Context -import com.olup.notable.db.* -import java.util.* +import com.ethran.notable.db.BookRepository +import com.ethran.notable.db.FolderRepository +import com.ethran.notable.db.ImageRepository +import com.ethran.notable.db.KvProxy +import com.ethran.notable.db.KvRepository +import com.ethran.notable.db.Page +import com.ethran.notable.db.PageRepository +import com.ethran.notable.db.StrokeRepository +import java.util.Date +import java.util.UUID class AppRepository(context: Context) { @@ -10,6 +18,7 @@ class AppRepository(context: Context) { val bookRepository = BookRepository(context) val pageRepository = PageRepository(context) val strokeRepository = StrokeRepository(context) + val imageRepository = ImageRepository(context) val folderRepository = FolderRepository(context) val kvRepository = KvRepository(context) val kvProxy = KvProxy(context) @@ -45,7 +54,8 @@ class AppRepository(context: Context) { } fun duplicatePage(pageId: String) { - val pageWithStrokes = pageRepository.getWithStrokeById(pageId) ?: return + val pageWithStrokes = pageRepository.getWithStrokeById(pageId) + val pageWithImages = pageRepository.getWithImageById(pageId) val duplicatedPage = pageWithStrokes.page.copy( id = UUID.randomUUID().toString(), scroll = 0, @@ -61,12 +71,28 @@ class AppRepository(context: Context) { createdAt = Date() ) }) - if(pageWithStrokes.page.notebookId != null) { + imageRepository.create(pageWithImages.images.map { + it.copy( + id = UUID.randomUUID().toString(), + pageId = duplicatedPage.id, + updatedAt = Date(), + createdAt = Date() + ) + }) + if (pageWithStrokes.page.notebookId != null) { val book = bookRepository.getById(pageWithStrokes.page.notebookId) ?: return val pageIndex = book.pageIds.indexOf(pageWithStrokes.page.id) - if(pageIndex == -1) return + if (pageIndex == -1) return + val pageIds = book.pageIds.toMutableList() + pageIds.add(pageIndex + 1, duplicatedPage.id) + bookRepository.update(book.copy(pageIds = pageIds)) + } + if (pageWithImages.page.notebookId != null) { + val book = bookRepository.getById(pageWithImages.page.notebookId) ?: return + val pageIndex = book.pageIds.indexOf(pageWithImages.page.id) + if (pageIndex == -1) return val pageIds = book.pageIds.toMutableList() - pageIds.add(pageIndex+1, duplicatedPage.id) + pageIds.add(pageIndex + 1, duplicatedPage.id) bookRepository.update(book.copy(pageIds = pageIds)) } } diff --git a/app/src/main/java/com/ethran/notable/classes/ClipboardContent.kt b/app/src/main/java/com/ethran/notable/classes/ClipboardContent.kt new file mode 100644 index 00000000..5e440879 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/classes/ClipboardContent.kt @@ -0,0 +1,9 @@ +package com.ethran.notable.classes + +import com.ethran.notable.db.Image +import com.ethran.notable.db.Stroke + +data class ClipboardContent( + val strokes: List = emptyList(), + val images: List = emptyList(), +) \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/classes/DrawCanvas.kt b/app/src/main/java/com/ethran/notable/classes/DrawCanvas.kt new file mode 100644 index 00000000..1991286d --- /dev/null +++ b/app/src/main/java/com/ethran/notable/classes/DrawCanvas.kt @@ -0,0 +1,622 @@ +package com.ethran.notable.classes + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import android.net.Uri +import android.os.Looper +import android.view.SurfaceHolder +import android.view.SurfaceView +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.ethran.notable.TAG +import com.ethran.notable.db.Image +import com.ethran.notable.db.ImageRepository +import com.ethran.notable.db.StrokeRepository +import com.ethran.notable.db.handleSelect +import com.ethran.notable.db.selectImage +import com.ethran.notable.db.selectImagesAndStrokes +import com.ethran.notable.modals.AppSettings +import com.ethran.notable.modals.GlobalAppSettings +import com.ethran.notable.utils.EditorState +import com.ethran.notable.utils.Eraser +import com.ethran.notable.utils.History +import com.ethran.notable.utils.Mode +import com.ethran.notable.utils.Operation +import com.ethran.notable.utils.Pen +import com.ethran.notable.utils.PlacementMode +import com.ethran.notable.utils.SimplePointF +import com.ethran.notable.utils.convertDpToPixel +import com.ethran.notable.utils.drawImage +import com.ethran.notable.utils.handleDraw +import com.ethran.notable.utils.handleErase +import com.ethran.notable.utils.handleLine +import com.ethran.notable.utils.penToStroke +import com.ethran.notable.utils.pointsToPath +import com.ethran.notable.utils.selectPaint +import com.ethran.notable.utils.uriToBitmap +import com.onyx.android.sdk.api.device.epd.EpdController +import com.onyx.android.sdk.data.note.TouchPoint +import com.onyx.android.sdk.pen.RawInputCallback +import com.onyx.android.sdk.pen.TouchHelper +import com.onyx.android.sdk.pen.data.TouchPointList +import io.shipbook.shipbooksdk.Log +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.concurrent.thread + + +val pressure = EpdController.getMaxTouchPressure() + +// keep reference of the surface view presently associated to the singleton touchhelper +var referencedSurfaceView: String = "" + +class DrawCanvas( + context: Context, + val coroutineScope: CoroutineScope, + val state: EditorState, + val page: PageView, + val history: History +) : SurfaceView(context) { + private val strokeHistoryBatch = mutableListOf() +// private val commitHistorySignal = MutableSharedFlow() + + + companion object { + var forceUpdate = MutableSharedFlow() + var refreshUi = MutableSharedFlow() + var isDrawing = MutableSharedFlow() + var restartAfterConfChange = MutableSharedFlow() + + // before undo we need to commit changes + val commitHistorySignal = MutableSharedFlow() + val commitHistorySignalImmediately = MutableSharedFlow() + + // used for checking if commit was completed + var commitCompletion = CompletableDeferred() + + // It might be bad idea, but plan is to insert graphic in this, and then take it from it + // There is probably better way + var addImageByUri = MutableStateFlow(null) + var rectangleToSelect = MutableStateFlow(null) + var drawingInProgress = Mutex() + } + + fun getActualState(): EditorState { + return this.state + } + + private val inputCallback: RawInputCallback = object : RawInputCallback() { + + override fun onBeginRawDrawing(p0: Boolean, p1: TouchPoint?) { + } + + override fun onEndRawDrawing(p0: Boolean, p1: TouchPoint?) { + } + + override fun onRawDrawingTouchPointMoveReceived(p0: TouchPoint?) { + } + + override fun onRawDrawingTouchPointListReceived(plist: TouchPointList) { + val startTime = System.currentTimeMillis() + // sometimes UI will get refreshed and frozen before we draw all the strokes. + // I think, its because of doing it in separate thread. Commented it for now, to + // observe app behavior, and determine if it fixed this bug, + // as I do not know reliable way to reproduce it + // Need testing if it will be better to do in main thread on, in separate. + // thread(start = true, isDaemon = false, priority = Thread.MAX_PRIORITY) { + + if (getActualState().mode == Mode.Draw) { +// val newThread = System.currentTimeMillis() +// Log.d(TAG,"Got to new thread ${Thread.currentThread().name}, in ${newThread - startTime}}") + coroutineScope.launch(Dispatchers.Main.immediate) { + // After each stroke ends, we draw it on our canvas. + // This way, when screen unfreezes the strokes are shown. + // When in scribble mode, ui want be refreshed. + // If we UI will be refreshed and frozen before we manage to draw + // strokes want be visible, so we need to ensure that it will be done + // before anything else happens. + drawingInProgress.withLock { + val lock = System.currentTimeMillis() + Log.d(TAG, "lock obtained in ${lock - startTime} ms") + +// Thread.sleep(1000) + handleDraw( + this@DrawCanvas.page, + strokeHistoryBatch, + getActualState().penSettings[getActualState().pen.penName]!!.strokeSize, + getActualState().penSettings[getActualState().pen.penName]!!.color, + getActualState().pen, + plist.points + ) +// val drawEndTime = System.currentTimeMillis() +// Log.d(TAG, "Drawing operation took ${drawEndTime - startTime} ms") + + } + coroutineScope.launch { + commitHistorySignal.emit(Unit) + } + +// val endTime = System.currentTimeMillis() +// Log.d(TAG,"onRawDrawingTouchPointListReceived completed in ${endTime - startTime} ms") + + } + } else thread { + if (getActualState().mode == Mode.Erase) { + handleErase( + this@DrawCanvas.page, + history, + plist.points.map { SimplePointF(it.x, it.y + page.scroll) }, + eraser = getActualState().eraser + ) + drawCanvasToView() + refreshUi() + } + + if (getActualState().mode == Mode.Select) { + handleSelect( + coroutineScope, + this@DrawCanvas.page, + getActualState(), + plist.points.map { SimplePointF(it.x, it.y + page.scroll) }) + drawCanvasToView() + refreshUi() + } + + if (getActualState().mode == Mode.Line) { + // draw line + handleLine( + page = this@DrawCanvas.page, + historyBucket = strokeHistoryBatch, + strokeSize = getActualState().penSettings[getActualState().pen.penName]!!.strokeSize, + color = getActualState().penSettings[getActualState().pen.penName]!!.color, + pen = getActualState().pen, + touchPoints = plist.points + ) + //make it visible + drawCanvasToView() + refreshUi() + } + + } + } + + + override fun onBeginRawErasing(p0: Boolean, p1: TouchPoint?) { + } + + override fun onEndRawErasing(p0: Boolean, p1: TouchPoint?) { + } + + override fun onRawErasingTouchPointListReceived(plist: TouchPointList?) { + if (plist == null) return + handleErase( + this@DrawCanvas.page, + history, + plist.points.map { SimplePointF(it.x, it.y + page.scroll) }, + eraser = getActualState().eraser + ) + drawCanvasToView() + refreshUi() + } + + override fun onRawErasingTouchPointMoveReceived(p0: TouchPoint?) { + } + + override fun onPenUpRefresh(refreshRect: RectF?) { + super.onPenUpRefresh(refreshRect) + } + + override fun onPenActive(point: TouchPoint?) { + super.onPenActive(point) + } + } + + private val touchHelper by lazy { + referencedSurfaceView = this.hashCode().toString() + TouchHelper.create(this, inputCallback) + } + + fun init() { + Log.i(TAG, "Initializing Canvas") + + val surfaceView = this + + val surfaceCallback: SurfaceHolder.Callback = object : SurfaceHolder.Callback { + override fun surfaceCreated(holder: SurfaceHolder) { + Log.i(TAG, "surface created $holder") + // set up the drawing surface + updateActiveSurface() + // This is supposed to let the ui update while the old surface is being unmounted + coroutineScope.launch { + forceUpdate.emit(null) + } + } + + override fun surfaceChanged( + holder: SurfaceHolder, format: Int, width: Int, height: Int + ) { + Log.i(TAG, "surface changed $holder") + drawCanvasToView() + updatePenAndStroke() + refreshUi() + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + Log.i( + TAG, + "surface destroyed ${ + this@DrawCanvas.hashCode() + } - ref $referencedSurfaceView" + ) + holder.removeCallback(this) + if (referencedSurfaceView == this@DrawCanvas.hashCode().toString()) { + touchHelper.closeRawDrawing() + } + } + } + + this.holder.addCallback(surfaceCallback) + + } + + fun registerObservers() { + + // observe forceUpdate + coroutineScope.launch { + forceUpdate.collect { zoneAffected -> + Log.v(TAG + "Observer", "Force update zone $zoneAffected") + + if (zoneAffected != null) page.drawArea( + area = Rect( + zoneAffected.left, + zoneAffected.top - page.scroll, + zoneAffected.right, + zoneAffected.bottom - page.scroll + ), + ) + refreshUiSuspend() + } + } + + // observe refreshUi + coroutineScope.launch { + refreshUi.collect { + Log.v(TAG + "Observer", "Refreshing UI!") + refreshUiSuspend() + } + } + coroutineScope.launch { + isDrawing.collect { + Log.v(TAG + "Observer", "drawing state changed!") + state.isDrawing = it + } + } + + + coroutineScope.launch { + addImageByUri.drop(1).collect { imageUri -> + Log.v(TAG + "Observer", "Received image!") + + if (imageUri != null) { + handleImage(imageUri) + } //else +// Log.i(TAG, "Image uri is empty") + } + } + coroutineScope.launch { + rectangleToSelect.drop(1).collect { + selectRectangle(it) + } + } + + + // observe restartcount + coroutineScope.launch { + restartAfterConfChange.collect { + Log.v(TAG + "Observer", "Configuration changed!") + init() + drawCanvasToView() + } + } + + // observe pen and stroke size + coroutineScope.launch { + snapshotFlow { state.pen }.drop(1).collect { + Log.v(TAG + "Observer", "pen change: ${state.pen}") + updatePenAndStroke() + refreshUiSuspend() + } + } + coroutineScope.launch { + snapshotFlow { state.penSettings.toMap() }.drop(1).collect { + Log.v(TAG + "Observer", "pen settings change: ${state.penSettings}") + updatePenAndStroke() + refreshUiSuspend() + } + } + coroutineScope.launch { + snapshotFlow { state.eraser }.drop(1).collect { + Log.v(TAG + "Observer", "eraser change: ${state.eraser}") + updatePenAndStroke() + refreshUiSuspend() + } + } + + // observe is drawing + coroutineScope.launch { + snapshotFlow { state.isDrawing }.drop(1).collect { + Log.v(TAG + "Observer", "isDrawing change: ${state.isDrawing}") + updateIsDrawing() + } + } + + // observe toolbar open + coroutineScope.launch { + snapshotFlow { state.isToolbarOpen }.drop(1).collect { + Log.v(TAG + "Observer", "istoolbaropen change: ${state.isToolbarOpen}") + updateActiveSurface() + } + } + + // observe mode + coroutineScope.launch { + snapshotFlow { getActualState().mode }.drop(1).collect { + Log.v(TAG + "Observer", "mode change: ${getActualState().mode}") + updatePenAndStroke() + refreshUiSuspend() + } + } + + coroutineScope.launch { + //After 500ms add to history strokes + commitHistorySignal.debounce(500).collect { + Log.v(TAG + "Observer", "Commiting to history") + commitToHistory() + } + } + coroutineScope.launch { + commitHistorySignalImmediately.collect { + commitToHistory() + commitCompletion.complete(Unit) + } + } + + } + + private suspend fun selectRectangle(rectToSelect: Rect?) { + if (rectToSelect != null) { + Log.d(TAG + "Observer", "position of image $rectToSelect") + rectToSelect.top += page.scroll + rectToSelect.bottom += page.scroll + // Query the database to find an image that coincides with the point + val imagesToSelect = withContext(Dispatchers.IO) { + ImageRepository(context).getImagesInRectangle(rectToSelect, page.id) + } + val strokesToSelect = withContext(Dispatchers.IO) { + StrokeRepository(context).getStrokesInRectangle(rectToSelect, page.id) + } + rectangleToSelect.value = null + if (imagesToSelect.isNotEmpty() || strokesToSelect.isNotEmpty()) { + selectImagesAndStrokes(coroutineScope, page, state, imagesToSelect, strokesToSelect) + } else { + SnackState.globalSnackFlow.emit( + SnackConf( + text = "There isn't anything.", + duration = 3000, + ) + ) + } + } + } + + private fun commitToHistory() { + if (strokeHistoryBatch.size > 0) history.addOperationsToHistory( + operations = listOf( + Operation.DeleteStroke(strokeHistoryBatch.map { it }) + ) + ) + strokeHistoryBatch.clear() + //testing if it will help with undo hiding strokes. + drawCanvasToView() + } + + private fun refreshUi() { + // Use only if you have confidence that there are no strokes being drawn at the moment + if (!state.isDrawing) { + Log.w(TAG, "Not in drawing mode, skipping refreshUI") + return + } + if (drawingInProgress.isLocked) + Log.w(TAG, "Drawing is still in progress there might be a bug.") + + drawCanvasToView() + + // reset screen freeze + // if in scribble mode, the screen want refresh + // So to update interface we need to disable, and re-enable + touchHelper.setRawDrawingEnabled(false) + touchHelper.setRawDrawingEnabled(true) + // screen won't freeze until you actually stoke + } + + private suspend fun refreshUiSuspend() { + // Do not use, if refresh need to be preformed without delay. + // This function waits for strokes to be fully rendered. + if (!state.isDrawing) { + waitForDrawing() + drawCanvasToView() + Log.w(TAG, "Not in drawing mode -- refreshUi ") + return + } + if (Looper.getMainLooper().isCurrentThread) { + Log.i( + TAG, + "refreshUiSuspend() is called from the main thread, it might not be a good idea." + ) + } + waitForDrawing() + drawCanvasToView() + touchHelper.setRawDrawingEnabled(false) + if (drawingInProgress.isLocked) + Log.w(TAG, "Lock was acquired during refreshing UI. It might cause errors.") + touchHelper.setRawDrawingEnabled(true) + } + + private suspend fun waitForDrawing() { + withTimeoutOrNull(3000) { + // Just to make sure wait 1ms before checking lock. + delay(1) + // Wait until drawingInProgress is unlocked before proceeding + while (drawingInProgress.isLocked) { + delay(5) + } + } ?: Log.e(TAG, "Timeout while waiting for drawing lock. Potential deadlock.") + } + + private fun handleImage(imageUri: Uri) { + // Convert the image to a software-backed bitmap + val imageBitmap = uriToBitmap(context, imageUri)?.asImageBitmap() + if (imageBitmap == null) + showHint("There was an error during image processing.", coroutineScope) + val softwareBitmap = + imageBitmap?.asAndroidBitmap()?.copy(Bitmap.Config.ARGB_8888, true) + if (softwareBitmap != null) { + addImageByUri.value = null + + // Get the image dimensions + val imageWidth = softwareBitmap.width + val imageHeight = softwareBitmap.height + + // Calculate the center position for the image relative to the page dimensions + val centerX = (page.viewWidth - imageWidth) / 2 + val centerY = (page.viewHeight - imageHeight) / 2 + page.scroll + val imageToSave = Image( + x = centerX, + y = centerY, + height = imageHeight, + width = imageWidth, + uri = imageUri.toString(), + pageId = page.id + ) + drawImage( + context, page.windowedCanvas, imageToSave, IntOffset(0, -page.scroll) + ) + selectImage(coroutineScope, page, state, imageToSave) + // image will be added to database when released, the same as with paste element. + state.selectionState.placementMode = PlacementMode.Paste + } else { + // Handle cases where the bitmap could not be created + Log.e("ImageProcessing", "Failed to create software bitmap from URI.") + } + } + + + fun drawCanvasToView() { + val canvas = this.holder.lockCanvas() ?: return + canvas.drawBitmap(page.windowedBitmap, 0f, 0f, Paint()) + if (getActualState().mode == Mode.Select) { + // render selection + if (getActualState().selectionState.firstPageCut != null) { + Log.i(TAG, "render cut") + val path = pointsToPath(getActualState().selectionState.firstPageCut!!.map { + SimplePointF( + it.x, it.y - page.scroll + ) + }) + canvas.drawPath(path, selectPaint) + } + } + // finish rendering + this.holder.unlockCanvasAndPost(canvas) + } + + private suspend fun updateIsDrawing() { + Log.i(TAG, "Update is drawing: ${state.isDrawing}") + if (state.isDrawing) { + touchHelper.setRawDrawingEnabled(true) + } else { + // Check if drawing is completed + waitForDrawing() + // draw to view, before showing drawing, avoid stutter + drawCanvasToView() + touchHelper.setRawDrawingEnabled(false) + } + } + + fun updatePenAndStroke() { + Log.i(TAG, "Update pen and stroke") + when (state.mode) { + Mode.Draw -> touchHelper.setStrokeStyle(penToStroke(state.pen)) + ?.setStrokeWidth(state.penSettings[state.pen.penName]!!.strokeSize) + ?.setStrokeColor(state.penSettings[state.pen.penName]!!.color) + + Mode.Erase -> { + when (state.eraser) { + Eraser.PEN -> touchHelper.setStrokeStyle(penToStroke(Pen.MARKER)) + ?.setStrokeWidth(30f) + ?.setStrokeColor(Color.GRAY) + + Eraser.SELECT -> touchHelper.setStrokeStyle(penToStroke(Pen.BALLPEN)) + ?.setStrokeWidth(3f) + ?.setStrokeColor(Color.GRAY) + } + } + + Mode.Select -> touchHelper.setStrokeStyle(penToStroke(Pen.BALLPEN))?.setStrokeWidth(3f) + ?.setStrokeColor(Color.GRAY) + + Mode.Line -> { + } + } + } + + fun updateActiveSurface() { + Log.i(TAG, "Update editable surface") + + val toolbarHeight = + if (state.isToolbarOpen) convertDpToPixel(40.dp, context).toInt() else 0 + + touchHelper.setRawDrawingEnabled(false) + touchHelper.closeRawDrawing() + + // Determine the exclusion area based on toolbar position + val excludeRect: Rect = + if (GlobalAppSettings.current.toolbarPosition == AppSettings.Position.Top) { + Rect(0, 0, this.width, toolbarHeight) + } else { + Rect(0, this.height - toolbarHeight, this.width, this.height) + } + + val limitRect = if (GlobalAppSettings.current.toolbarPosition == AppSettings.Position.Top) + Rect(0, toolbarHeight, this.width, this.height) + else + Rect(0, 0, this.width, this.height - toolbarHeight) + + touchHelper.setLimitRect(mutableListOf(limitRect)).setExcludeRect(listOf(excludeRect)) + .openRawDrawing() + + touchHelper.setRawDrawingEnabled(true) + updatePenAndStroke() + + refreshUi() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/classes/EditorControlTower.kt b/app/src/main/java/com/ethran/notable/classes/EditorControlTower.kt new file mode 100644 index 00000000..347c55ff --- /dev/null +++ b/app/src/main/java/com/ethran/notable/classes/EditorControlTower.kt @@ -0,0 +1,178 @@ +package com.ethran.notable.classes + +import android.content.Context +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.toOffset +import com.ethran.notable.db.selectImagesAndStrokes +import com.ethran.notable.utils.EditorState +import com.ethran.notable.utils.History +import com.ethran.notable.utils.Mode +import com.ethran.notable.utils.Operation +import com.ethran.notable.utils.PlacementMode +import com.ethran.notable.utils.SimplePointF +import com.ethran.notable.utils.divideStrokesFromCut +import com.ethran.notable.utils.offsetStroke +import com.ethran.notable.utils.pageAreaToCanvasArea +import com.ethran.notable.utils.strokeBounds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.util.Date +import java.util.UUID + +class EditorControlTower( + private val scope: CoroutineScope, + val page: PageView, + private val history: History, + val state: EditorState +) { + + fun onSingleFingerVerticalSwipe(startPosition: SimplePointF, delta: Int) { + if (state.mode == Mode.Select) { + if (state.selectionState.firstPageCut != null) { + onOpenPageCut(delta) + } else { + onPageScroll(-delta) + } + } else { + onPageScroll(-delta) + } + + scope.launch { DrawCanvas.refreshUi.emit(Unit) } + + } + + private fun onOpenPageCut(offset: Int) { + if (offset < 0) return + val cutLine = state.selectionState.firstPageCut!! + + val (_, previousStrokes) = divideStrokesFromCut(page.strokes, cutLine) + + // calculate new strokes to add to the page + val nextStrokes = previousStrokes.map { + it.copy(points = it.points.map { + it.copy(x = it.x, y = it.y + offset) + }, top = it.top + offset, bottom = it.bottom + offset) + } + + // remove and paste + page.removeStrokes(strokeIds = previousStrokes.map { it.id }) + page.addStrokes(nextStrokes) + + // commit to history + history.addOperationsToHistory( + listOf( + Operation.DeleteStroke(nextStrokes.map { it.id }), + Operation.AddStroke(previousStrokes) + ) + ) + + state.selectionState.reset() + page.drawArea( + pageAreaToCanvasArea( + strokeBounds(previousStrokes + nextStrokes), page.scroll + ) + ) + } + + private fun onPageScroll(delta: Int) { + page.updateScroll(delta) + } + + //Now we can have selected images or selected strokes + fun applySelectionDisplace() { + val operationList = state.selectionState.applySelectionDisplace(page) + if (!operationList.isNullOrEmpty()) { + history.addOperationsToHistory(operationList) + } + scope.launch { + DrawCanvas.refreshUi.emit(Unit) + } + } + + fun deleteSelection() { + val operationList = state.selectionState.deleteSelection(page) + history.addOperationsToHistory(operationList) + state.isDrawing = true + scope.launch { + DrawCanvas.refreshUi.emit(Unit) + } + } + + fun changeSizeOfSelection(scale: Int) { + if (!state.selectionState.selectedImages.isNullOrEmpty()) + state.selectionState.resizeImages(scale, scope, page) + if (!state.selectionState.selectedStrokes.isNullOrEmpty()) + state.selectionState.resizeStrokes(scale, scope, page) + // Emit a refresh signal to update UI + scope.launch { + DrawCanvas.refreshUi.emit(Unit) + } + } + + fun duplicateSelection() { + // finish ongoing movement + applySelectionDisplace() + state.selectionState.duplicateSelection() + + } + + fun cutSelectionToClipboard(context: Context) { + state.clipboard = state.selectionState.selectionToClipboard(page.scroll, context) + deleteSelection() + showHint("Content cut to clipboard", scope) + } + + fun copySelectionToClipboard(context: Context) { + state.clipboard = state.selectionState.selectionToClipboard(page.scroll, context) + } + + + fun pasteFromClipboard() { + // finish ongoing movement + applySelectionDisplace() + + val (strokes, images) = state.clipboard ?: return + + val now = Date() + val scrollPos = page.scroll + val addPageScroll = IntOffset(0, scrollPos).toOffset() + + val pastedStrokes = strokes.map { + offsetStroke(it, offset = addPageScroll).copy( + // change the pasted strokes' ids - it's a copy + id = UUID + .randomUUID() + .toString(), + createdAt = now, + // set the pageId to the current page + pageId = this.page.id + ) + } + + val pastedImages = images.map { + it.copy( + // change the pasted images' ids - it's a copy + id = UUID + .randomUUID() + .toString(), + y = it.y + scrollPos, + createdAt = now, + // set the pageId to the current page + pageId = this.page.id + ) + } + + history.addOperationsToHistory( + operations = listOf( + Operation.DeleteImage(pastedImages.map { it.id }), + Operation.DeleteStroke(pastedStrokes.map { it.id }), + ) + ) + + selectImagesAndStrokes(scope, page, state, pastedImages, pastedStrokes) + state.selectionState.placementMode = PlacementMode.Paste + + showHint("Pasted content from clipboard", scope) + } +} + diff --git a/app/src/main/java/com/ethran/notable/classes/GestureState.kt b/app/src/main/java/com/ethran/notable/classes/GestureState.kt new file mode 100644 index 00000000..8aa7ca68 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/classes/GestureState.kt @@ -0,0 +1,122 @@ +package com.ethran.notable.classes + +import android.graphics.Rect +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.unit.IntOffset +import com.ethran.notable.utils.SimplePointF +import kotlin.math.abs + + +data class GestureState( + val initialPositions: MutableMap = mutableMapOf(), + val lastPositions: MutableMap = mutableMapOf(), + var initialTimestamp: Long = System.currentTimeMillis(), + var lastTimestamp: Long = initialTimestamp, +) { + fun getElapsedTime(): Long { + return lastTimestamp - initialTimestamp + } + + fun calculateTotalDelta(): Float { + return initialPositions.keys.sumOf { id -> + val initial = initialPositions[id] ?: Offset.Zero + val last = lastPositions[id] ?: initial + (initial - last).getDistance().toDouble() + }.toFloat() + } + + fun getFirstPosition(): IntOffset? { + return initialPositions.values.firstOrNull()?.let { point -> + IntOffset(point.x.toInt(), point.y.toInt()) + } + } + + fun getFirstPositionF(): SimplePointF? { + return initialPositions.values.firstOrNull()?.let { point -> + SimplePointF(point.x, point.y) + } + } + + fun getLastPositionIO(): IntOffset? { + return lastPositions.values.firstOrNull()?.let { point -> + IntOffset(point.x.toInt(), point.y.toInt()) + } ?: getFirstPosition() + } + + fun calculateRectangleBounds(): Rect? { + if (initialPositions.isEmpty() && lastPositions.isEmpty()) return null + + val firstPosition = initialPositions.values.firstOrNull() ?: return null + val lastPosition = lastPositions.values.firstOrNull() ?: firstPosition + + return Rect( + firstPosition.x.coerceAtMost(lastPosition.x).toInt(), + firstPosition.y.coerceAtMost(lastPosition.y).toInt(), + firstPosition.x.coerceAtLeast(lastPosition.x).toInt(), + firstPosition.y.coerceAtLeast(lastPosition.y).toInt() + ) + } + + // Insert a position for the given pointer ID + fun insertPosition(input: PointerInputChange) { + lastTimestamp = System.currentTimeMillis() + if (initialPositions.containsKey(input.id)) { + // Update last position if the pointer ID already exists in initial positions + lastPositions[input.id] = input.position + + } else { + // Add to initial positions if the pointer ID is new + initialPositions[input.id] = input.position + } + } + + // Get the current number of active inputs + fun getInputCount(): Int { + return initialPositions.size + } + + //return smallest horizontal movement, or 0, if movement is not horizontal + fun getHorizontalDrag(): Float { + if (initialPositions.isEmpty() || lastPositions.isEmpty()) return 0f + + var minHorizontalMovement: Float? = null + + for ((id, initial) in initialPositions) { + val last = lastPositions[id] ?: continue + val delta = last - initial + + // Check if the movement is more horizontal than vertical + if (abs(delta.x) <= abs(delta.y)) return 0f + + // Track the smallest horizontal movement + if (minHorizontalMovement == null || abs(delta.x) < abs(minHorizontalMovement)) { + minHorizontalMovement = delta.x + } + } + + return minHorizontalMovement ?: 0f + } + + //return smallest vertical movement, or 0, if movement is not vertical + fun getVerticalDrag(): Float { + if (initialPositions.isEmpty() || lastPositions.isEmpty()) return 0f + + var minVerticalMovement: Float? = null + + for ((id, initial) in initialPositions) { + val last = lastPositions[id] ?: continue + val delta = last - initial + + // Check if the movement is more vertical than horizontal + if (abs(delta.y) <= abs(delta.x)) return 0f + + // Track the smallest vertical movement + if (minVerticalMovement == null || abs(delta.y) < abs(minVerticalMovement)) { + minVerticalMovement = delta.y + } + } + return minVerticalMovement ?: 0f + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/classes/PageView.kt b/app/src/main/java/com/ethran/notable/classes/PageView.kt new file mode 100644 index 00000000..9dafbbe6 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/classes/PageView.kt @@ -0,0 +1,478 @@ +package com.ethran.notable.classes + + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Rect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.IntOffset +import androidx.core.graphics.toRect +import androidx.core.graphics.withClip +import com.ethran.notable.SCREEN_HEIGHT +import com.ethran.notable.SCREEN_WIDTH +import com.ethran.notable.TAG +import com.ethran.notable.db.AppDatabase +import com.ethran.notable.db.Image +import com.ethran.notable.db.Page +import com.ethran.notable.db.Stroke +import com.ethran.notable.modals.GlobalAppSettings +import com.ethran.notable.utils.drawBg +import com.ethran.notable.utils.drawImage +import com.ethran.notable.utils.drawStroke +import com.ethran.notable.utils.imageBounds +import com.ethran.notable.utils.strokeBounds +import io.shipbook.shipbooksdk.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream +import java.nio.file.Files +import kotlin.io.path.Path +import kotlin.math.abs +import kotlin.math.max +import kotlin.system.measureTimeMillis + +class PageView( + val context: Context, + val coroutineScope: CoroutineScope, + val id: String, + val width: Int, + var viewWidth: Int, + var viewHeight: Int +) { + private var strokeInitialLoadingJob: Job? = null + private var strokeRemainingLoadingJob: Job? = null + + private var snack: SnackConf? = null + fun cleanJob() { + //ensure that snack is canceled, even on dispose of the page. + CoroutineScope(Dispatchers.IO).launch { + snack?.let { SnackState.cancelGlobalSnack.emit(it.id) } + } + strokeInitialLoadingJob?.cancel() + strokeRemainingLoadingJob?.cancel() + } + + var windowedBitmap = Bitmap.createBitmap(viewWidth, viewHeight, Bitmap.Config.ARGB_8888) + var windowedCanvas = Canvas(windowedBitmap) + var strokes = listOf() + private var strokesById: HashMap = hashMapOf() + var images = listOf() + private var imagesById: HashMap = hashMapOf() + var scroll by mutableIntStateOf(0) // is observed by ui + private val saveTopic = MutableSharedFlow() + + var height by mutableIntStateOf(viewHeight) // is observed by ui + + var pageFromDb = AppRepository(context).pageRepository.getById(id) + + private var dbStrokes = AppDatabase.getDatabase(context).strokeDao() + private var dbImages = AppDatabase.getDatabase(context).ImageDao() + + + init { + coroutineScope.launch { + saveTopic.debounce(1000).collect { + launch { persistBitmap() } + launch { persistBitmapThumbnail() } + } + } + + windowedCanvas.drawColor(Color.WHITE) + drawBg(windowedCanvas, pageFromDb?.nativeTemplate!!, scroll) + + val isCached = loadBitmap() + initFromPersistLayer(isCached) + } + + private fun indexStrokes() { + coroutineScope.launch { + strokesById = hashMapOf(*strokes.map { s -> s.id to s }.toTypedArray()) + } + } + + private fun indexImages() { + coroutineScope.launch { + imagesById = hashMapOf(*images.map { img -> img.id to img }.toTypedArray()) + } + } + + private fun initFromPersistLayer(isCached: Boolean) { + cleanJob() + // pageInfos + // TODO page might not exists yet + val page = AppRepository(context).pageRepository.getById(id) + scroll = page!!.scroll + if (strokeInitialLoadingJob?.isActive == true || strokeRemainingLoadingJob?.isActive == true) { + Log.w(TAG, "Strokes are still loading, trying to cancel and resume") + cleanJob() + } + strokeInitialLoadingJob = coroutineScope.launch(Dispatchers.IO) { + val startTime = System.currentTimeMillis() + val pageWithImages = AppRepository(context).pageRepository.getWithImageById(id) + val viewRectangle = Rect(0, 0, windowedCanvas.width, windowedCanvas.height) + //for some reason, scroll is always 0 hare, so take it directly from db + val viewRectangleWithScroll = Rect( + viewRectangle.left, + viewRectangle.top + page.scroll, + viewRectangle.right, + viewRectangle.bottom + page.scroll + ) + strokes = + AppRepository(context).strokeRepository.getStrokesInRectangle( + viewRectangleWithScroll, + id + ) + + images = pageWithImages.images + indexImages() + val indexingJob = coroutineScope.launch(Dispatchers.Default) { + indexStrokes() + } + + + val fromDatabase = System.currentTimeMillis() + Log.d(TAG, "Strokes fetch from database, in ${fromDatabase - startTime}}") + if (!isCached) { + // we draw and cache +// Log.d(TAG, "We do not have cashed.") + drawBg(windowedCanvas, page.nativeTemplate, scroll) + drawArea(viewRectangle) + persistBitmap() + persistBitmapThumbnail() + DrawCanvas.refreshUi.emit(Unit) + } + + // Fetch all remaining strokes + strokeRemainingLoadingJob = coroutineScope.launch(Dispatchers.IO) { + val timeToLoad = measureTimeMillis { + // Set duration as safety guard: in 60 s all strokes should be loaded + snack = SnackConf(text = "Loading strokes...", duration = 60000) + SnackState.globalSnackFlow.emit(snack!!) +// Thread.sleep(50000) + val pageWithStrokes = + AppRepository(context).pageRepository.getWithStrokeByIdSuspend(id) + strokes = pageWithStrokes.strokes + indexingJob.cancelAndJoin() + indexStrokes() + computeHeight() + snack?.let { SnackState.cancelGlobalSnack.emit(it.id) } + } + Log.d(TAG, "All strokes loaded in $timeToLoad ms") + // Ensure strokes are fully loaded and visible before drawing them + // Switching to the Main thread guarantees that `strokes = pageWithStrokes.strokes` + // has completed and is accessible for rendering. + // Or at least I hope it does. + launch(Dispatchers.Main) { + //required to ensure that everything is visible by draw area. + launch(Dispatchers.Default) { + Log.d(TAG, "Strokes remaining loaded") + drawArea(viewRectangle) + DrawCanvas.refreshUi.emit(Unit) + } + } + } + Log.d(TAG, "Strokes drawn, in ${System.currentTimeMillis() - fromDatabase}") + } + + //TODO: Images loading + } + + fun addStrokes(strokesToAdd: List) { + strokes += strokesToAdd + strokesToAdd.forEach { + val bottomPlusPadding = it.bottom + 50 + if (bottomPlusPadding > height) height = bottomPlusPadding.toInt() + } + + saveStrokesToPersistLayer(strokesToAdd) + indexStrokes() + + persistBitmapDebounced() + } + + fun removeStrokes(strokeIds: List) { + strokes = strokes.filter { s -> !strokeIds.contains(s.id) } + removeStrokesFromPersistLayer(strokeIds) + indexStrokes() + computeHeight() + + persistBitmapDebounced() + } + + fun getStrokes(strokeIds: List): List { + return strokeIds.map { s -> strokesById[s] } + } + + private fun saveStrokesToPersistLayer(strokes: List) { + dbStrokes.create(strokes) + } + + private fun saveImagesToPersistLayer(image: List) { + dbImages.create(image) + } + + + fun addImage(imageToAdd: Image) { + images += listOf(imageToAdd) + val bottomPlusPadding = imageToAdd.x + imageToAdd.height + 50 + if (bottomPlusPadding > height) height = bottomPlusPadding + + saveImagesToPersistLayer(listOf(imageToAdd)) + indexImages() + + persistBitmapDebounced() + } + + fun addImage(imageToAdd: List) { + images += imageToAdd + imageToAdd.forEach { + val bottomPlusPadding = it.x + it.height + 50 + if (bottomPlusPadding > height) height = bottomPlusPadding + } + saveImagesToPersistLayer(imageToAdd) + indexImages() + + persistBitmapDebounced() + } + + fun removeImages(imageIds: List) { + images = images.filter { s -> !imageIds.contains(s.id) } + removeImagesFromPersistLayer(imageIds) + indexImages() + computeHeight() + + persistBitmapDebounced() + } + + fun getImage(imageId: String): Image? { + return imagesById[imageId] + } + + fun getImages(imageIds: List): List { + return imageIds.map { i -> imagesById[i] } + } + + + private fun computeHeight() { + if (strokes.isEmpty()) { + height = viewHeight + return + } + val maxStrokeBottom = strokes.maxOf { it.bottom }.plus(50) + height = max(maxStrokeBottom.toInt(), viewHeight) + } + + fun computeWidth(): Int { + if (strokes.isEmpty()) { + return viewWidth + } + val maxStrokeRight = strokes.maxOf { it.right }.plus(50) + return max(maxStrokeRight.toInt(), viewWidth) + } + + private fun removeStrokesFromPersistLayer(strokeIds: List) { + AppRepository(context).strokeRepository.deleteAll(strokeIds) + } + + private fun removeImagesFromPersistLayer(imageIds: List) { + AppRepository(context).imageRepository.deleteAll(imageIds) + } + + private fun loadBitmap(): Boolean { + val imgFile = File(context.filesDir, "pages/previews/full/$id") + val imgBitmap: Bitmap? + if (imgFile.exists()) { + imgBitmap = BitmapFactory.decodeFile(imgFile.absolutePath) + if (imgBitmap != null) { + windowedCanvas.drawBitmap(imgBitmap, 0f, 0f, Paint()) + Log.i(TAG, "Page rendered from cache") + // let's control that the last preview fits the present orientation. Otherwise we'll ask for a redraw. + if (imgBitmap.height == windowedCanvas.height && imgBitmap.width == windowedCanvas.width) { + return true + } else { + Log.i(TAG, "Image preview does not fit canvas area - redrawing") + } + } else { + Log.i(TAG, "Cannot read cache image") + } + } else { + Log.i(TAG, "Cannot find cache image") + } + return false + } + + private fun persistBitmap() { + val file = File(context.filesDir, "pages/previews/full/$id") + Files.createDirectories(Path(file.absolutePath).parent) + val os = BufferedOutputStream(FileOutputStream(file)) + windowedBitmap.compress(Bitmap.CompressFormat.PNG, 100, os) + os.close() + } + + private fun persistBitmapThumbnail() { + val file = File(context.filesDir, "pages/previews/thumbs/$id") + Files.createDirectories(Path(file.absolutePath).parent) + val os = BufferedOutputStream(FileOutputStream(file)) + val ratio = windowedBitmap.height.toFloat() / windowedBitmap.width.toFloat() + Bitmap.createScaledBitmap(windowedBitmap, 500, (500 * ratio).toInt(), false) + .compress(Bitmap.CompressFormat.JPEG, 80, os) + os.close() + } + + // ignored strokes are used in handleSelect + fun drawArea( + area: Rect, + ignoredStrokeIds: List = listOf(), + ignoredImageIds: List = listOf(), + canvas: Canvas? = null + ) { + val activeCanvas = canvas ?: windowedCanvas + val pageArea = Rect( + area.left, + area.top + scroll, + area.right, + area.bottom + scroll + ) + + + activeCanvas.withClip(area) { + drawColor(Color.BLACK) + + + val timeToDraw = measureTimeMillis { + drawBg(this, pageFromDb?.nativeTemplate ?: "blank", scroll) + val appSettings = GlobalAppSettings.current + + if (appSettings?.debugMode == true) { +// Draw the gray edge of the rectangle + val redPaint = Paint().apply { + color = Color.GRAY + style = Paint.Style.STROKE + strokeWidth = 4f + } + drawRect(area, redPaint) + } + // Trying to find what throws error when drawing quickly + try { + images.forEach { image -> + if (ignoredImageIds.contains(image.id)) return@forEach + Log.i(TAG, "PageView.kt: drawing image!") + val bounds = imageBounds(image) + // if stroke is not inside page section + if (!bounds.toRect().intersect(pageArea)) return@forEach + drawImage(context, this, image, IntOffset(0, -scroll)) + + } + } catch (e: Exception) { + Log.e(TAG, "PageView.kt: Drawing images failed: ${e.message}", e) + + val errorMessage = + if (e.message?.contains("does not have permission") == true) { + "Permission error: Unable to access image." + } else { + "Failed to load images." + } + showHint(errorMessage, coroutineScope) + } + try { + strokes.forEach { stroke -> + if (ignoredStrokeIds.contains(stroke.id)) return@forEach + val bounds = strokeBounds(stroke) + // if stroke is not inside page section + if (!bounds.toRect().intersect(pageArea)) return@forEach + + drawStroke( + this, stroke, IntOffset(0, -scroll) + ) + } + } catch (e: Exception) { + Log.e(TAG, "PageView.kt: Drawing strokes failed: ${e.message}", e) + showHint("Error drawing strokes", coroutineScope) + } + + } + Log.i(TAG, "Drew area in ${timeToDraw}ms") + } + } + + fun updateScroll(_delta: Int) { + var delta = _delta + if (scroll + delta < 0) delta = 0 - scroll + + // There is nothing to do, return. + if (delta == 0) return + + scroll += delta + + // scroll bitmap + val tmp = windowedBitmap.copy(windowedBitmap.config!!, false) + drawBg(windowedCanvas, pageFromDb?.nativeTemplate ?: "blank", scroll) + + windowedCanvas.drawBitmap(tmp, 0f, -delta.toFloat(), Paint()) + tmp.recycle() + + // where is the new rendering area starting ? + val canvasOffset = if (delta > 0) windowedCanvas.height - delta else 0 + + drawArea( + area = Rect( + 0, + canvasOffset, + windowedCanvas.width, + canvasOffset + abs(delta) + ), + ) + + persistBitmapDebounced() + saveToPersistLayer() + } + + // updates page setting in db, (for instance type of background) + // and redraws page to vew. + fun updatePageSettings(page: Page) { + AppRepository(context).pageRepository.update(page) + pageFromDb = AppRepository(context).pageRepository.getById(id) + drawArea(Rect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)) + persistBitmapDebounced() + } + + fun updateDimensions(newWidth: Int, newHeight: Int) { + if (newWidth != viewWidth || newHeight != viewHeight) { + viewWidth = newWidth + viewHeight = newHeight + + // Recreate bitmap and canvas with new dimensions + windowedBitmap = Bitmap.createBitmap(viewWidth, viewHeight, Bitmap.Config.ARGB_8888) + windowedCanvas = Canvas(windowedBitmap) + drawArea(Rect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)) + persistBitmapDebounced() + } + } + + private fun persistBitmapDebounced() { + coroutineScope.launch { + saveTopic.emit(Unit) + } + } + + private fun saveToPersistLayer() { + coroutineScope.launch { + AppRepository(context).pageRepository.updateScroll(id, scroll) + pageFromDb = AppRepository(context).pageRepository.getById(id) + } + } +} + diff --git a/app/src/main/java/com/ethran/notable/classes/SelectionState.kt b/app/src/main/java/com/ethran/notable/classes/SelectionState.kt new file mode 100644 index 00000000..afb8a878 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/classes/SelectionState.kt @@ -0,0 +1,235 @@ +package com.ethran.notable.classes + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Rect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.toOffset +import androidx.core.graphics.createBitmap +import com.ethran.notable.TAG +import com.ethran.notable.db.Image +import com.ethran.notable.db.Stroke +import com.ethran.notable.utils.Operation +import com.ethran.notable.utils.PlacementMode +import com.ethran.notable.utils.SimplePointF +import com.ethran.notable.utils.copyBitmapToClipboard +import com.ethran.notable.utils.drawImage +import com.ethran.notable.utils.imageBoundsInt +import com.ethran.notable.utils.offsetImage +import com.ethran.notable.utils.offsetStroke +import com.ethran.notable.utils.pageAreaToCanvasArea +import io.shipbook.shipbooksdk.Log +import kotlinx.coroutines.CoroutineScope +import java.util.Date +import java.util.UUID + + +class SelectionState { + var firstPageCut by mutableStateOf?>(null) + var secondPageCut by mutableStateOf?>(null) + var selectedStrokes by mutableStateOf?>(null) + var selectedImages by mutableStateOf?>(null) + var selectedBitmap by mutableStateOf(null) + var selectionStartOffset by mutableStateOf(null) + var selectionDisplaceOffset by mutableStateOf(null) + var selectionRect by mutableStateOf(null) + var placementMode by mutableStateOf(null) + + fun reset() { + selectedStrokes = null + selectedImages = null + secondPageCut = null + firstPageCut = null + selectedBitmap = null + selectionStartOffset = null + selectionRect = null + selectionDisplaceOffset = null + placementMode = null + } + + fun isResizable(): Boolean { + return selectedImages?.count() == 1 && selectedStrokes.isNullOrEmpty() + } + + fun resizeImages(scale: Int, scope: CoroutineScope, page: PageView) { + val selectedImagesCopy = selectedImages?.map { image -> + image.copy( + height = image.height + (image.height * scale / 100), + width = image.width + (image.width * scale / 100) + ) + } + + // Ensure selected images are not null or empty + if (selectedImagesCopy.isNullOrEmpty()) { + showHint("For now, strokes cannot be resized", scope) + return + } + + selectedImages = selectedImagesCopy + // Adjust displacement offset by half the size change + val sizeChange = selectedImagesCopy.firstOrNull()?.let { image -> + IntOffset( + x = (image.width * scale / 200), + y = (image.height * scale / 200) + ) + } ?: IntOffset.Zero + + val pageBounds = imageBoundsInt(selectedImagesCopy) + selectionRect = pageAreaToCanvasArea(pageBounds, page.scroll) + + selectionDisplaceOffset = + selectionDisplaceOffset?.let { it - sizeChange } + ?: IntOffset.Zero + + val selectedBitmapNew = createBitmap(pageBounds.width(), pageBounds.height()) + val selectedCanvas = Canvas(selectedBitmapNew) + selectedImagesCopy.forEach { + drawImage( + page.context, + selectedCanvas, + it, + IntOffset(-it.x, -it.y) + ) + } + + // set state + selectedBitmap = selectedBitmapNew + } + + @Suppress("UNUSED_PARAMETER") + fun resizeStrokes(scale: Int, scope: CoroutineScope, page: PageView) { + //TODO: implement this + } + + fun deleteSelection(page: PageView): List { + val operationList = listOf() + val selectedImagesToRemove = selectedImages + if (!selectedImagesToRemove.isNullOrEmpty()) { + val imageIds: List = selectedImagesToRemove.map { it.id } + Log.i(TAG, "removing images") + page.removeImages(imageIds) + } + val selectedStrokesToRemove = selectedStrokes + if (!selectedStrokesToRemove.isNullOrEmpty()) { + val strokeIds: List = selectedStrokesToRemove.map { it.id } + Log.i(TAG, "removing strokes") + page.removeStrokes(strokeIds) + operationList.plus(Operation.AddStroke(selectedStrokesToRemove)) + } + reset() + return operationList + } + + fun duplicateSelection() { + // set operation to paste only + placementMode = PlacementMode.Paste + if (!selectedStrokes.isNullOrEmpty()) + // change the selected stokes' ids - it's a copy + selectedStrokes = selectedStrokes!!.map { + it.copy( + id = UUID + .randomUUID() + .toString(), + createdAt = Date() + ) + } + if (!selectedImages.isNullOrEmpty()) + selectedImages = selectedImages!!.map { + it.copy( + id = UUID + .randomUUID() + .toString(), + createdAt = Date() + ) + } + // move the selection a bit, to show the copy + selectionDisplaceOffset = IntOffset( + x = selectionDisplaceOffset!!.x + 50, + y = selectionDisplaceOffset!!.y + 50, + ) + } + + fun applySelectionDisplace(page: PageView): List? { + + if (selectionDisplaceOffset == null) return null + if (selectionRect == null) return null + + // get snapshot of the selection + val selectedStrokesCopy = selectedStrokes + val selectedImagesCopy = selectedImages + val offset = selectionDisplaceOffset!! + val finalZone = Rect(selectionRect!!) + finalZone.offset(offset.x, offset.y) + + // collect undo operations for strokes and images together, as a single change + val operationList = mutableListOf() + + if (selectedStrokesCopy != null) { + val displacedStrokes = selectedStrokesCopy.map { + offsetStroke(it, offset = offset.toOffset()) + } + + if (placementMode == PlacementMode.Move) + page.removeStrokes(selectedStrokesCopy.map { it.id }) + + page.addStrokes(displacedStrokes) + page.drawArea(finalZone) + + + if (offset.x > 0 || offset.y > 0) { + // A displacement happened, we can create a history for this + operationList += Operation.DeleteStroke(displacedStrokes.map { it.id }) + // in case we are on a move operation, this history point re-adds the original strokes + if (placementMode == PlacementMode.Move) + operationList += Operation.AddStroke(selectedStrokesCopy) + } + } + if (selectedImagesCopy != null) { + Log.i(TAG, "Commit images to history.") + + val displacedImages = selectedImagesCopy.map { + offsetImage(it, offset = offset.toOffset()) + } + if (placementMode == PlacementMode.Move) + page.removeImages(selectedImagesCopy.map { it.id }) + + page.addImage(displacedImages) + page.drawArea(finalZone) + + if (offset.x != 0 || offset.y != 0) { + // TODO: find why sometimes we add two times same operation. + // A displacement happened, we can create a history for this + // To undo changes we first remove image + operationList += Operation.DeleteImage(displacedImages.map { it.id }) + // then add the original images, only if we intended to move it. + if (placementMode == PlacementMode.Move) + operationList += Operation.AddImage(selectedImagesCopy) + } + } + return operationList + } + + fun selectionToClipboard(scrollPos: Int, context: Context): ClipboardContent { + val removePageScroll = IntOffset(0, -scrollPos).toOffset() + + val strokes = selectedStrokes?.map { + offsetStroke(it, offset = removePageScroll) + } + + val images = selectedImages?.map { + it.copy(y = it.y - scrollPos) + } + + selectedBitmap?.let { + copyBitmapToClipboard(context, it) + } + return ClipboardContent( + strokes = strokes ?: emptyList(), + images = images ?: emptyList() + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/olup/notable/classes/SnackBar.kt b/app/src/main/java/com/ethran/notable/classes/SnackBar.kt similarity index 54% rename from app/src/main/java/com/olup/notable/classes/SnackBar.kt rename to app/src/main/java/com/ethran/notable/classes/SnackBar.kt index 7bcc5e1d..12706b5e 100644 --- a/app/src/main/java/com/olup/notable/classes/SnackBar.kt +++ b/app/src/main/java/com/ethran/notable/classes/SnackBar.kt @@ -1,22 +1,34 @@ -package com.olup.notable +package com.ethran.notable.classes -import io.shipbook.shipbooksdk.Log import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import com.ethran.notable.utils.noRippleClickable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import java.util.UUID -val SnackContext = staticCompositionLocalOf { SnackState() } +val LocalSnackContext = staticCompositionLocalOf { SnackState() } data class SnackConf( val id: String = UUID.randomUUID().toString(), @@ -29,14 +41,39 @@ data class SnackConf( class SnackState { val snackFlow = MutableSharedFlow() val cancelSnackFlow = MutableSharedFlow() - suspend fun displaySnack(conf: SnackConf) : suspend ()->Unit { + suspend fun displaySnack(conf: SnackConf): suspend () -> Unit { snackFlow.emit(conf) return suspend { removeSnack(conf.id) } } - suspend fun removeSnack(id: String) { + // TODO: check if this is a good approach, + // this does work, but I have doubts if it is a proper way for doing it + // Register Observers for Global Actions + companion object { + val globalSnackFlow = MutableSharedFlow() + val cancelGlobalSnack = MutableSharedFlow(extraBufferCapacity = 1) + } + + fun registerGlobalSnackObserver() { + CoroutineScope(Dispatchers.Main).launch { + globalSnackFlow.collect { + displaySnack(it) + } + } + } + + fun registerCancelGlobalSnackObserver() { + CoroutineScope(Dispatchers.Main).launch { + cancelGlobalSnack.collect { + removeSnack(it) + } + } + } + + + private suspend fun removeSnack(id: String) { cancelSnackFlow.emit(id) } } @@ -58,11 +95,11 @@ fun SnackBar(state: SnackState) { launch { state.snackFlow.collect { snack -> if (snack != null) { - getSnacks().add(snack!!) - if (snack!!.duration != null) { + getSnacks().add(snack) + if (snack.duration != null) { launch { - delay(snack!!.duration!!.toLong()) - getSnacks().removeIf { it.id == snack!!.id } + delay(snack.duration.toLong()) + getSnacks().removeIf { it.id == snack.id } } } } @@ -89,7 +126,7 @@ fun SnackBar(state: SnackState) { contentAlignment = Alignment.Center ) { if (it.text != null) { - Row() { + Row { Text(text = it.text, color = Color.White) if (it.actions != null && it.actions.isEmpty().not()) { it.actions.map { @@ -102,11 +139,22 @@ fun SnackBar(state: SnackState) { } } - } else if (it.content != null) { - it.content!!() + } else it.content?.let { content -> + content() } } } } } +} + +fun showHint(text: String, scope: CoroutineScope = CoroutineScope(Dispatchers.Default), duration: Int = 3000) { + scope.launch { + SnackState.globalSnackFlow.emit( + SnackConf( + text = text, + duration = duration, + ) + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/classes/XoppFile.kt b/app/src/main/java/com/ethran/notable/classes/XoppFile.kt new file mode 100644 index 00000000..1fad81d6 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/classes/XoppFile.kt @@ -0,0 +1,581 @@ +package com.ethran.notable.classes + +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.RectF +import android.net.Uri +import android.os.Environment +import android.os.Looper +import android.provider.MediaStore +import android.util.Base64 +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.core.graphics.toColorInt +import androidx.core.net.toUri +import com.ethran.notable.BuildConfig +import com.ethran.notable.SCREEN_HEIGHT +import com.ethran.notable.SCREEN_WIDTH +import com.ethran.notable.TAG +import com.ethran.notable.db.AppDatabase +import com.ethran.notable.db.BookRepository +import com.ethran.notable.db.Image +import com.ethran.notable.db.Notebook +import com.ethran.notable.db.Page +import com.ethran.notable.db.PageRepository +import com.ethran.notable.db.Stroke +import com.ethran.notable.db.StrokePoint +import com.ethran.notable.modals.A4_WIDTH +import com.ethran.notable.utils.Pen +import com.ethran.notable.utils.ensureImagesFolder +import com.onyx.android.sdk.api.device.epd.EpdController +import io.shipbook.shipbooksdk.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream +import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream +import org.w3c.dom.Document +import org.w3c.dom.Element +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.BufferedWriter +import java.io.ByteArrayInputStream +import java.io.File +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStreamWriter +import java.io.StringWriter +import java.util.UUID +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.OutputKeys +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult + + +object XoppFile { + private val scaleFactor = A4_WIDTH.toFloat() / SCREEN_WIDTH + private val maxPressure = EpdController.getMaxTouchPressure() + + //I do not know what pressureFactor should be, I just guest it. + private val pressureFactor = maxPressure / 2 + + /** + * Exports an entire book as a `.xopp` file. + * + * This method processes each page separately, writing the XML data + * to a temporary file to prevent excessive memory usage. After all + * pages are processed, the file is compressed into a `.xopp` format. + * + * @param context The application context. + * @param bookId The ID of the book to export. + */ + fun exportBook(context: Context, bookId: String) { + Log.v(TAG, "Exporting book $bookId") + if (Looper.getMainLooper().isCurrentThread) + Log.w(TAG, "Exporting is done on main thread.") + + val book = BookRepository(context).getById(bookId) + ?: return Log.e(TAG, "Book ID($bookId) not found") + + val fileName = book.title + val tempFile = File(context.cacheDir, "$fileName.xml") + + BufferedWriter( + OutputStreamWriter( + FileOutputStream(tempFile), + Charsets.UTF_8 + ) + ).use { writer -> + writer.write("\n") + writer.write("\n") + + book.pageIds.forEach { pageId -> + writePage(context, pageId, writer) + } + + writer.write("\n") + } + + saveAsXopp(context, tempFile, fileName) + } + + /** + * Exports page as a `.xopp` file. + */ + fun exportPage(context: Context, pageId: String) { + Log.v(TAG, "Exporting page $pageId") + val tempFile = File(context.cacheDir, "exported_page.xml") + + BufferedWriter( + OutputStreamWriter( + FileOutputStream(tempFile), + Charsets.UTF_8 + ) + ).use { writer -> + writer.write("\n") + writer.write("\n") + writePage(context, pageId, writer) + writer.write("\n") + } + + saveAsXopp(context, tempFile, "exported_page") + } + + + /** + * Writes a single page's XML data to the output stream. + * + * This method retrieves the strokes and images for the given page + * and writes them to the provided BufferedWriter. + * + * @param context The application context. + * @param pageId The ID of the page to process. + * @param writer The BufferedWriter to write XML data to. + */ + private fun writePage(context: Context, pageId: String, writer: BufferedWriter) { + val pages = PageRepository(context) + val (_, strokes) = pages.getWithStrokeById(pageId) + val (_, images) = pages.getWithImageById(pageId) + + val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument() + + val root = doc.createElement("page") + val strokeHeight = if (strokes.isEmpty()) 0 else strokes.maxOf(Stroke::bottom).toInt() + 50 + val height = strokeHeight.coerceAtLeast(SCREEN_HEIGHT) * scaleFactor + + root.setAttribute("width", A4_WIDTH.toString()) + root.setAttribute("height", height.toString()) + doc.appendChild(root) + + val bcgElement = doc.createElement("background") + bcgElement.setAttribute("type", "solid") + bcgElement.setAttribute("color", "#ffffffff") + bcgElement.setAttribute("style", "plain") + root.appendChild(bcgElement) + + + val layer = doc.createElement("layer") + root.appendChild(layer) + + + + for (stroke in strokes) { + val strokeElement = doc.createElement("stroke") + strokeElement.setAttribute("tool", stroke.pen.toString()) + strokeElement.setAttribute("color", getColorName(Color(stroke.color))) + val widthValues = mutableListOf(stroke.size * scaleFactor) + if (stroke.pen == Pen.FOUNTAIN || stroke.pen == Pen.BRUSH || stroke.pen == Pen.PENCIL) + widthValues += stroke.points.map { it.pressure / pressureFactor } + val widthString = widthValues.joinToString(" ") + + strokeElement.setAttribute("width", widthString) + + val pointsString = + stroke.points.joinToString(" ") { "${it.x * scaleFactor} ${it.y * scaleFactor}" } + strokeElement.textContent = pointsString + layer.appendChild(strokeElement) + } + + for (image in images) { + val imgElement = doc.createElement("image") + + val left = image.x * scaleFactor + val top = image.y * scaleFactor + val right = (image.x + image.width) * scaleFactor + val bottom = (image.y + image.height) * scaleFactor + + imgElement.setAttribute("left", left.toString()) + imgElement.setAttribute("top", top.toString()) + imgElement.setAttribute("right", right.toString()) + imgElement.setAttribute("bottom", bottom.toString()) + + image.uri?.let { uri -> + imgElement.setAttribute("filename", uri) + imgElement.textContent = convertImageToBase64(image.uri, context) + } + if (imgElement.textContent.isNotBlank()) + layer.appendChild(imgElement) + else + showHint("Image cannot be loaded.") + } + + val xmlString = convertXmlToString(doc) + writer.write(xmlString) + } + + + /** + * Opens a file and converts it to a base64 string. + */ + private fun convertImageToBase64(uri: String, context: Context): String { + return try { + val inputStream = context.contentResolver.openInputStream(uri.toUri()) + val bytes = inputStream?.readBytes() ?: return "" + Base64.encodeToString(bytes, Base64.DEFAULT) + } catch (e: SecurityException) { + Log.e("convertImageToBase64", "Permission denied: ${e.message}") + "" + } catch (e: FileNotFoundException) { + Log.e("convertImageToBase64", "File not found: ${e.message}") + "" + } catch (e: IOException) { + Log.e("convertImageToBase64", "I/O error: ${e.message}") + "" + } + } + + + /** + * Converts an XML Document to a formatted string without the XML declaration. + * + * This is used to convert an individual page's XML structure into a string + * before writing it to the output file. The XML declaration is removed to + * prevent duplicate headers when merging pages. + * + * @param document The XML Document to convert. + * @return The formatted XML string without the XML declaration. + */ + private fun convertXmlToString(document: Document): String { + val transformer = TransformerFactory.newInstance().newTransformer() + transformer.setOutputProperty(OutputKeys.INDENT, "yes") + transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes") // β Omit XML header + val writer = StringWriter() + transformer.transform(DOMSource(document), StreamResult(writer)) + return writer.toString().trim() // Remove extra spaces or newlines + } + + + /** + * Saves a temporary XML file as a compressed `.xopp` file. + * + * @param context The application context. + * @param file The temporary XML file to compress. + * @param fileName The name of the final `.xopp` file. + */ + private fun saveAsXopp(context: Context, file: File, fileName: String) { + val contentValues = ContentValues().apply { + put(MediaStore.Files.FileColumns.DISPLAY_NAME, "$fileName.xopp") + put(MediaStore.Files.FileColumns.MIME_TYPE, "application/x-xopp") + put( + MediaStore.Files.FileColumns.RELATIVE_PATH, + Environment.DIRECTORY_DOCUMENTS + "/Notable/" + ) + } + + val resolver = context.contentResolver + val uri = resolver.insert(MediaStore.Files.getContentUri("external"), contentValues) + ?: throw IOException("Failed to create Media Store entry") + + resolver.openOutputStream(uri)?.use { outputStream -> + GzipCompressorOutputStream(BufferedOutputStream(outputStream)).use { gzipOutputStream -> + file.inputStream().copyTo(gzipOutputStream) + } + } ?: throw IOException("Failed to open output stream") + + file.delete() + } + + + /** + * Imports a `.xopp` file, creating a new book and pages in the database. + * + * @param context The application context. + * @param uri The URI of the `.xopp` file to import. + */ + fun importBook(context: Context, uri: Uri, parentFolderId: String?) { + Log.v(TAG, "Importing book from $uri, into $parentFolderId") + if (Looper.getMainLooper().isCurrentThread) + Log.e(TAG, "Importing is done on main thread.") + val inputStream = context.contentResolver.openInputStream(uri) ?: return + val xmlContent = extractXmlFromXopp(inputStream) ?: return + + val document = parseXml(xmlContent) ?: return + val bookTitle = getBookTitle(uri) + val bookRepo = BookRepository(context) + val pageRepo = PageRepository(context) + + val book = Notebook( + title = bookTitle, + parentFolderId = parentFolderId, + defaultNativeTemplate = "blank" + ) + bookRepo.createEmpty(book) + + val pages = document.getElementsByTagName("page") + + for (i in 0 until pages.length) { + val pageElement = pages.item(i) as Element + val page = Page(notebookId = book.id, nativeTemplate = "blank") + pageRepo.create(page) + parseStrokes(context, pageElement, page) + parseImages(context, pageElement, page) + + bookRepo.addPage(book.id, page.id) + } + Log.i(TAG, "Successfully imported book '${book.title}' with ${pages.length} pages.") + } + + /** + * Extracts XML content from a `.xopp` file. + */ + private fun extractXmlFromXopp(inputStream: InputStream): String? { + return try { + GzipCompressorInputStream(BufferedInputStream(inputStream)).bufferedReader() + .use { it.readText() } + } catch (e: IOException) { + Log.e(TAG, "Error extracting XML from .xopp file: ${e.message}") + null + } + } + + /** + * Parses an XML string into a DOM Document. + */ + private fun parseXml(xml: String): Document? { + return try { + val factory = DocumentBuilderFactory.newInstance() + factory.isNamespaceAware = true + val builder = factory.newDocumentBuilder() + builder.parse(ByteArrayInputStream(xml.toByteArray(Charsets.UTF_8))) + } catch (e: Exception) { + Log.e(TAG, "Error parsing XML: ${e.message}") + null + } + } + + /** + * Extracts strokes from a page element and saves them. + */ + private fun parseStrokes(context: Context, pageElement: Element, page: Page) { + val strokeRepo = AppDatabase.getDatabase(context).strokeDao() + val strokeNodes = pageElement.getElementsByTagName("stroke") + val strokes = mutableListOf() + + + for (i in 0 until strokeNodes.length) { + val strokeElement = strokeNodes.item(i) as Element + val pointsString = strokeElement.textContent.trim() + + if (pointsString.isBlank()) continue // Skip empty strokes + + // Decode stroke attributes +// val strokeSize = strokeElement.getAttribute("width").toFloatOrNull()?.div(scaleFactor) ?: 1.0f + val color = parseColor(strokeElement.getAttribute("color")) + + + // Decode width attribute + val widthString = strokeElement.getAttribute("width").trim() + val widthValues = widthString.split(" ").mapNotNull { it.toFloatOrNull() } + + val strokeSize = + widthValues.firstOrNull()?.div(scaleFactor) ?: 1.0f // First value is stroke width + val pressureValues = widthValues.drop(1) // Remaining values are pressure + + + val points = pointsString.split(" ").chunked(2).mapIndexedNotNull { index, chunk -> + try { + StrokePoint( + x = chunk[0].toFloat() / scaleFactor, + y = chunk[1].toFloat() / scaleFactor, + pressure = pressureValues.getOrNull(index - 1)?.times(pressureFactor) + ?: (maxPressure / 2), + size = strokeSize, + tiltX = 0, + tiltY = 0, + timestamp = System.currentTimeMillis() + ) + } catch (e: Exception) { + Log.e(TAG, "Error parsing stroke point: ${e.message}") + null + } + } + if (points.isEmpty()) continue // Skip strokes without valid points + + val boundingBox = RectF() + + val decodedPoints = points.mapIndexed { index, it -> + if (index == 0) boundingBox.set(it.x, it.y, it.x, it.y) else boundingBox.union( + it.x, + it.y + ) + it + } + + boundingBox.inset(-strokeSize, -strokeSize) + val toolName = strokeElement.getAttribute("tool") + val tool = Pen.fromString(toolName) + + val stroke = Stroke( + size = strokeSize, + pen = tool, // TODO: change this to proper pen + pageId = page.id, + top = boundingBox.top, + bottom = boundingBox.bottom, + left = boundingBox.left, + right = boundingBox.right, + points = decodedPoints, + color = android.graphics.Color.argb( + (color.alpha * 255).toInt(), + (color.red * 255).toInt(), + (color.green * 255).toInt(), + (color.blue * 255).toInt() + ) + ) + strokes.add(stroke) + } + strokeRepo.create(strokes) + } + + + /** + * Extracts images from a page element and saves them. + */ + private fun parseImages( + context: Context, + pageElement: Element, + page: Page + ) { + val imageRepo = AppDatabase.getDatabase(context).ImageDao() + val imageNodes = pageElement.getElementsByTagName("image") + val images = mutableListOf() + + for (i in 0 until imageNodes.length) { + val imageElement = imageNodes.item(i) as? Element ?: continue + val base64Data = imageElement.textContent.trim() + + if (base64Data.isBlank()) continue // Skip empty image data + + try { + // Extract position attributes + val left = + imageElement.getAttribute("left").toFloatOrNull()?.div(scaleFactor) ?: continue + val top = + imageElement.getAttribute("top").toFloatOrNull()?.div(scaleFactor) ?: continue + val right = + imageElement.getAttribute("right").toFloatOrNull()?.div(scaleFactor) ?: continue + val bottom = imageElement.getAttribute("bottom").toFloatOrNull()?.div(scaleFactor) + ?: continue + + // Decode Base64 to Bitmap + val imageUri = decodeAndSave(base64Data) ?: continue + + // Create Image object and add it to the list + val image = Image( + x = left.toInt(), + y = top.toInt(), + width = (right - left).toInt(), + height = (bottom - top).toInt(), + uri = imageUri.toString(), + pageId = page.id + ) + images.add(image) + + } catch (e: Exception) { + Log.e("ImageProcessing", "Error parsing image: ${e.message}") + } + } + + // Save images in the repository + if (images.isNotEmpty()) { + imageRepo.create(images) + } + } + + /** + * Decodes a Base64 image string, saves it as a file, and returns the URI. + */ + private fun decodeAndSave(base64String: String): Uri? { + return try { + // Decode Base64 to ByteArray + val decodedBytes = Base64.decode(base64String, Base64.DEFAULT) + val bitmap = + BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size) ?: return null + + // Ensure the directory exists + val outputDir = ensureImagesFolder() + + // Generate a unique and safe file name + val fileName = "image_${UUID.randomUUID()}.png" + val outputFile = File(outputDir, fileName) + + // Save the bitmap to the file + FileOutputStream(outputFile).use { fos -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos) + } + + // Return the file URI + Uri.fromFile(outputFile) + } catch (e: IOException) { + Log.e(TAG, "Error decoding and saving image: ${e.message}") + null + } + } + + /** + * Extracts the book title from a file URI. + */ + private fun getBookTitle(uri: Uri): String { + return uri.lastPathSegment?.substringAfterLast("/")?.removeSuffix(".xopp") + ?: "Imported Book" + } + + /** + * Parses an Xournal++ color string to a Compose Color. + */ + private fun parseColor(colorString: String): Color { + return when (colorString.lowercase()) { + "black" -> Color.Black + "blue" -> Color.Blue + "red" -> Color.Red + "green" -> Color.Green + "magenta" -> Color.Magenta + "yellow" -> Color.Yellow + // Convert "#RRGGBBAA" β "#AARRGGBB" β Android Color + else -> { + if (colorString.startsWith("#") && colorString.length == 9) + Color( + ("#" + colorString.substring(7, 9) + + colorString.substring(1, 7)).toColorInt() + ) + else { + Log.e(TAG, "Unknown color: $colorString") + Color.Black + } + } + } + } + + /** + * Maps a Compose Color to an Xournal++ color name. + * + * @param color The Compose Color object. + * @return The corresponding color name as a string. + */ + private fun getColorName(color: Color): String { + return when (color) { + Color.Black -> "black" + Color.Blue -> "blue" + Color.Red -> "red" + Color.Green -> "green" + Color.Magenta -> "magenta" + Color.Yellow -> "yellow" + Color.DarkGray, Color.Gray -> "gray" + else -> { + val argb = color.toArgb() + // Convert ARGB (Android default) β RGBA + String.format( + "#%02X%02X%02X%02X", + (argb shr 16) and 0xFF, // Red + (argb shr 8) and 0xFF, // Green + (argb) and 0xFF, // Blue + (argb shr 24) and 0xFF // Alpha + ) + } + } + } +} diff --git a/app/src/main/java/com/ethran/notable/components/BreadCrumb.kt b/app/src/main/java/com/ethran/notable/components/BreadCrumb.kt new file mode 100644 index 00000000..3027cf44 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/components/BreadCrumb.kt @@ -0,0 +1,50 @@ +package com.ethran.notable.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextDecoration +import com.ethran.notable.db.Folder +import com.ethran.notable.db.FolderRepository +import com.ethran.notable.utils.noRippleClickable +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight + +@Composable +fun BreadCrumb(folderId: String? = null, onSelectFolderId: (String?) -> Unit) { + val context = LocalContext.current + + fun getFolderList(folderId: String): List { + @Suppress("USELESS_ELVIS") + val folder = FolderRepository(context).get(folderId) ?: return emptyList() + val folderList = mutableListOf(folder) + + val parentId = folder.parentFolderId + if (parentId != null) { + folderList.addAll(getFolderList(parentId)) + } + + return folderList + } + + Row { + Text( + text = "Library", + textDecoration = TextDecoration.Underline, + modifier = Modifier.noRippleClickable { onSelectFolderId(null) }) + if (folderId != null) { + val folders = getFolderList(folderId).reversed() + + folders.map { f -> + Icon(imageVector = FeatherIcons.ChevronRight, contentDescription = "") + Text( + text = f.title, + textDecoration = TextDecoration.Underline, + modifier = Modifier.noRippleClickable { onSelectFolderId(f.id) }) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/components/ConfirmationDialog.kt b/app/src/main/java/com/ethran/notable/components/ConfirmationDialog.kt new file mode 100644 index 00000000..af485565 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/components/ConfirmationDialog.kt @@ -0,0 +1,135 @@ +package com.ethran.notable.components + +import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.ethran.notable.classes.SnackConf +import com.ethran.notable.classes.SnackState +import com.ethran.notable.classes.XoppFile +import com.ethran.notable.modals.ActionButton +import com.ethran.notable.utils.exportBook +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + + +@Composable +fun ShowConfirmationDialog( + title: String, + message: String, + onConfirm: () -> Unit, + onCancel: () -> Unit +) { + Dialog(onDismissRequest = { onCancel() }) { + Column( + modifier = Modifier + .background(Color.White) + .border(1.dp, Color.Black, RectangleShape) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = title, fontWeight = FontWeight.Bold, fontSize = 20.sp) + Text(text = message, fontSize = 16.sp) + Row( + horizontalArrangement = Arrangement.SpaceAround, + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + ) { + ActionButton(text = "Cancel", onClick = onCancel) + ActionButton(text = "Confirm", onClick = onConfirm) + } + } + } +} + +@Composable +fun ShowExportDialog( + snackManager: SnackState, + bookId: String, + context: Context, + onConfirm: () -> Unit, + onCancel: () -> Unit +) { + Dialog(onDismissRequest = { onCancel() }) { + Column( + modifier = Modifier + .background(Color.White) + .border(1.dp, Color.Black, RectangleShape) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = "Choose Export Format", fontWeight = FontWeight.Bold, fontSize = 20.sp) + Text( + text = "Select the format in which you want to export the book:\n" + + "- Xopp: Preserves all data and can be imported. " + + "However, if opened and saved by Xournal++, tool-specific information will be lost, " + + "and all strokes will be interpreted as ballpoint pen.\n" + + "- PDF: A standard format for document sharing.", + fontSize = 16.sp + ) + Row( + horizontalArrangement = Arrangement.SpaceAround, + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + ) { + ActionButton( + text = "Cancel", + onClick = onCancel + ) + ActionButton( + text = "Export as PDF", + onClick = { + CoroutineScope(Dispatchers.IO).launch { + val removeSnack = snackManager.displaySnack( + SnackConf( + text = "Exporting book to PDF...", + id = "exportSnack" + ) + ) + val message = exportBook(context, bookId) + removeSnack() + snackManager.displaySnack( + SnackConf(text = message, duration = 2000) + ) + } + onConfirm() + }) + ActionButton( + text = "Export as Xopp", + onClick = { + CoroutineScope(Dispatchers.IO).launch { + val removeSnack = snackManager.displaySnack( + SnackConf( + text = "Exporting book to Xopp format...", + id = "exportSnack" + ) + ) + XoppFile.exportBook(context, bookId) + removeSnack() + } + onConfirm() + } + ) + } + } + } +} diff --git a/app/src/main/java/com/ethran/notable/components/EditorGestureReceiver.kt b/app/src/main/java/com/ethran/notable/components/EditorGestureReceiver.kt new file mode 100644 index 00000000..bf359dbf --- /dev/null +++ b/app/src/main/java/com/ethran/notable/components/EditorGestureReceiver.kt @@ -0,0 +1,386 @@ +package com.ethran.notable.components + +import android.graphics.Rect +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.ethran.notable.TAG +import com.ethran.notable.classes.DrawCanvas +import com.ethran.notable.classes.EditorControlTower +import com.ethran.notable.classes.GestureState +import com.ethran.notable.classes.showHint +import com.ethran.notable.modals.AppSettings +import com.ethran.notable.modals.GlobalAppSettings +import com.ethran.notable.utils.EditorState +import com.ethran.notable.utils.History +import com.ethran.notable.utils.Mode +import com.ethran.notable.utils.UndoRedoType +import io.shipbook.shipbooksdk.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.abs + + +private const val HOLD_THRESHOLD_MS = 300 +private const val ONE_FINGER_TOUCH_TAP_TIME = 100L +private const val TAP_MOVEMENT_TOLERANCE = 15f +private const val SWIPE_THRESHOLD = 200f +private const val DOUBLE_TAP_TIMEOUT_MS = 170L +private const val DOUBLE_TAP_MIN_MS = 20L +private const val TWO_FINGER_TOUCH_TAP_MAX_TIME = 200L +private const val TWO_FINGER_TOUCH_TAP_MIN_TIME = 20L +private const val TWO_FINGER_TAP_MOVEMENT_TOLERANCE = 20f + + +@Composable +@ExperimentalComposeUiApi +fun EditorGestureReceiver( + goToNextPage: () -> Unit, + goToPreviousPage: () -> Unit, + controlTower: EditorControlTower, + state: EditorState +) { + + val coroutineScope = rememberCoroutineScope() + val appSettings = remember { GlobalAppSettings.current } + var crossPosition by remember { mutableStateOf(null) } + var rectangleBounds by remember { mutableStateOf(null) } + var isSelection by remember { mutableStateOf(false) } + val view = LocalView.current + Box( + modifier = Modifier + .pointerInput(Unit) { + awaitEachGesture { + try { + // Detect initial touch + val down = awaitFirstDown() + // testing if it will fixed exception: + // kotlinx.coroutines.CompletionHandlerException: Exception in resume + // onCancellation handler for CancellableContinuation(DispatchedContinuation[AndroidUiDispatcher@145d639, + // Continuation at androidx.compose.foundation.gestures.PressGestureScopeImpl.reset(TapGestureDetector.kt:357) + // @8b7a2c]){Completed}@4a49cf5 + if (!coroutineScope.isActive) return@awaitEachGesture + // if window lost focus, ignore input + if (!view.hasWindowFocus()) return@awaitEachGesture + + val gestureState = GestureState() + if (!state.isDrawing && !isSelection) { + state.isDrawing = true + } + isSelection = false + + // Ignore non-touch input + if (down.type != PointerType.Touch) { + Log.i(TAG, "Ignoring non-touch input") + return@awaitEachGesture + } + gestureState.initialTimestamp = System.currentTimeMillis() + gestureState.insertPosition(down) + + do { + // wait for second gesture + val event = withTimeoutOrNull(1000L) { awaitPointerEvent() } + if (!coroutineScope.isActive) return@awaitEachGesture + // if window lost focus, ignore input + if (!view.hasWindowFocus()) return@awaitEachGesture + + if (event != null) { + val fingerChange = + event.changes.filter { it.type == PointerType.Touch } + + // is already consumed return + if (fingerChange.find { it.isConsumed } != null) { + Log.i(TAG, "Canceling gesture - already consumed") + crossPosition = null + rectangleBounds = null + return@awaitEachGesture + } + fingerChange.forEach { change -> + // Consume changes and update positions + change.consume() + gestureState.insertPosition(change) + } + if (fingerChange.any { !it.pressed }) { + gestureState.lastTimestamp = System.currentTimeMillis() + break + } + } + // events are only send on change, so we need to check for holding in place separately + gestureState.lastTimestamp = System.currentTimeMillis() + if (isSelection) { + crossPosition = gestureState.getLastPositionIO() + rectangleBounds = gestureState.calculateRectangleBounds() + } else if (gestureState.getElapsedTime() >= HOLD_THRESHOLD_MS && gestureState.getInputCount() == 1) { + if (gestureState.calculateTotalDelta() < TAP_MOVEMENT_TOLERANCE) { + isSelection = true + crossPosition = gestureState.getLastPositionIO() + rectangleBounds = gestureState.calculateRectangleBounds() + showHint("Selection mode!", coroutineScope, 1500) + } + + } + } while (true) + + if (isSelection) { + resolveGesture( + settings = appSettings, + default = AppSettings.defaultHoldAction, + override = AppSettings::holdAction, + state = state, + scope = coroutineScope, + previousPage = goToPreviousPage, + nextPage = goToNextPage, + rectangle = rectangleBounds!! + ) + crossPosition = null + rectangleBounds = null + return@awaitEachGesture + } + // Calculate the total delta (movement distance) for all pointers + val totalDelta = gestureState.calculateTotalDelta() + val gestureDuration = gestureState.getElapsedTime() + Log.v( + TAG, + "Leaving gesture. totalDelta: ${totalDelta}, gestureDuration: $gestureDuration " + ) + if (!coroutineScope.isActive) return@awaitEachGesture + // if window lost focus, ignore input + if (!view.hasWindowFocus()) return@awaitEachGesture + + + if (gestureState.getInputCount() == 1) { + if (totalDelta < TAP_MOVEMENT_TOLERANCE && gestureDuration < ONE_FINGER_TOUCH_TAP_TIME) { + if (withTimeoutOrNull(DOUBLE_TAP_TIMEOUT_MS) { + val secondDown = awaitFirstDown() + val deltaTime = + System.currentTimeMillis() - gestureState.lastTimestamp + Log.v( + TAG, + "Second down detected: ${secondDown.type}, position: ${secondDown.position}, deltaTime: $deltaTime" + ) + if (deltaTime < DOUBLE_TAP_MIN_MS) { + showHint( + text = "Too quick for double click! delta: $totalDelta, time between: $deltaTime", + coroutineScope + ) + return@withTimeoutOrNull null + } else { + Log.v(TAG, "double click!") + } + if (secondDown.type != PointerType.Touch) { + Log.i( + TAG, + "Ignoring non-touch input during double-tap detection" + ) + return@withTimeoutOrNull null + } + resolveGesture( + settings = appSettings, + default = AppSettings.defaultDoubleTapAction, + override = AppSettings::doubleTapAction, + state = state, + scope = coroutineScope, + previousPage = goToPreviousPage, + nextPage = goToNextPage, + ) + + + } != null) return@awaitEachGesture + } + } else if (gestureState.getInputCount() == 2) { + Log.v(TAG, "Two finger tap") + if (totalDelta < TWO_FINGER_TAP_MOVEMENT_TOLERANCE && + gestureDuration < TWO_FINGER_TOUCH_TAP_MAX_TIME && + gestureDuration > TWO_FINGER_TOUCH_TAP_MIN_TIME + ) { + resolveGesture( + settings = appSettings, + default = AppSettings.defaultTwoFingerTapAction, + override = AppSettings::twoFingerTapAction, + state = state, + scope = coroutineScope, + previousPage = goToPreviousPage, + nextPage = goToNextPage, + ) + } + } + + val horizontalDrag = gestureState.getHorizontalDrag() + val verticalDrag = gestureState + .getVerticalDrag() + .toInt() + + Log.v(TAG, "horizontalDrag $horizontalDrag, verticalDrag $verticalDrag") + when { + horizontalDrag < -SWIPE_THRESHOLD -> { + resolveGesture( + settings = appSettings, + default = if (gestureState.getInputCount() == 1) AppSettings.defaultSwipeLeftAction else AppSettings.defaultTwoFingerSwipeLeftAction, + override = if (gestureState.getInputCount() == 1) AppSettings::swipeLeftAction else AppSettings::twoFingerSwipeLeftAction, + state = state, + scope = coroutineScope, + previousPage = goToPreviousPage, + nextPage = goToNextPage, + ) + } + + horizontalDrag > SWIPE_THRESHOLD -> { + resolveGesture( + settings = appSettings, + default = if (gestureState.getInputCount() == 1) AppSettings.defaultSwipeRightAction else AppSettings.defaultTwoFingerSwipeRightAction, + override = if (gestureState.getInputCount() == 1) AppSettings::swipeRightAction else AppSettings::twoFingerSwipeRightAction, + state = state, + scope = coroutineScope, + previousPage = goToPreviousPage, + nextPage = goToNextPage, + ) + } + + } + + if (abs(verticalDrag) > SWIPE_THRESHOLD && gestureState.getInputCount() == 1) { + controlTower.onSingleFingerVerticalSwipe( + gestureState.getFirstPositionF()!!, + verticalDrag + ) + } + } catch (e: CancellationException) { + Log.w(TAG, "Gesture coroutine canceled", e) + } catch (e: Exception) { + Log.e(TAG, "Unexpected error in gesture handling", e) + } + } + } + + .fillMaxWidth() + .fillMaxHeight() + ) { + val density = LocalDensity.current + // Draw cross where finger is touching + DrawCross(crossPosition, density) + // Draw the rectangle while dragging + DrawRectangle(rectangleBounds, density) + } +} + +@Composable +private fun DrawRectangle(rectangleBounds: Rect?, density: Density) { + rectangleBounds?.let { bounds -> + // Draw the rectangle + Box( + Modifier + .offset { IntOffset(bounds.left, bounds.top) } + .size( + width = with(density) { (bounds.right - bounds.left).toDp() }, + height = with(density) { (bounds.bottom - bounds.top).toDp() } + ) + // Is there rendering speed difference between colors? + .background(Color(0x55000000)) + .border(1.dp, Color.Black) + ) + } + +} + +@Composable +private fun DrawCross(crossPosition: IntOffset?, density: Density) { + + // Draw cross where finger is touching + crossPosition?.let { pos -> + val crossSizePx = with(density) { 100.dp.toPx() } + Box( + Modifier + .offset { + IntOffset( + pos.x - (crossSizePx / 2).toInt(), + pos.y + ) + } // Horizontal bar centered + .size(width = 100.dp, height = 2.dp) + .background(Color.Black) + ) + Box( + Modifier + .offset { + IntOffset( + pos.x, + pos.y - (crossSizePx / 2).toInt() + ) + } // Vertical bar centered + .size(width = 2.dp, height = 100.dp) + .background(Color.Black) + ) + } +} + +private fun resolveGesture( + settings: AppSettings?, + default: AppSettings.GestureAction, + override: AppSettings.() -> AppSettings.GestureAction?, + state: EditorState, + scope: CoroutineScope, + previousPage: () -> Unit, + nextPage: () -> Unit, + rectangle: Rect = Rect() +) { + when (if (settings != null) override(settings) else default) { + null -> Log.i(TAG, "No Action") + AppSettings.GestureAction.PreviousPage -> previousPage() + AppSettings.GestureAction.NextPage -> nextPage() + + AppSettings.GestureAction.ChangeTool -> + state.mode = if (state.mode == Mode.Draw) Mode.Erase else Mode.Draw + + AppSettings.GestureAction.ToggleZen -> + state.isToolbarOpen = !state.isToolbarOpen + + AppSettings.GestureAction.Undo -> { + Log.i(TAG, "Undo") + scope.launch { + History.moveHistory(UndoRedoType.Undo) +// moved to history operation - avoids unnecessary refresh, and ensures that it will be done after drawing. +// DrawCanvas.refreshUi.emit(Unit) + } + } + + AppSettings.GestureAction.Redo -> { + Log.i(TAG, "Redo") + scope.launch { + History.moveHistory(UndoRedoType.Redo) +// DrawCanvas.refreshUi.emit(Unit) + } + } + + AppSettings.GestureAction.Select -> { + Log.i(TAG, "select") + scope.launch { + DrawCanvas.rectangleToSelect.emit(rectangle) + } + } + } +} + diff --git a/app/src/main/java/com/olup/notable/components/EditorSurface.kt b/app/src/main/java/com/ethran/notable/components/EditorSurface.kt similarity index 56% rename from app/src/main/java/com/olup/notable/components/EditorSurface.kt rename to app/src/main/java/com/ethran/notable/components/EditorSurface.kt index 3a7c2a4d..3f8b8326 100644 --- a/app/src/main/java/com/olup/notable/components/EditorSurface.kt +++ b/app/src/main/java/com/ethran/notable/components/EditorSurface.kt @@ -1,20 +1,26 @@ -package com.olup.notable +package com.ethran.notable.components -import io.shipbook.shipbooksdk.Log import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView +import com.ethran.notable.classes.DrawCanvas +import com.ethran.notable.utils.EditorState +import com.ethran.notable.TAG +import com.ethran.notable.classes.PageView +import com.ethran.notable.utils.History +import io.shipbook.shipbooksdk.Log @Composable @ExperimentalComposeUiApi fun EditorSurface( - state: EditorState, page : PageView, history: History + state: EditorState, page: PageView, history: History ) { - val couroutineScope = rememberCoroutineScope() + val coroutineScope = rememberCoroutineScope() Log.i(TAG, "recompose surface") Box( @@ -24,7 +30,7 @@ fun EditorSurface( ) { AndroidView(factory = { ctx -> - DrawCanvas(ctx, couroutineScope, state, page, history ).apply { + DrawCanvas(ctx, coroutineScope, state, page, history).apply { init() registerObservers() } diff --git a/app/src/main/java/com/olup/notable/components/EraserToolbarButton.kt b/app/src/main/java/com/ethran/notable/components/EraserToolbarButton.kt similarity index 80% rename from app/src/main/java/com/olup/notable/components/EraserToolbarButton.kt rename to app/src/main/java/com/ethran/notable/components/EraserToolbarButton.kt index 2f8555eb..a11e3691 100644 --- a/app/src/main/java/com/olup/notable/components/EraserToolbarButton.kt +++ b/app/src/main/java/com/ethran/notable/components/EraserToolbarButton.kt @@ -1,4 +1,4 @@ -package com.olup.notable +package com.ethran.notable.components import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -6,7 +6,12 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -15,6 +20,10 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import com.ethran.notable.utils.Eraser +import com.ethran.notable.R +import com.ethran.notable.modals.BUTTON_SIZE +import com.ethran.notable.utils.convertDpToPixel @Composable fun EraserToolbarButton( @@ -65,13 +74,13 @@ fun EraserToolbarButton( iconId = R.drawable.eraser, isSelected = value == Eraser.PEN, onSelect = { onChange(Eraser.PEN) }, - modifier = Modifier.height(37.dp) + modifier = Modifier.height(BUTTON_SIZE.dp) ) ToolbarButton( iconId = R.drawable.eraser_select, isSelected = value == Eraser.SELECT, onSelect = { onChange(Eraser.SELECT) }, - modifier = Modifier.height(37.dp) + modifier = Modifier.height(BUTTON_SIZE.dp) ) } diff --git a/app/src/main/java/com/ethran/notable/components/FolderSelectionDialog.kt b/app/src/main/java/com/ethran/notable/components/FolderSelectionDialog.kt new file mode 100644 index 00000000..d13232fd --- /dev/null +++ b/app/src/main/java/com/ethran/notable/components/FolderSelectionDialog.kt @@ -0,0 +1,112 @@ +package com.ethran.notable.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.ethran.notable.classes.AppRepository +import com.ethran.notable.db.Notebook +import com.ethran.notable.modals.ActionButton + + +@Composable +fun ShowFolderSelectionDialog( + book: Notebook, + notebookName: String, + initialFolderId: String?, + onCancel: () -> Unit, + onConfirm: (String?) -> Unit +) { + val appRepository = AppRepository(LocalContext.current) + + + var currentFolderId by remember { mutableStateOf(initialFolderId) } + val availableFolders by appRepository.folderRepository.getAllInFolder(currentFolderId) + .observeAsState() + val currentFolderName = currentFolderId?.let { + appRepository.folderRepository.get(it).title + } ?: "Library" + val parentFolder = appRepository.folderRepository.getParent(currentFolderId) + + Dialog(onDismissRequest = { onCancel() }) { + Column( + modifier = Modifier + .background(Color.White) + .border(1.dp, Color.Black, RectangleShape) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Choose folder to move \"$notebookName\":", + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + modifier = Modifier.padding(bottom = 8.dp) + ) + BreadCrumb(currentFolderId) { currentFolderId = it } + // Folder List + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { currentFolderId = parentFolder } + .padding(2.dp) + .background(if (currentFolderId == parentFolder) Color.LightGray else Color.Transparent), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "..", fontSize = 16.sp, fontWeight = FontWeight.Normal) + } + + // List Folders + availableFolders?.forEach { folder -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + currentFolderId = folder.id // Navigate into child folder + } + .padding(8.dp) + .background(if (currentFolderId == folder.id) Color.LightGray else Color.Transparent), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = folder.title, fontSize = 16.sp, fontWeight = FontWeight.Normal) + } + } + } + + // Cancel and Confirm Buttons + Row( + horizontalArrangement = Arrangement.SpaceAround, + modifier = Modifier.fillMaxWidth() + ) { + ActionButton("Cancel", onClick = onCancel) + ActionButton("Confirm", onClick = { onConfirm(currentFolderId) }) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/components/LineToolbarButton.kt b/app/src/main/java/com/ethran/notable/components/LineToolbarButton.kt new file mode 100644 index 00000000..e814e895 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/components/LineToolbarButton.kt @@ -0,0 +1,37 @@ +package com.ethran.notable.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.ethran.notable.TAG +import io.shipbook.shipbooksdk.Log + +@Composable +fun LineToolbarButton( + icon: Int, + isSelected: Boolean, + onSelect: () -> Unit, + unSelect: () -> Unit +) { + + Box { + + ToolbarButton( + isSelected = isSelected, + onSelect = { + if (isSelected) { + // If it's already selected, deselect it + Log.d(TAG, "Deselecting line") + unSelect() + } else { + // Otherwise, select it + Log.d(TAG, "Selecting line") + onSelect() + } + }, + penColor = Color.LightGray, + iconId = icon, + contentDescription = "Lines!" + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/olup/notable/components/PageMenu.kt b/app/src/main/java/com/ethran/notable/components/PageMenu.kt similarity index 76% rename from app/src/main/java/com/olup/notable/components/PageMenu.kt rename to app/src/main/java/com/ethran/notable/components/PageMenu.kt index 54b0808a..e7c9a032 100644 --- a/app/src/main/java/com/olup/notable/components/PageMenu.kt +++ b/app/src/main/java/com/ethran/notable/components/PageMenu.kt @@ -1,8 +1,12 @@ -package com.olup.notable +package com.ethran.notable.components import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -13,8 +17,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties -import com.olup.notable.AppRepository -import com.olup.notable.db.Page +import com.ethran.notable.classes.AppRepository +import com.ethran.notable.db.Page +import com.ethran.notable.utils.deletePage +import com.ethran.notable.utils.noRippleClickable @Composable @@ -43,7 +49,7 @@ fun PageMenu( Modifier .padding(10.dp) .noRippleClickable { - appRepository.bookRepository.changeePageIndex( + appRepository.bookRepository.changePageIndex( notebookId, pageId, index - 1 @@ -57,7 +63,7 @@ fun PageMenu( Modifier .padding(10.dp) .noRippleClickable { - appRepository.bookRepository.changeePageIndex( + appRepository.bookRepository.changePageIndex( notebookId, pageId, index + 1 @@ -69,8 +75,12 @@ fun PageMenu( Modifier .padding(10.dp) .noRippleClickable { - val book = appRepository.bookRepository.getById(notebookId) ?: return@noRippleClickable - val page = Page(notebookId = notebookId, nativeTemplate = book.defaultNativeTemplate) + val book = appRepository.bookRepository.getById(notebookId) + ?: return@noRippleClickable + val page = Page( + notebookId = notebookId, + nativeTemplate = book.defaultNativeTemplate + ) appRepository.pageRepository.create(page) appRepository.bookRepository.addPage(notebookId, page.id, index + 1) }) { diff --git a/app/src/main/java/com/olup/notable/components/PagePreview.kt b/app/src/main/java/com/ethran/notable/components/PagePreview.kt similarity index 61% rename from app/src/main/java/com/olup/notable/components/PagePreview.kt rename to app/src/main/java/com/ethran/notable/components/PagePreview.kt index 30cfb2be..4b26acc3 100644 --- a/app/src/main/java/com/olup/notable/components/PagePreview.kt +++ b/app/src/main/java/com/ethran/notable/components/PagePreview.kt @@ -1,32 +1,28 @@ -package com.olup.notable +package com.ethran.notable.components import android.graphics.Bitmap import android.graphics.BitmapFactory import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.composed import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp import coil.compose.rememberAsyncImagePainter import java.io.File @Composable -fun PagePreview(modifier: Modifier, pageId: String){ +fun PagePreview(modifier: Modifier, pageId: String) { val context = LocalContext.current - val imgFile = remember() { + val imgFile = remember (pageId){ File(context.filesDir, "pages/previews/thumbs/$pageId") } var imgBitmap: Bitmap? = null if (imgFile.exists()) { - imgBitmap = remember { + imgBitmap = remember(pageId) { BitmapFactory.decodeFile(imgFile.absolutePath) } } @@ -36,11 +32,11 @@ fun PagePreview(modifier: Modifier, pageId: String){ Modifier.padding(10.dp) )) }else {*/ - Image( - painter = rememberAsyncImagePainter(model = imgBitmap), - contentDescription = "Image", - contentScale = ContentScale.FillWidth, - modifier = modifier.then(Modifier.background(Color.LightGray)) - ) + Image( + painter = rememberAsyncImagePainter(model = imgBitmap), + contentDescription = "Image", + contentScale = ContentScale.FillWidth, + modifier = modifier.then(Modifier.background(Color.LightGray)) + ) //} } \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/components/PenToolbarButton.kt b/app/src/main/java/com/ethran/notable/components/PenToolbarButton.kt new file mode 100644 index 00000000..f654d664 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/components/PenToolbarButton.kt @@ -0,0 +1,67 @@ +package com.ethran.notable.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import com.ethran.notable.utils.Pen +import com.ethran.notable.utils.PenSetting + +@Composable +fun PenToolbarButton( + pen: Pen, + icon: Int, + isSelected: Boolean, + onSelect: () -> Unit, + sizes: List>, + penSetting: PenSetting, + onChangeSetting: (PenSetting) -> Unit, + onStrokeMenuOpenChange: ((Boolean) -> Unit)? = null +) { + var isStrokeMenuOpen by remember { mutableStateOf(false) } + + if (onStrokeMenuOpenChange != null) { + LaunchedEffect(isStrokeMenuOpen) { + onStrokeMenuOpenChange(isStrokeMenuOpen) + } + } + + + Box { + + ToolbarButton( + isSelected = isSelected, + onSelect = { + if (isSelected) isStrokeMenuOpen = !isStrokeMenuOpen + else onSelect() + }, + penColor = Color(penSetting.color), + iconId = icon, + contentDescription = pen.penName + ) + + if (isStrokeMenuOpen) { + StrokeMenu( + value = penSetting, + onChange = { onChangeSetting(it) }, + onClose = { isStrokeMenuOpen = false }, + sizeOptions = sizes, + colorOptions = listOf( + Color.Red, + Color.Green, + Color.Blue, + Color.Cyan, + Color.Magenta, + Color.Yellow, + Color.Gray, + Color.DarkGray, + Color.Black, + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/olup/notable/components/QuickNav.kt b/app/src/main/java/com/ethran/notable/components/QuickNav.kt similarity index 50% rename from app/src/main/java/com/olup/notable/components/QuickNav.kt rename to app/src/main/java/com/ethran/notable/components/QuickNav.kt index d330ed06..85740cda 100644 --- a/app/src/main/java/com/olup/notable/components/QuickNav.kt +++ b/app/src/main/java/com/ethran/notable/components/QuickNav.kt @@ -1,4 +1,4 @@ -package com.olup.notable +package com.ethran.notable.components import android.annotation.SuppressLint import androidx.compose.foundation.background @@ -6,20 +6,31 @@ import androidx.compose.foundation.border import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.key -import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.navigation.NavController -import com.olup.notable.AppRepository +import com.ethran.notable.classes.AppRepository +import com.ethran.notable.modals.GlobalAppSettings +import com.ethran.notable.utils.noRippleClickable import compose.icons.FeatherIcons import compose.icons.feathericons.Home import compose.icons.feathericons.Plus @@ -34,12 +45,8 @@ fun QuickNav(navController: NavController, onClose: () -> Unit) { val pageId = currentBackStackEntry?.arguments?.getString("pageId") val kv = appRepository.kvProxy - val settings by kv.observeKv("APP_SETTINGS", AppSettings.serializer(), AppSettings(version = 1)) - .observeAsState() + val settings = remember { GlobalAppSettings.current } - fun setSettings(settings: AppSettings) { - kv.setKv("APP_SETTINGS", settings, AppSettings.serializer()) - } val pages = settings?.quickNavPages ?: listOf() @@ -72,35 +79,30 @@ fun QuickNav(navController: NavController, onClose: () -> Unit) { Row { ToolbarButton( - imageVector = FeatherIcons.Home, - onSelect = { - val parentFolder = if(pageId != null) appRepository.pageRepository.getById(pageId)?.parentFolderId else null + imageVector = FeatherIcons.Home, onSelect = { + val parentFolder = + if (pageId != null) appRepository.pageRepository.getById(pageId)?.parentFolderId else null navController.navigate( - route = if(parentFolder != null) "library?folderId=${parentFolder}" else "library" + route = if (parentFolder != null) "library?folderId=${parentFolder}" else "library" ) onClose() - } - ) + }) if (pageId != null && !pages.contains(pageId)) { Spacer(modifier = Modifier.width(5.dp)) ToolbarButton( - imageVector = FeatherIcons.Plus, - onSelect = { + imageVector = FeatherIcons.Plus, onSelect = { if (settings == null) return@ToolbarButton if (settings!!.quickNavPages.contains(pageId)) return@ToolbarButton - setSettings( - settings!!.copy(quickNavPages = pages + pageId) - ) - } - ) + kv.setAppSettings(settings!!.copy(quickNavPages = pages + pageId)) + }) } } - - if (!pages.isEmpty()) Spacer(modifier = Modifier.height(10.dp)) - Row() { + if (pages.isNotEmpty()) Spacer(modifier = Modifier.height(10.dp)) + + Row { LazyVerticalGrid( modifier = Modifier.fillMaxWidth(), columns = GridCells.Adaptive(minSize = 80.dp), @@ -110,29 +112,27 @@ fun QuickNav(navController: NavController, onClose: () -> Unit) { items(pages.reversed()) { thisPageId -> key(thisPageId) { - PagePreview(modifier = Modifier - .border(1.dp, Color.Black) - .fillMaxWidth() - .aspectRatio(3f / 4f) - .noRippleClickable { - val bookId = - appRepository.pageRepository.getById(thisPageId)?.notebookId - val url = - if (bookId == null) "pages/${thisPageId}" else "books/${bookId}/pages/${thisPageId}" - navController.navigate(url) - onClose() - } - .draggable( - orientation = Orientation.Vertical, - onDragStopped = { - if (settings == null) return@draggable - setSettings( - settings!!.copy(quickNavPages = pages.filterNot { it == thisPageId }) - ) - }, - state = rememberDraggableState(onDelta = {}) - ), - pageId = thisPageId + PagePreview( + modifier = Modifier + .border(1.dp, Color.Black) + .fillMaxWidth() + .aspectRatio(3f / 4f) + .noRippleClickable { + val bookId = + appRepository.pageRepository.getById(thisPageId)?.notebookId + val url = + if (bookId == null) "pages/${thisPageId}" else "books/${bookId}/pages/${thisPageId}" + navController.navigate(url) + onClose() + } + .draggable( + orientation = Orientation.Vertical, + onDragStopped = { + if (settings == null) return@draggable + kv.setAppSettings(settings!!.copy(quickNavPages = pages.filterNot { it == thisPageId })) + }, + state = rememberDraggableState(onDelta = {}) + ), pageId = thisPageId ) } } diff --git a/app/src/main/java/com/olup/notable/components/ScrollIndicator.kt b/app/src/main/java/com/ethran/notable/components/ScrollIndicator.kt similarity index 56% rename from app/src/main/java/com/olup/notable/components/ScrollIndicator.kt rename to app/src/main/java/com/ethran/notable/components/ScrollIndicator.kt index e313ecb3..c40b9abf 100644 --- a/app/src/main/java/com/olup/notable/components/ScrollIndicator.kt +++ b/app/src/main/java/com/ethran/notable/components/ScrollIndicator.kt @@ -1,22 +1,34 @@ -package com.olup.notable +package com.ethran.notable.components import android.content.Context +import io.shipbook.shipbooksdk.Log import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import com.ethran.notable.utils.EditorState +import com.ethran.notable.TAG +import com.ethran.notable.utils.convertDpToPixel import kotlin.math.max @Composable fun ScrollIndicator(context: Context, state: EditorState) { - BoxWithConstraints(modifier = Modifier.width(5.dp).fillMaxHeight()) { + BoxWithConstraints(modifier = Modifier + .width(5.dp) + .fillMaxHeight()) { val height = convertDpToPixel(this.maxHeight, LocalContext.current).toInt() val page = state.pageView - println(page.scroll + height) - println(page.height) + Log.d(TAG, "Scroll + Height: ${page.scroll + height}") + Log.d(TAG, "Page Height: ${page.height}") val virtualHeight = max(page.height, page.scroll + height) if (virtualHeight <= height) return@BoxWithConstraints @@ -29,7 +41,8 @@ fun ScrollIndicator(context: Context, state: EditorState) { Box( modifier = Modifier .fillMaxWidth() - .offset(y = indicatorPosition.dp + .offset( + y = indicatorPosition.dp ) .background(Color.Black) .height( diff --git a/app/src/main/java/com/olup/notable/components/Select.kt b/app/src/main/java/com/ethran/notable/components/Select.kt similarity index 69% rename from app/src/main/java/com/olup/notable/components/Select.kt rename to app/src/main/java/com/ethran/notable/components/Select.kt index 453ed500..77c990c9 100644 --- a/app/src/main/java/com/olup/notable/components/Select.kt +++ b/app/src/main/java/com/ethran/notable/components/Select.kt @@ -1,30 +1,38 @@ -package com.olup.notable.components +package com.ethran.notable.components import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ArrowDropDown -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material.icons.sharp.Edit -import androidx.compose.material.icons.sharp.KeyboardArrowDown -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup -import com.olup.notable.noRippleClickable +import com.ethran.notable.utils.noRippleClickable @Composable -fun SelectMenu(options: List>, value: String, onChange: (String) -> Unit) { +fun SelectMenu(options: List>, value: T, onChange: (T) -> Unit) { var isExpanded by remember { mutableStateOf(false) } - Box() { - Row() { + Box { + Row { Text(text = options.find { it.first == value }?.second ?: "Undefined", fontWeight = FontWeight.Light, modifier = Modifier.noRippleClickable { isExpanded = true }) @@ -52,11 +60,10 @@ fun SelectMenu(options: List>, value: String, onChange: (St .noRippleClickable { onChange(it.first) isExpanded = false - }) + } + ) } } - } } - } \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/components/SelectorBitmap.kt b/app/src/main/java/com/ethran/notable/components/SelectorBitmap.kt new file mode 100644 index 00000000..13f2f7c2 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/components/SelectorBitmap.kt @@ -0,0 +1,188 @@ +package com.ethran.notable.components + +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.round +import com.ethran.notable.R +import com.ethran.notable.TAG +import com.ethran.notable.classes.EditorControlTower +import com.ethran.notable.modals.BUTTON_SIZE +import com.ethran.notable.utils.EditorState +import com.ethran.notable.utils.noRippleClickable +import com.ethran.notable.utils.shareBitmap +import compose.icons.FeatherIcons +import compose.icons.feathericons.Clipboard +import compose.icons.feathericons.Copy +import compose.icons.feathericons.Scissors +import compose.icons.feathericons.Share2 +import io.shipbook.shipbooksdk.Log + +val strokeStyle = androidx.compose.ui.graphics.drawscope.Stroke( + width = 2f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f) +) + +@Composable +@ExperimentalComposeUiApi +@ExperimentalFoundationApi +fun SelectedBitmap( + context: Context, + editorState: EditorState, + controlTower: EditorControlTower +) { + val selectionState = editorState.selectionState + if (selectionState.selectedBitmap == null) return + if (selectionState.selectionDisplaceOffset == null) { + Log.e(TAG, "SelectedBitmap: selectionDisplaceOffset is null") + return + } + Box( + Modifier + .fillMaxSize() + .noRippleClickable { + controlTower.applySelectionDisplace() + selectionState.reset() + editorState.isDrawing = true + }) { + Image( + bitmap = selectionState.selectedBitmap!!.asImageBitmap(), + contentDescription = "Selection bitmap", + modifier = Modifier + .offset { + if (selectionState.selectionStartOffset == null) return@offset IntOffset( + 0, + 0 + ) // guard + selectionState.selectionStartOffset!! + selectionState.selectionDisplaceOffset!! + } + .drawBehind { + drawRect( + color = Color.Gray, + topLeft = Offset(0f, 0f), + size = size, + style = strokeStyle + ) + } + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + change.consume() + //TODO: Sometimes its null, when handling images, for now I added some logs. + if (selectionState.selectionDisplaceOffset == null) { + Log.e(TAG, "selectionDisplaceOffset is null, probably was dissected") + Toast.makeText( + context, + "Please report issue if something went wrong with handling selection.", + Toast.LENGTH_LONG + ).show() + return@detectDragGestures + } + selectionState.selectionDisplaceOffset = + selectionState.selectionDisplaceOffset!! + dragAmount.round() + } + } + .combinedClickable( + indication = null, interactionSource = remember { MutableInteractionSource() }, + onClick = {}, + onDoubleClick = { controlTower.duplicateSelection() } + ) + ) + + // TODO: improve this code + + val buttonCount = if (selectionState.isResizable()) 7 else 5 + val toolbarPadding = 4; + + // If we can calculate offset of buttons show selection handling tools + selectionState.selectionStartOffset?.let { startOffset -> + selectionState.selectionDisplaceOffset?.let { displaceOffset -> + // TODO: I think the toolbar is still not in the center. + val xPos = selectionState.selectionRect?.let { rect -> + (rect.right - rect.left)/2 - buttonCount * (BUTTON_SIZE + 5* toolbarPadding) + } ?: 0 + val offset = startOffset + displaceOffset + IntOffset(x = xPos, y = -100) + // Overlay buttons near the selection box + Row( + modifier = Modifier + .offset { offset } + .background(Color.White.copy(alpha = 0.8f)) + .padding(toolbarPadding.dp) + .height(BUTTON_SIZE.dp) + ) { + ToolbarButton( + vectorIcon = FeatherIcons.Share2, + isSelected = false, + onSelect = { + shareBitmap(context, editorState.selectionState.selectedBitmap!!) + }, + modifier = Modifier.height(BUTTON_SIZE.dp) + ) + ToolbarButton( + iconId = R.drawable.delete, + isSelected = false, + onSelect = { + controlTower.deleteSelection() + }, + modifier = Modifier.height(BUTTON_SIZE.dp) + ) + if (selectionState.isResizable()) { + ToolbarButton( + iconId = R.drawable.plus, + isSelected = false, + onSelect = { controlTower.changeSizeOfSelection(10) }, + modifier = Modifier.height(BUTTON_SIZE.dp) + ) + ToolbarButton( + iconId = R.drawable.minus, + isSelected = false, + onSelect = { controlTower.changeSizeOfSelection(-10) }, + modifier = Modifier.height(BUTTON_SIZE.dp) + ) + } + ToolbarButton( + vectorIcon = FeatherIcons.Scissors, + isSelected = false, + onSelect = { controlTower.cutSelectionToClipboard(context) }, + modifier = Modifier.height(BUTTON_SIZE.dp) + ) + ToolbarButton( + vectorIcon = FeatherIcons.Clipboard, + isSelected = false, + onSelect = { controlTower.copySelectionToClipboard(context) }, + modifier = Modifier.height(BUTTON_SIZE.dp) + ) + ToolbarButton( + vectorIcon = FeatherIcons.Copy, + isSelected = false, + onSelect = { controlTower.duplicateSelection() }, + modifier = Modifier.height(BUTTON_SIZE.dp) + ) + } + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/components/StrokeMenu.kt b/app/src/main/java/com/ethran/notable/components/StrokeMenu.kt new file mode 100644 index 00000000..39c133a8 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/components/StrokeMenu.kt @@ -0,0 +1,106 @@ +package com.ethran.notable.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import com.ethran.notable.utils.PenSetting +import com.ethran.notable.utils.convertDpToPixel + +@Composable +fun StrokeMenu( + value: PenSetting, + onChange: (setting: PenSetting) -> Unit, + onClose: () -> Unit, + sizeOptions: List>, + colorOptions: List, +) { + val context = LocalContext.current + + Popup( + offset = IntOffset(0, convertDpToPixel(43.dp, context).toInt()), onDismissRequest = { + onClose() + }, properties = PopupProperties(focusable = true), alignment = Alignment.TopCenter + ) { + + Column { + // Color Selection Section + Row( + Modifier + .background(Color.White) + .border(1.dp, Color.Black) + .height(IntrinsicSize.Max) + ) { + colorOptions.map { color -> + Box( + modifier = Modifier + .size(40.dp) + .background(color) + .border( + 3.dp, + if (color == Color(value.color)) Color.Black else Color.Transparent + ) + .clickable { + onChange( + PenSetting( + strokeSize = value.strokeSize, + color = android.graphics.Color.argb( + (color.alpha * 255).toInt(), + (color.red * 255).toInt(), + (color.green * 255).toInt(), + (color.blue * 255).toInt() + ) + ) + ) + } + .padding(8.dp) + ) + } + } + + Spacer(Modifier.height(4.dp)) + + Row( + modifier = Modifier + .background(Color.White) + .border(1.dp, Color.Black) + .align(Alignment.CenterHorizontally), + horizontalArrangement = Arrangement.Center + ) { + sizeOptions.forEach { + ToolbarButton( + text = it.first, + isSelected = value.strokeSize == it.second, + onSelect = { + onChange( + PenSetting( + strokeSize = it.second, + color = value.color + ) + ) + }, + modifier = Modifier + ) + } + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/components/Toolbar.kt b/app/src/main/java/com/ethran/notable/components/Toolbar.kt new file mode 100644 index 00000000..ea5ef570 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/components/Toolbar.kt @@ -0,0 +1,478 @@ +package com.ethran.notable.components + + +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.navigation.NavController +import com.ethran.notable.R +import com.ethran.notable.classes.AppRepository +import com.ethran.notable.classes.DrawCanvas +import com.ethran.notable.classes.EditorControlTower +import com.ethran.notable.modals.AppSettings +import com.ethran.notable.modals.BUTTON_SIZE +import com.ethran.notable.modals.GlobalAppSettings +import com.ethran.notable.modals.PageSettingsModal +import com.ethran.notable.utils.EditorState +import com.ethran.notable.utils.History +import com.ethran.notable.utils.Mode +import com.ethran.notable.utils.Pen +import com.ethran.notable.utils.PenSetting +import com.ethran.notable.utils.UndoRedoType +import com.ethran.notable.utils.createFileFromContentUri +import com.ethran.notable.utils.noRippleClickable +import compose.icons.FeatherIcons +import compose.icons.feathericons.Clipboard +import compose.icons.feathericons.EyeOff +import io.shipbook.shipbooksdk.Log +import kotlinx.coroutines.launch + +fun presentlyUsedToolIcon(mode: Mode, pen: Pen): Int { + return when (mode) { + Mode.Draw -> { + when (pen) { + Pen.BALLPEN -> R.drawable.ballpen + Pen.REDBALLPEN -> R.drawable.ballpenred + Pen.BLUEBALLPEN -> R.drawable.ballpenblue + Pen.GREENBALLPEN -> R.drawable.ballpengreen + Pen.FOUNTAIN -> R.drawable.fountain + Pen.BRUSH -> R.drawable.brush + Pen.MARKER -> R.drawable.marker + Pen.PENCIL -> R.drawable.pencil + } + } + + Mode.Erase -> R.drawable.eraser + Mode.Select -> R.drawable.lasso + Mode.Line -> R.drawable.line + } +} + +fun isSelected(state: EditorState, penType: Pen): Boolean { + return if (state.mode == Mode.Draw && state.pen == penType) { + true + } else if (state.mode == Mode.Line && state.pen == penType) { + true + } else { + false + } +} + +@Composable +@ExperimentalComposeUiApi +fun Toolbar( + navController: NavController, state: EditorState, controlTower: EditorControlTower +) { + val scope = rememberCoroutineScope() + var isStrokeSelectionOpen by remember { mutableStateOf(false) } + var isMenuOpen by remember { mutableStateOf(false) } + var isPageSettingsModalOpen by remember { mutableStateOf(false) } + + val context = LocalContext.current + + + // Create a remembered variable to track whether an image is loaded + var isImageLoaded by remember { mutableStateOf(false) } + + // Create an activity result launcher for picking visual media (images in this case) + val pickMedia = + rememberLauncherForActivityResult(contract = PickVisualMedia()) { uri -> + uri?.let { + // Grant read URI permission to access the selected URI + val flag = Intent.FLAG_GRANT_READ_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(uri, flag) + + // copy image to documents/notabledb/images/filename + val copiedFile = createFileFromContentUri(context, uri) + + // Set isImageLoaded to true + isImageLoaded = true + Log.i( + "InsertImage", + "Image was received and copied, it is now at:${copiedFile.toUri()}" + ) + DrawCanvas.addImageByUri.value = copiedFile.toUri() + + } + } + + LaunchedEffect(isMenuOpen) { + state.isDrawing = !isMenuOpen + } + + fun handleChangePen(pen: Pen) { + if (state.mode == Mode.Draw && state.pen == pen) { + isStrokeSelectionOpen = true + } else { + state.mode = Mode.Draw + state.pen = pen + } + } + + fun handleEraser() { + state.mode = Mode.Erase + } + + fun handleSelection() { + state.mode = Mode.Select + } + + fun handleLine() { + state.mode = Mode.Line + } + + fun onChangeStrokeSetting(penName: String, setting: PenSetting) { + val settings = state.penSettings.toMutableMap() + settings[penName] = setting.copy() + state.penSettings = settings + } + + if (isPageSettingsModalOpen) { + PageSettingsModal(pageView = state.pageView) { + isPageSettingsModalOpen = false + } + } + if (state.isToolbarOpen) { + Column( + modifier = Modifier + .fillMaxWidth() + .height((BUTTON_SIZE + 51).dp) + .padding(bottom = 50.dp) // TODO: fix this + ) { + if (GlobalAppSettings.current.toolbarPosition == AppSettings.Position.Bottom) { + Box( + Modifier + .fillMaxWidth() + .height(1.dp) + .background(Color.Black) + ) + } + Row( + Modifier + .background(Color.White) + .height(BUTTON_SIZE.dp) + .fillMaxWidth() + ) { + ToolbarButton( + onSelect = { + state.isToolbarOpen = !state.isToolbarOpen + }, vectorIcon = FeatherIcons.EyeOff, contentDescription = "close toolbar" + ) + Box( + Modifier + .fillMaxHeight() + .width(0.5.dp) + .background(Color.Black) + ) + + PenToolbarButton( + onStrokeMenuOpenChange = { state.isDrawing = !it }, + pen = Pen.BALLPEN, + icon = R.drawable.ballpen, + isSelected = isSelected(state, Pen.BALLPEN), + onSelect = { handleChangePen(Pen.BALLPEN) }, + sizes = listOf("S" to 3f, "M" to 5f, "L" to 10f, "XL" to 20f), + penSetting = state.penSettings[Pen.BALLPEN.penName] ?: return, + onChangeSetting = { onChangeStrokeSetting(Pen.BALLPEN.penName, it) }) + + PenToolbarButton( + onStrokeMenuOpenChange = { state.isDrawing = !it }, + pen = Pen.REDBALLPEN, + icon = R.drawable.ballpenred, + isSelected = isSelected(state, Pen.REDBALLPEN), + onSelect = { handleChangePen(Pen.REDBALLPEN) }, + sizes = listOf("S" to 3f, "M" to 5f, "L" to 10f, "XL" to 20f), + penSetting = state.penSettings[Pen.REDBALLPEN.penName] ?: return, + onChangeSetting = { onChangeStrokeSetting(Pen.REDBALLPEN.penName, it) }, + ) + + PenToolbarButton( + onStrokeMenuOpenChange = { state.isDrawing = !it }, + pen = Pen.BLUEBALLPEN, + icon = R.drawable.ballpenblue, + isSelected = isSelected(state, Pen.BLUEBALLPEN), + onSelect = { handleChangePen(Pen.BLUEBALLPEN) }, + sizes = listOf("S" to 3f, "M" to 5f, "L" to 10f, "XL" to 20f), + penSetting = state.penSettings[Pen.BLUEBALLPEN.penName] ?: return, + onChangeSetting = { onChangeStrokeSetting(Pen.BLUEBALLPEN.penName, it) }, + ) +// Removed to make space for insert tool + PenToolbarButton( + onStrokeMenuOpenChange = { state.isDrawing = !it }, + pen = Pen.GREENBALLPEN, + icon = R.drawable.ballpengreen, + isSelected = isSelected(state, Pen.GREENBALLPEN), + onSelect = { handleChangePen(Pen.GREENBALLPEN) }, + sizes = listOf("S" to 3f, "M" to 5f, "L" to 10f, "XL" to 20f), + penSetting = state.penSettings[Pen.GREENBALLPEN.penName] ?: return, + onChangeSetting = { onChangeStrokeSetting(Pen.GREENBALLPEN.penName, it) }, + ) + + if (GlobalAppSettings.current.neoTools) { + PenToolbarButton( + onStrokeMenuOpenChange = { state.isDrawing = !it }, + pen = Pen.PENCIL, + icon = R.drawable.pencil, + isSelected = isSelected(state, Pen.PENCIL), + onSelect = { handleChangePen(Pen.PENCIL) }, // Neo-tool! Usage not recommended + sizes = listOf("S" to 3f, "M" to 5f, "L" to 10f, "XL" to 20f), + penSetting = state.penSettings[Pen.PENCIL.penName] ?: return, + onChangeSetting = { onChangeStrokeSetting(Pen.PENCIL.penName, it) }, + ) + + PenToolbarButton( + onStrokeMenuOpenChange = { state.isDrawing = !it }, + pen = Pen.BRUSH, + icon = R.drawable.brush, + isSelected = isSelected(state, Pen.BRUSH), + onSelect = { handleChangePen(Pen.BRUSH) }, // Neo-tool! Usage not recommended + sizes = listOf("S" to 3f, "M" to 5f, "L" to 10f, "XL" to 20f), + penSetting = state.penSettings[Pen.BRUSH.penName] ?: return, + onChangeSetting = { onChangeStrokeSetting(Pen.BRUSH.penName, it) }, + ) + } + PenToolbarButton( + onStrokeMenuOpenChange = { state.isDrawing = !it }, + pen = Pen.FOUNTAIN, + icon = R.drawable.fountain, + isSelected = isSelected(state, Pen.FOUNTAIN), + onSelect = { handleChangePen(Pen.FOUNTAIN) },// Neo-tool! Usage not recommended + sizes = listOf("S" to 3f, "M" to 5f, "L" to 10f, "XL" to 20f), + penSetting = state.penSettings[Pen.FOUNTAIN.penName] ?: return, + onChangeSetting = { onChangeStrokeSetting(Pen.FOUNTAIN.penName, it) }, + ) + + LineToolbarButton( + unSelect = { state.mode = Mode.Draw }, + icon = R.drawable.line, + isSelected = state.mode == Mode.Line, + onSelect = { handleLine() }, + ) + + Box( + Modifier + .fillMaxHeight() + .width(0.5.dp) + .background(Color.Black) + ) + + PenToolbarButton( + onStrokeMenuOpenChange = { state.isDrawing = !it }, + pen = Pen.MARKER, + icon = R.drawable.marker, + isSelected = isSelected(state, Pen.MARKER), + onSelect = { handleChangePen(Pen.MARKER) }, + sizes = listOf("L" to 40f, "XL" to 60f), + penSetting = state.penSettings[Pen.MARKER.penName] ?: return, + onChangeSetting = { onChangeStrokeSetting(Pen.MARKER.penName, it) }) + Box( + Modifier + .fillMaxHeight() + .width(0.5.dp) + .background(Color.Black) + ) + EraserToolbarButton( + isSelected = state.mode == Mode.Erase, + onSelect = { + handleEraser() + }, + onMenuOpenChange = { isStrokeSelectionOpen = it }, + value = state.eraser, + onChange = { state.eraser = it }) + Box( + Modifier + .fillMaxHeight() + .width(0.5.dp) + .background(Color.Black) + ) + ToolbarButton( + isSelected = state.mode == Mode.Select, + onSelect = { handleSelection() }, + iconId = R.drawable.lasso, + contentDescription = "lasso" + ) + Box( + Modifier + .fillMaxHeight() + .width(0.5.dp) + .background(Color.Black) + ) + + ToolbarButton( + iconId = R.drawable.image, + contentDescription = "library", + onSelect = { + // Call insertImage when the button is tapped + Log.i("InsertImage", "Launching image picker...") + pickMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) + } + ) + Box( + Modifier + .fillMaxHeight() + .width(0.5.dp) + .background(Color.Black) + ) + + if (state.clipboard != null) { + ToolbarButton( + vectorIcon = FeatherIcons.Clipboard, + contentDescription = "paste", + onSelect = { + controlTower.pasteFromClipboard() + } + ) + Box( + Modifier + .fillMaxHeight() + .width(0.5.dp) + .background(Color.Black) + ) + } + + Spacer(Modifier.weight(1f)) + + Box( + Modifier + .fillMaxHeight() + .width(0.5.dp) + .background(Color.Black) + ) + + ToolbarButton( + onSelect = { + scope.launch { + History.moveHistory(UndoRedoType.Undo) + DrawCanvas.refreshUi.emit(Unit) + } + }, + iconId = R.drawable.undo, + contentDescription = "undo" + ) + + ToolbarButton( + onSelect = { + scope.launch { + History.moveHistory(UndoRedoType.Redo) + DrawCanvas.refreshUi.emit(Unit) + } + }, + iconId = R.drawable.redo, + contentDescription = "redo" + ) + + Box( + Modifier + .fillMaxHeight() + .width(0.5.dp) + .background(Color.Black) + ) + + if (state.bookId != null) { + val book = AppRepository(context).bookRepository.getById(state.bookId) + + // TODO maybe have generic utils for this ? + val pageNumber = book!!.pageIds.indexOf(state.pageId) + 1 + val totalPageNumber = book.pageIds.size + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .height(35.dp) + .padding(10.dp, 0.dp) + ) { + Text( + text = "${pageNumber}/${totalPageNumber}", + fontWeight = FontWeight.Light, + modifier = Modifier.noRippleClickable { + navController.navigate("books/${state.bookId}/pages") + }, + textAlign = TextAlign.Center + ) + } + Box( + Modifier + .fillMaxHeight() + .width(0.5.dp) + .background(Color.Black) + ) + } + // Add Library Button + ToolbarButton( + iconId = R.drawable.home, // Replace with your library icon resource + contentDescription = "library", + onSelect = { + navController.navigate("library") // Navigate to main library + } + ) + Box( + Modifier + .fillMaxHeight() + .width(0.5.dp) + .background(Color.Black) + ) + Column { + ToolbarButton( + onSelect = { + isMenuOpen = !isMenuOpen + }, iconId = R.drawable.menu, contentDescription = "menu" + ) + if (isMenuOpen) ToolbarMenu( + navController = navController, + state = state, + onClose = { isMenuOpen = false }, + onPageSettingsOpen = { isPageSettingsModalOpen = true }) + } + } + + Box( + Modifier + .fillMaxWidth() + .height(1.dp) + .background(Color.Black) + ) + } + } else { + ToolbarButton( + onSelect = { state.isToolbarOpen = true }, + iconId = presentlyUsedToolIcon(state.mode, state.pen), + penColor = if (state.mode != Mode.Erase) state.penSettings[state.pen.penName]?.color?.let { + Color( + it + ) + } else null, + contentDescription = "open toolbar", + modifier = Modifier + .height((BUTTON_SIZE + 51).dp) + .padding(bottom = 50.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/olup/notable/components/ToolbarButton.kt b/app/src/main/java/com/ethran/notable/components/ToolbarButton.kt similarity index 53% rename from app/src/main/java/com/olup/notable/components/ToolbarButton.kt rename to app/src/main/java/com/ethran/notable/components/ToolbarButton.kt index 4e685e52..14b65a1c 100644 --- a/app/src/main/java/com/olup/notable/components/ToolbarButton.kt +++ b/app/src/main/java/com/ethran/notable/components/ToolbarButton.kt @@ -1,42 +1,61 @@ -package com.olup.notable +package com.ethran.notable.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ethran.notable.utils.noRippleClickable @Composable fun ToolbarButton( + modifier: Modifier = Modifier, isSelected: Boolean = false, onSelect: () -> Unit = {}, iconId: Int? = null, - imageVector : ImageVector? = null, + vectorIcon: ImageVector ? =null, + imageVector: ImageVector? = null, text: String? = null, - contentDescription: String = "", - modifier: Modifier = Modifier + penColor: Color? = null, + contentDescription: String = "" ) { Box( - Modifier.then(modifier) + Modifier + .then(modifier) .noRippleClickable { onSelect() } - .background(if (isSelected) Color.Black else Color.Transparent) + .background( + color = if (isSelected) penColor ?: Color.Black else penColor ?: Color.Transparent, + shape = if (!isSelected) CircleShape else RectangleShape + ) .padding(7.dp) ) { + //needs simplification: if (iconId != null) { Icon( painter = painterResource(id = iconId), contentDescription, Modifier, - if (isSelected) Color.White else Color.Black + if (penColor == Color.Black || penColor == Color.DarkGray) Color.White else if (isSelected) Color.White else Color.Black + ) + } + if (vectorIcon!=null){ + Icon( + imageVector = vectorIcon, + contentDescription, + Modifier, + if (penColor == Color.Black || penColor == Color.DarkGray) Color.White else if (isSelected) Color.White else Color.Black ) } @@ -51,6 +70,7 @@ fun ToolbarButton( if (text != null) { Text( text = text, + fontSize = 20.sp, color = if (isSelected) Color.White else Color.Black ) } diff --git a/app/src/main/java/com/ethran/notable/components/ToolbarMenu.kt b/app/src/main/java/com/ethran/notable/components/ToolbarMenu.kt new file mode 100644 index 00000000..9e3165b3 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/components/ToolbarMenu.kt @@ -0,0 +1,300 @@ +package com.ethran.notable.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.navigation.NavController +import com.ethran.notable.classes.AppRepository +import com.ethran.notable.classes.LocalSnackContext +import com.ethran.notable.classes.SnackConf +import com.ethran.notable.classes.XoppFile +import com.ethran.notable.utils.EditorState +import com.ethran.notable.utils.convertDpToPixel +import com.ethran.notable.utils.copyPagePngLinkForObsidian +import com.ethran.notable.utils.exportBook +import com.ethran.notable.utils.exportBookToPng +import com.ethran.notable.utils.exportPage +import com.ethran.notable.utils.exportPageToJpeg +import com.ethran.notable.utils.exportPageToPng +import com.ethran.notable.utils.noRippleClickable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun ToolbarMenu( + navController: NavController, + state: EditorState, + onClose: () -> Unit, + onPageSettingsOpen: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val snackManager = LocalSnackContext.current + val page = AppRepository(context).pageRepository.getById(state.pageId)!! + val parentFolder = + if (page.notebookId != null) + AppRepository(context).bookRepository.getById(page.notebookId)!! + .parentFolderId + else page.parentFolderId + + Popup( + alignment = Alignment.TopEnd, + onDismissRequest = { onClose() }, + offset = + IntOffset( + convertDpToPixel((-10).dp, context).toInt(), + convertDpToPixel(50.dp, context).toInt() + ), + properties = PopupProperties(focusable = true) + ) { + Column( + Modifier + .border(1.dp, Color.Black, RectangleShape) + .background(Color.White) + .width(IntrinsicSize.Max) + ) { + Box( + Modifier + .fillMaxWidth() + .padding(10.dp) + .noRippleClickable { + navController.navigate( + route = + if (parentFolder != null) "library?folderId=${parentFolder}" + else "library" + ) + } + ) { Text("Library") } + Box( + Modifier + .padding(10.dp) + .noRippleClickable { + scope.launch { + val removeSnack = + snackManager.displaySnack( + SnackConf(text = "Exporting the page to PDF...") + ) + delay(10L) + // Q: Why do I need this ? + // A: I guess that we need to wait for strokes to be drawn. + // checking if drawingInProgress.isLocked should be enough + // but I do not have time to test it. + val message = withContext(Dispatchers.IO) { + exportPage(context, state.pageId) + } + removeSnack() + snackManager.displaySnack( + SnackConf(text = message, duration = 2000) + ) + + onClose() + } + } + ) { Text("Export page to PDF") } + + Box( + Modifier + .padding(10.dp) + .noRippleClickable { + scope.launch { + val removeSnack = + snackManager.displaySnack( + SnackConf(text = "Exporting the page to PNG...") + ) + delay(10L) + + val message = + withContext(Dispatchers.IO) { + exportPageToPng(context, state.pageId) + } + removeSnack() + snackManager.displaySnack( + SnackConf(text = message, duration = 2000) + ) + onClose() + } + } + ) { Text("Export page to PNG") } + + Box( + Modifier + .padding(10.dp) + .noRippleClickable { + scope.launch { + delay(10L) + copyPagePngLinkForObsidian(context, state.pageId) + snackManager.displaySnack( + SnackConf(text = "Copied page link for obsidian", duration = 2000) + ) + onClose() + } + } + ) { Text("Copy page png link for obsidian") } + + Box( + Modifier + .padding(10.dp) + .noRippleClickable { + scope.launch { + val removeSnack = + snackManager.displaySnack( + SnackConf(text = "Exporting the page to JPEG...") + ) + delay(10L) + + val message = withContext(Dispatchers.IO) { + exportPageToJpeg(context, state.pageId) + } + removeSnack() + snackManager.displaySnack( + SnackConf(text = message, duration = 2000) + ) + onClose() + } + } + ) { Text("Export page to JPEG") } + + Box( + Modifier + .padding(10.dp) + .noRippleClickable { + scope.launch { + val removeSnack = + snackManager.displaySnack( + SnackConf(text = "Exporting the page to xopp") + ) + delay(10L) + CoroutineScope(Dispatchers.IO).launch { + XoppFile.exportPage(context, state.pageId) + removeSnack() + } + onClose() + } + } + ) { Text("Export page to xopp") } + + if (state.bookId != null) + Box( + Modifier + .padding(10.dp) + .noRippleClickable { + // Should I rather use: + // CoroutineScope(Dispatchers.IO).launch + // ? + scope.launch { + val removeSnack = + snackManager.displaySnack( + SnackConf( + text = "Exporting the book to PDF...", + id = "exportSnack" + ) + ) + delay(10L) + + val message = + withContext(Dispatchers.IO) { + exportBook(context, state.bookId) + } + removeSnack() + snackManager.displaySnack( + SnackConf(text = message, duration = 2000) + ) + onClose() + } + } + ) { Text("Export book to PDF") } + + if (state.bookId != null) { + Box( + Modifier + .padding(10.dp) + .noRippleClickable { + scope.launch { + val removeSnack = + snackManager.displaySnack( + SnackConf( + text = "Exporting the book to PNG...", + id = "exportSnack" + ) + ) + delay(10L) + + val message = withContext(Dispatchers.IO) { + exportBookToPng(context, state.bookId) + } + removeSnack() + snackManager.displaySnack( + SnackConf(text = message, duration = 2000) + ) + onClose() + } + } + ) { Text("Export book to PNG") } + Box( + Modifier + .padding(10.dp) + .noRippleClickable { + scope.launch { + val removeSnack = + snackManager.displaySnack( + SnackConf(text = "Exporting the book to xopp") + ) + delay(10L) + CoroutineScope(Dispatchers.IO).launch { + XoppFile.exportBook(context, state.bookId) + removeSnack() + } + onClose() + } + } + ) { Text("Export book to xopp") } + } + + Box( + Modifier + .fillMaxWidth() + .height(0.5.dp) + .background(Color.Black) + ) + Box( + Modifier + .padding(10.dp) + .noRippleClickable { + onPageSettingsOpen() + onClose() + } + ) { Text("Page Settings") } + + /*Box( + Modifier + .fillMaxWidth() + .height(0.5.dp) + .background(Color.Black) + ) + Box(Modifier.padding(10.dp)) { + Text("Refresh page") + }*/ + } + } +} diff --git a/app/src/main/java/com/olup/notable/components/Topbar.kt b/app/src/main/java/com/ethran/notable/components/Topbar.kt similarity index 61% rename from app/src/main/java/com/olup/notable/components/Topbar.kt rename to app/src/main/java/com/ethran/notable/components/Topbar.kt index 457e3b3f..de6b1c0a 100644 --- a/app/src/main/java/com/olup/notable/components/Topbar.kt +++ b/app/src/main/java/com/ethran/notable/components/Topbar.kt @@ -1,18 +1,21 @@ -package com.olup.notable +package com.ethran.notable.components import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @Composable -fun Topbar(content: @Composable() () -> Unit){ +fun Topbar(content: @Composable () -> Unit) { Column( modifier = Modifier.fillMaxWidth() ) { - Box() { + Box { content() } Box( diff --git a/app/src/main/java/com/olup/notable/datastore/EditorSettingCacheManager.kt b/app/src/main/java/com/ethran/notable/datastore/EditorSettingCacheManager.kt similarity index 56% rename from app/src/main/java/com/olup/notable/datastore/EditorSettingCacheManager.kt rename to app/src/main/java/com/ethran/notable/datastore/EditorSettingCacheManager.kt index 96b155b1..19ae0974 100644 --- a/app/src/main/java/com/olup/notable/datastore/EditorSettingCacheManager.kt +++ b/app/src/main/java/com/ethran/notable/datastore/EditorSettingCacheManager.kt @@ -1,13 +1,16 @@ -package com.olup.notable +package com.ethran.notable.datastore import android.content.Context -import com.olup.notable.AppRepository -import com.olup.notable.db.Kv -import kotlinx.serialization.decodeFromString +import com.ethran.notable.utils.Eraser +import com.ethran.notable.utils.Mode +import com.ethran.notable.utils.NamedSettings +import com.ethran.notable.utils.Pen +import com.ethran.notable.classes.AppRepository +import com.ethran.notable.db.Kv import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -val persistVersion = 2 +const val persistVersion = 2 object EditorSettingCacheManager { @@ -21,15 +24,15 @@ object EditorSettingCacheManager { val mode: Mode ) - fun init(context: Context){ + fun init(context: Context) { val settingsJSon = AppRepository(context).kvRepository.get("EDITOR_SETTINGS") - if(settingsJSon != null) { + if (settingsJSon != null) { val settings = Json.decodeFromString(settingsJSon.value) - if(settings.version == persistVersion) setEditorSettings(context, settings, false) + if (settings.version == persistVersion) setEditorSettings(context, settings, false) } } - fun persist(context: Context, settings : EditorSettings){ + private fun persist(context: Context, settings: EditorSettings) { val settingsJson = Json.encodeToString(settings) AppRepository(context).kvRepository.set(Kv("EDITOR_SETTINGS", settingsJson)) } @@ -39,8 +42,12 @@ object EditorSettingCacheManager { return editorSettings } - fun setEditorSettings(context : Context, newEditorSettings: EditorSettings, shouldPersist : Boolean = true) { + fun setEditorSettings( + context: Context, + newEditorSettings: EditorSettings, + shouldPersist: Boolean = true + ) { editorSettings = newEditorSettings - if(shouldPersist) persist(context, newEditorSettings) + if (shouldPersist) persist(context, newEditorSettings) } } diff --git a/app/src/main/java/com/olup/notable/db/Db.kt b/app/src/main/java/com/ethran/notable/db/Db.kt similarity index 52% rename from app/src/main/java/com/olup/notable/db/Db.kt rename to app/src/main/java/com/ethran/notable/db/Db.kt index 3c689bc5..05e3c662 100644 --- a/app/src/main/java/com/olup/notable/db/Db.kt +++ b/app/src/main/java/com/ethran/notable/db/Db.kt @@ -1,21 +1,29 @@ -package com.olup.notable.db +package com.ethran.notable.db import android.content.Context -import androidx.room.* -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase -import kotlinx.serialization.decodeFromString +import android.os.Environment +import androidx.room.AutoMigration +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverter +import androidx.room.TypeConverters import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import java.io.File import java.util.Date + class Converters { @TypeConverter fun fromListString(value: List) = Json.encodeToString(value) + @TypeConverter fun toListString(value: String) = Json.decodeFromString>(value) + @TypeConverter fun fromListPoint(value: List) = Json.encodeToString(value) + @TypeConverter fun toListPoint(value: String) = Json.decodeFromString>(value) @@ -26,25 +34,26 @@ class Converters { @TypeConverter fun dateToTimestamp(date: Date?): Long? { - return date?.time?.toLong() + return date?.time } } - @Database( - entities = [Folder::class, Notebook::class, Page::class, Stroke::class, Kv::class], - version = 28, + entities = [Folder::class, Notebook::class, Page::class, Stroke::class, Image::class, Kv::class], + version = 30, autoMigrations = [ - AutoMigration(19,20), - AutoMigration(20,21), - AutoMigration(21,22), - AutoMigration(23,24), - AutoMigration(24,25), - AutoMigration(25,26), - AutoMigration(26,27), - AutoMigration(27,28), - ] + AutoMigration(19, 20), + AutoMigration(20, 21), + AutoMigration(21, 22), + AutoMigration(23, 24), + AutoMigration(24, 25), + AutoMigration(25, 26), + AutoMigration(26, 27), + AutoMigration(27, 28), + AutoMigration(28, 29), + AutoMigration(29, 30), + ], exportSchema = true ) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { @@ -54,21 +63,29 @@ abstract class AppDatabase : RoomDatabase() { abstract fun notebookDao(): NotebookDao abstract fun pageDao(): PageDao abstract fun strokeDao(): StrokeDao + abstract fun ImageDao(): ImageDao companion object { private var INSTANCE: AppDatabase? = null + fun getDatabase(context: Context): AppDatabase { if (INSTANCE == null) { synchronized(this) { + val documentsDir = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS) + val dbDir = File(documentsDir, "notabledb") + if (!dbDir.exists()) { + dbDir.mkdirs() + } + val dbFile = File(dbDir, "app_database") + + // Use Room to build the database INSTANCE = - Room.databaseBuilder(context, AppDatabase::class.java, "app_database") - .allowMainThreadQueries() - //.fallbackToDestructiveMigration() - .addMigrations( - MIGRATION_16_17, - MIGRATION_17_18, - MIGRATION_22_23) + Room.databaseBuilder(context, AppDatabase::class.java, dbFile.absolutePath) + .allowMainThreadQueries() // Avoid in production + .addMigrations(MIGRATION_16_17, MIGRATION_17_18, MIGRATION_22_23) .build() + } } return INSTANCE!! diff --git a/app/src/main/java/com/olup/notable/db/Folder.kt b/app/src/main/java/com/ethran/notable/db/Folder.kt similarity index 65% rename from app/src/main/java/com/olup/notable/db/Folder.kt rename to app/src/main/java/com/ethran/notable/db/Folder.kt index 56a25cde..0fe9bc96 100644 --- a/app/src/main/java/com/olup/notable/db/Folder.kt +++ b/app/src/main/java/com/ethran/notable/db/Folder.kt @@ -1,8 +1,15 @@ -package com.olup.notable.db +package com.ethran.notable.db import android.content.Context import androidx.lifecycle.LiveData -import androidx.room.* +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Insert +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Update import java.util.Date import java.util.UUID @@ -19,7 +26,7 @@ data class Folder( val id: String = UUID.randomUUID().toString(), val title: String = "New Folder", - @ColumnInfo(index = true) + @ColumnInfo(index = true) val parentFolderId: String? = null, val createdAt: Date = Date(), @@ -30,10 +37,10 @@ data class Folder( @Dao interface FolderDao { @Query("SELECT * FROM folder WHERE parentFolderId IS :folderId") - fun getChildrenFolders(folderId:String?): LiveData> + fun getChildrenFolders(folderId: String?): LiveData> @Query("SELECT * FROM folder WHERE id IS :folderId") - fun get(folderId:String): Folder + fun get(folderId: String): Folder @Insert @@ -47,13 +54,13 @@ interface FolderDao { } class FolderRepository(context: Context) { - var db = AppDatabase.getDatabase(context)?.folderDao()!! + var db = AppDatabase.getDatabase(context).folderDao() fun create(folder: Folder) { db.create(folder) } - fun update(folder : Folder) { + fun update(folder: Folder) { db.update(folder) } @@ -61,6 +68,13 @@ class FolderRepository(context: Context) { return db.getChildrenFolders(folderId) } + fun getParent(folderId: String? = null): String? { + if(folderId ==null) + return null + val folder = db.get(folderId) + return folder.parentFolderId + } + fun get(folderId: String): Folder { return db.get(folderId) } diff --git a/app/src/main/java/com/ethran/notable/db/Image.kt b/app/src/main/java/com/ethran/notable/db/Image.kt new file mode 100644 index 00000000..ba5ff380 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/db/Image.kt @@ -0,0 +1,152 @@ +package com.ethran.notable.db + +import android.content.Context +import android.graphics.Rect +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Insert +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import java.util.Date +import java.util.UUID + + +// Entity class for images +@Entity( + foreignKeys = [ForeignKey( + entity = Page::class, + parentColumns = arrayOf("id"), + childColumns = arrayOf("pageId"), + onDelete = ForeignKey.CASCADE + )] +) +data class Image( + @PrimaryKey + val id: String = UUID.randomUUID().toString(), + + var x: Int = 0, + var y: Int = 0, + val height: Int, + val width: Int, + + // use uri instead of bytearray + //val bitmap: ByteArray, + val uri: String? = null, + + @ColumnInfo(index = true) + val pageId: String, + + val createdAt: Date = Date(), + val updatedAt: Date = Date() +) + +// DAO for image operations +@Dao +interface ImageDao { + @Insert + fun create(image: Image): Long + + @Insert + fun create(images: List) + + @Update + fun update(image: Image) + + @Query("DELETE FROM Image WHERE id IN (:ids)") + fun deleteAll(ids: List) + + @Transaction + @Query("SELECT * FROM Image WHERE id = :imageId") + fun getById(imageId: String): Image + + @Query( + """ + SELECT * FROM Image + WHERE :x >= x AND :x <= (x + width) + AND :y >= y AND :y <= (y + height) + AND pageId= :pageId + """ + ) + fun getImageAtPoint(x: Int, y: Int, pageId: String): Image? + + @Query( + """ + SELECT * FROM Image + WHERE x < :right AND (x + width) > :left + AND y < :bottom AND (y + height) > :top + AND pageId = :pageId + """ + ) + fun getImagesInRectangle( + left: Int, + top: Int, + right: Int, + bottom: Int, + pageId: String + ): List + + +} + +// Repository for stroke operations +class ImageRepository(context: Context) { + private val db = AppDatabase.getDatabase(context).ImageDao() + + fun create(image: Image): Long { + return db.create(image) + } + + fun create( + imageUri: String, + //position on canvas + x: Int, + y: Int, + pageId: String, + //size on canvas + width: Int, + height: Int + ): Long { + // Prepare the Image object with specified placement + val imageToSave = Image( + x = x, + y = y, + width = width, + height = height, + uri = imageUri, + pageId = pageId + ) + + // Save the image to the database + return db.create(imageToSave) + } + + fun create(images: List) { + db.create(images) + } + + fun update(image: Image) { + db.update(image) + } + + fun deleteAll(ids: List) { + db.deleteAll(ids) + } + + fun getImageWithPointsById(imageId: String): Image { + return db.getById(imageId) + } + + fun getImageAtPoint(x: Int, y: Int, pageId: String): Image? { + return db.getImageAtPoint(x, y, pageId) + } + + fun getImagesInRectangle(rect: Rect, pageId: String): List { + return db.getImagesInRectangle(rect.left, rect.top, rect.right, rect.bottom, pageId) + } +} + + diff --git a/app/src/main/java/com/olup/notable/db/Kv.kt b/app/src/main/java/com/ethran/notable/db/Kv.kt similarity index 60% rename from app/src/main/java/com/olup/notable/db/Kv.kt rename to app/src/main/java/com/ethran/notable/db/Kv.kt index 5a37b5f5..9c117452 100644 --- a/app/src/main/java/com/olup/notable/db/Kv.kt +++ b/app/src/main/java/com/ethran/notable/db/Kv.kt @@ -1,28 +1,20 @@ -package com.olup.notable.db +package com.ethran.notable.db import android.content.Context -import io.shipbook.shipbooksdk.Log import androidx.lifecycle.LiveData -import androidx.room.* -import java.util.Date -import java.util.UUID -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.map -import androidx.room.Room -import androidx.room.RoomDatabase -import androidx.sqlite.db.SupportSQLiteDatabase -import com.olup.notable.AppSettings -import com.olup.notable.TAG -import com.olup.notable.persistVersion -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import com.ethran.notable.TAG +import com.ethran.notable.modals.AppSettings +import com.ethran.notable.modals.GlobalAppSettings +import io.shipbook.shipbooksdk.Log import kotlinx.serialization.KSerializer -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import kotlinx.serialization.serializer -import kotlin.reflect.KType @Entity @@ -35,24 +27,24 @@ data class Kv( // DAO @Dao interface KvDao { - @Query("SELECT * FROM kv WHERE key=:key") + @Query("SELECT * FROM kv WHERE `key`=:key") fun get(key: String): Kv - @Query("SELECT * FROM kv WHERE key=:key") + @Query("SELECT * FROM kv WHERE `key`=:key") fun getLive(key: String): LiveData @Insert(onConflict = OnConflictStrategy.REPLACE) fun set(kv: Kv) - @Query("DELETE FROM kv WHERE key=:key") + @Query("DELETE FROM kv WHERE `key`=:key") fun delete(key: String) } class KvRepository(context: Context) { - var db = AppDatabase.getDatabase(context)?.kvDao()!! + var db = AppDatabase.getDatabase(context).kvDao() - fun get(key: String): Kv? { + fun get(key: String): Kv { return db.get(key) } @@ -76,14 +68,14 @@ class KvProxy(context: Context) { fun observeKv(key: String, serializer: KSerializer, default: T): LiveData { return kvRepository.getLive(key).map { if (it == null) return@map default - val jsonValue = it!!.value + val jsonValue = it.value Json.decodeFromString(serializer, jsonValue) } } fun get(key: String, serializer: KSerializer): T? { - val kv = kvRepository.get(key) ?: return null - val jsonValue = kv!!.value + val kv = kvRepository.get(key) ?: return null //returns null when there is no database + val jsonValue = kv.value return Json.decodeFromString(serializer, jsonValue) } @@ -93,4 +85,10 @@ class KvProxy(context: Context) { Log.i(TAG, jsonValue) kvRepository.set(Kv(key, jsonValue)) } + + fun setAppSettings(value: AppSettings) { + setKv("APP_SETTINGS", value, AppSettings.serializer()) + GlobalAppSettings.update(value) + } + } diff --git a/app/src/main/java/com/olup/notable/db/Migrations.kt b/app/src/main/java/com/ethran/notable/db/Migrations.kt similarity index 67% rename from app/src/main/java/com/olup/notable/db/Migrations.kt rename to app/src/main/java/com/ethran/notable/db/Migrations.kt index 31110127..780fc18f 100644 --- a/app/src/main/java/com/olup/notable/db/Migrations.kt +++ b/app/src/main/java/com/ethran/notable/db/Migrations.kt @@ -1,6 +1,5 @@ -package com.olup.notable.db +package com.ethran.notable.db -import androidx.room.AutoMigration import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase @@ -25,9 +24,17 @@ val MIGRATION_17_18 = object : Migration(17, 18) { val MIGRATION_22_23 = object : Migration(22, 23) { override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("DELETE FROM Page " + - "WHERE notebookId IS NOT NULL " + - "AND notebookId NOT IN (SELECT id FROM Notebook);") + database.execSQL( + "DELETE FROM Page " + + "WHERE notebookId IS NOT NULL " + + "AND notebookId NOT IN (SELECT id FROM Notebook);" + ) } } +val MIGRATION_29_30 = object : Migration(29, 30) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE Image ADD COLUMN createdAt INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE Image ADD COLUMN updatedAt INTEGER NOT NULL DEFAULT 0") + } +} diff --git a/app/src/main/java/com/olup/notable/db/Notebook.kt b/app/src/main/java/com/ethran/notable/db/Notebook.kt similarity index 77% rename from app/src/main/java/com/olup/notable/db/Notebook.kt rename to app/src/main/java/com/ethran/notable/db/Notebook.kt index de938b29..76b7f10b 100644 --- a/app/src/main/java/com/olup/notable/db/Notebook.kt +++ b/app/src/main/java/com/ethran/notable/db/Notebook.kt @@ -1,10 +1,17 @@ -package com.olup.notable.db +package com.ethran.notable.db import android.content.Context -import io.shipbook.shipbooksdk.Log import androidx.lifecycle.LiveData -import androidx.room.* -import com.olup.notable.TAG +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Insert +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Update +import com.ethran.notable.TAG +import io.shipbook.shipbooksdk.Log import java.util.Date import java.util.UUID @@ -62,20 +69,25 @@ interface NotebookDao { } class BookRepository(context: Context) { - var db = AppDatabase.getDatabase(context)?.notebookDao()!! - var pageDb = AppDatabase.getDatabase(context)?.pageDao()!! + var db = AppDatabase.getDatabase(context).notebookDao() + private var pageDb = AppDatabase.getDatabase(context).pageDao() fun create(notebook: Notebook) { db.create(notebook) - val page = Page(notebookId = notebook.id) + val page = Page(notebookId = notebook.id, nativeTemplate = notebook.defaultNativeTemplate) pageDb.create(page) db.setPageIds(notebook.id, listOf(page.id)) db.setOpenPageId(notebook.id, page.id) } + fun createEmpty(notebook: Notebook) { + db.create(notebook) + } fun update(notebook: Notebook) { - db.update(notebook) + Log.i(TAG, "updating DB") + val updatedNotebook = notebook.copy(updatedAt = Date()) + db.update(updatedNotebook) } fun getAllInFolder(folderId: String? = null): LiveData> { @@ -95,26 +107,26 @@ class BookRepository(context: Context) { } fun addPage(id: String, pageId: String, index: Int? = null) { - var pageIds = (db.getById(id) ?: return).pageIds.toMutableList() + val pageIds = (db.getById(id) ?: return).pageIds.toMutableList() if (index != null) pageIds.add(index, pageId) else pageIds.add(pageId) db.setPageIds(id, pageIds) } fun removePage(id: String, pageId: String) { - var notebook = db.getById(id) ?: return - var updatedNotebook = notebook.copy( + val notebook = db.getById(id) ?: return + val updatedNotebook = notebook.copy( // remove the page pageIds = notebook.pageIds.filterNot { it == pageId }, // remove the "open page" if it's the one openPageId = if (notebook.openPageId == pageId) null else notebook.openPageId ) db.update(updatedNotebook) - Log.i(TAG, "Cleaned ${id} ${pageId}") + Log.i(TAG, "Cleaned $id $pageId") } - fun changeePageIndex(id: String, pageId: String, index: Int) { - var pageIds = (db.getById(id) ?: return).pageIds.toMutableList() + fun changePageIndex(id: String, pageId: String, index: Int) { + val pageIds = (db.getById(id) ?: return).pageIds.toMutableList() var correctedIndex = index if (correctedIndex < 0) correctedIndex = 0 if (correctedIndex > pageIds.size - 1) correctedIndex = pageIds.size - 1 diff --git a/app/src/main/java/com/olup/notable/db/Page.kt b/app/src/main/java/com/ethran/notable/db/Page.kt similarity index 68% rename from app/src/main/java/com/olup/notable/db/Page.kt rename to app/src/main/java/com/ethran/notable/db/Page.kt index 59fe279a..8f7d337e 100644 --- a/app/src/main/java/com/olup/notable/db/Page.kt +++ b/app/src/main/java/com/ethran/notable/db/Page.kt @@ -1,9 +1,20 @@ -package com.olup.notable.db +package com.ethran.notable.db import android.content.Context import androidx.lifecycle.LiveData -import androidx.room.* -import java.util.* +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Insert +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Relation +import androidx.room.Transaction +import androidx.room.Update +import java.util.Date +import java.util.UUID @Entity( foreignKeys = [ForeignKey( @@ -32,6 +43,12 @@ data class PageWithStrokes( ) val strokes: List ) +data class PageWithImages( + @Embedded val page: Page, @Relation( + parentColumn = "id", entityColumn = "pageId", entity = Image::class + ) val images: List +) + // DAO @Dao interface PageDao { @@ -45,6 +62,14 @@ interface PageDao { @Query("SELECT * FROM page WHERE id =:pageId") fun getPageWithStrokesById(pageId: String): PageWithStrokes + @Transaction + @Query("SELECT * FROM page WHERE id =:pageId") + suspend fun getPageWithStrokesByIdSuspend(pageId: String): PageWithStrokes + + @Transaction + @Query("SELECT * FROM page WHERE id =:pageId") + fun getPageWithImagesById(pageId: String): PageWithImages + @Query("UPDATE page SET scroll=:scroll WHERE id =:pageId") fun updateScroll(pageId: String, scroll: Int) @@ -62,7 +87,7 @@ interface PageDao { } class PageRepository(context: Context) { - var db = AppDatabase.getDatabase(context)?.pageDao()!! + var db = AppDatabase.getDatabase(context).pageDao() fun create(page: Page): Long { return db.create(page) @@ -80,6 +105,14 @@ class PageRepository(context: Context) { return db.getPageWithStrokesById(pageId) } + suspend fun getWithStrokeByIdSuspend(pageId: String): PageWithStrokes { + return db.getPageWithStrokesByIdSuspend(pageId) + } + + fun getWithImageById(pageId: String): PageWithImages { + return db.getPageWithImagesById(pageId) + } + fun getSinglePagesInFolder(folderId: String? = null): LiveData> { return db.getSinglePagesInFolder(folderId) } diff --git a/app/src/main/java/com/ethran/notable/db/Select.kt b/app/src/main/java/com/ethran/notable/db/Select.kt new file mode 100644 index 00000000..80279ca1 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/db/Select.kt @@ -0,0 +1,198 @@ +package com.ethran.notable.db + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Rect +import androidx.compose.ui.unit.IntOffset +import com.ethran.notable.TAG +import com.ethran.notable.classes.DrawCanvas +import com.ethran.notable.classes.PageView +import com.ethran.notable.utils.EditorState +import com.ethran.notable.utils.PlacementMode +import com.ethran.notable.utils.SelectPointPosition +import com.ethran.notable.utils.SimplePointF +import com.ethran.notable.utils.divideStrokesFromCut +import com.ethran.notable.utils.drawImage +import com.ethran.notable.utils.drawStroke +import com.ethran.notable.utils.imageBoundsInt +import com.ethran.notable.utils.pageAreaToCanvasArea +import com.ethran.notable.utils.pointsToPath +import com.ethran.notable.utils.selectImagesFromPath +import com.ethran.notable.utils.selectStrokesFromPath +import com.ethran.notable.utils.strokeBounds +import io.shipbook.shipbooksdk.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +//TODO: clean up this code, there is a lot of duplication + +// allows selection of all images and strokes in given rectangle +fun selectImagesAndStrokes( + scope: CoroutineScope, + page: PageView, + editorState: EditorState, + imagesToSelect: List, + strokesToSelect: List +) { + //handle selection: + val pageBounds = Rect() + + if (imagesToSelect.isNotEmpty()) + pageBounds.union(imageBoundsInt(imagesToSelect)) + if (strokesToSelect.isNotEmpty()) + pageBounds.union(strokeBounds(strokesToSelect)) + + // padding inside the dashed selection square + // - if there are strokes selected, add some padding; + // - for image-only selections, use a tight fit. + val padding = if (strokesToSelect.isNotEmpty()) 30 else 0 + + pageBounds.inset(-padding, -padding) + val bounds = pageAreaToCanvasArea(pageBounds, page.scroll) + + // create bitmap and draw images and strokes + val selectedBitmap = + Bitmap.createBitmap(bounds.width(), bounds.height(), Bitmap.Config.ARGB_8888) + val selectedCanvas = Canvas(selectedBitmap) + + imagesToSelect.forEach { + drawImage( + page.context, + selectedCanvas, + it, + IntOffset(-pageBounds.left, -pageBounds.top) + ) + } + strokesToSelect.forEach { + drawStroke( + selectedCanvas, + it, + IntOffset(-pageBounds.left, -pageBounds.top) + ) + } + // set state + editorState.selectionState.selectedImages = imagesToSelect + editorState.selectionState.selectedStrokes = strokesToSelect + editorState.selectionState.selectedBitmap = selectedBitmap + editorState.selectionState.selectionStartOffset = IntOffset(bounds.left, bounds.top) + editorState.selectionState.selectionRect = bounds + editorState.selectionState.selectionDisplaceOffset = IntOffset(0, 0) + editorState.selectionState.placementMode = PlacementMode.Move + page.drawArea( + bounds, + ignoredImageIds = imagesToSelect.map { it.id }, + ignoredStrokeIds = strokesToSelect.map { it.id }) + + scope.launch { + DrawCanvas.refreshUi.emit(Unit) + editorState.isDrawing = false + } +} + + +/** + * Selects a single image (and deselects all strokes) on the page. + */ +fun selectImage( + scope: CoroutineScope, + page: PageView, + editorState: EditorState, + imageToSelect: Image +) { + selectImagesAndStrokes(scope, page, editorState, listOf(imageToSelect), emptyList()) +} + + +/** Written by GPT: + * Handles selection of strokes and areas on a page, enabling either lasso selection or + * page-cut-based selection for further manipulation or operations. + * + * This function performs the following steps: + * + * 1. **Page Cut Selection**: + * - Identifies if the selection points cross the left or right edge of the page (`Page cut` case). + * - Determines the direction of the cut and creates a complete selection area spanning the page. + * - For the first page cut, it registers the cut coordinates. + * - For the second page cut, it orders the cuts, divides the strokes into sections based on these cuts, + * and assigns the strokes in the middle section to `selectedStrokes`. + * + * 2. **Lasso Selection**: + * - For non-page-cut cases, it performs lasso selection using the provided points. + * - Creates a `Path` from the selection points and identifies strokes within this lasso area. + * - Computes the bounding box (`pageBounds`) for the selected strokes and expands it with padding. + * - Maps the page-relative bounds to the canvas coordinate space. + * - Renders the selected strokes onto a new bitmap using the calculated bounds. + * - Updates the editor's selection state with: + * - The selected strokes. + * - The created bitmap and its position on the canvas. + * - The selection rectangle and displacement offset. + * - Enabling the "Move" placement mode for manipulation. + * - Optionally, redraws the affected area without the selected strokes. + * + * 3. **UI Refresh**: + * - Notifies the UI to refresh and disables the drawing mode. + * + * @param scope The `CoroutineScope` used to perform asynchronous operations, such as UI refresh. + * @param page The `PageView` object representing the current page, including its strokes and dimensions. + * @param editorState The `EditorState` object storing the current state of the editor, such as selected strokes. + * @param points A list of `SimplePointF` objects defining the user's selection path in page coordinates. + * points is in page coordinates + */ +fun handleSelect( + scope: CoroutineScope, + page: PageView, + editorState: EditorState, + points: List +) { + val state = editorState.selectionState + + val firstPointPosition = + if (points.first().x < 50) SelectPointPosition.LEFT else if (points.first().x > page.width - 50) SelectPointPosition.RIGHT else SelectPointPosition.CENTER + val lastPointPosition = + if (points.last().x < 50) SelectPointPosition.LEFT else if (points.last().x > page.width - 50) SelectPointPosition.RIGHT else SelectPointPosition.CENTER + + if (firstPointPosition != SelectPointPosition.CENTER && lastPointPosition != SelectPointPosition.CENTER && firstPointPosition != lastPointPosition) { + // Page cut situation + val correctedPoints = + if (firstPointPosition === SelectPointPosition.LEFT) points else points.reversed() + // lets make this end to end + val completePoints = + listOf(SimplePointF(0f, correctedPoints.first().y)) + correctedPoints + listOf( + SimplePointF(page.width.toFloat(), correctedPoints.last().y) + ) + if (state.firstPageCut == null) { + // this is the first page cut + state.firstPageCut = completePoints + Log.i(TAG, "Registered first curt") + } else { + // this is the second page cut, we can also select the strokes + // first lets have the cuts in the right order + if (completePoints[0].y > state.firstPageCut!![0].y) state.secondPageCut = + completePoints + else { + state.secondPageCut = state.firstPageCut + state.firstPageCut = completePoints + } + // let's get stroke selection from that + val (_, after) = divideStrokesFromCut(page.strokes, state.firstPageCut!!) + val (middle, _) = divideStrokesFromCut(after, state.secondPageCut!!) + state.selectedStrokes = middle + } + } else { + // lasso selection + + // rcreate the lasso selection + val selectionPath = pointsToPath(points) + selectionPath.close() + + // get the selected strokes and images + val selectedStrokes = selectStrokesFromPath(page.strokes, selectionPath) + val selectedImages = selectImagesFromPath(page.images, selectionPath); + + if (selectedStrokes.isEmpty() && selectedImages.isEmpty()) return + + selectImagesAndStrokes(scope, page, editorState, selectedImages, selectedStrokes); + + // TODO collocate with control tower ? + } +} diff --git a/app/src/main/java/com/olup/notable/db/Stroke.kt b/app/src/main/java/com/ethran/notable/db/Stroke.kt similarity index 59% rename from app/src/main/java/com/olup/notable/db/Stroke.kt rename to app/src/main/java/com/ethran/notable/db/Stroke.kt index c7086b3c..0ccfcf8b 100644 --- a/app/src/main/java/com/olup/notable/db/Stroke.kt +++ b/app/src/main/java/com/ethran/notable/db/Stroke.kt @@ -1,16 +1,26 @@ -package com.olup.notable.db +package com.ethran.notable.db import android.content.Context -import androidx.room.* -import com.olup.notable.Pen -import java.util.* +import android.graphics.Rect +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Insert +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import com.ethran.notable.utils.Pen +import java.util.Date +import java.util.UUID @kotlinx.serialization.Serializable data class StrokePoint( val x: Float, var y: Float, val pressure: Float, - val size: Float, + val size: Float, //TODO: remove? It seams the same as Stroke size val tiltX: Int, val tiltY: Int, val timestamp: Long, @@ -29,6 +39,8 @@ data class Stroke( val id: String = UUID.randomUUID().toString(), val size: Float, val pen: Pen, + @ColumnInfo(defaultValue = "0xFF000000") + val color: Int = 0xFF000000.toInt(), var top: Float, var bottom: Float, @@ -62,10 +74,27 @@ interface StrokeDao { @Transaction @Query("SELECT * FROM stroke WHERE id =:strokeId") fun getById(strokeId: String): Stroke + + @Query( + """ + SELECT * FROM stroke + WHERE `right` > :left AND `left` < :right + AND bottom > :top AND top < :bottom + AND pageId = :pageId + """ + ) + fun getStrokesInRectangle( + left: Int, + top: Int, + right: Int, + bottom: Int, + pageId: String + ): List + } class StrokeRepository(context: Context) { - var db = AppDatabase.getDatabase(context)?.strokeDao()!! + var db = AppDatabase.getDatabase(context).strokeDao() fun create(stroke: Stroke): Long { return db.create(stroke) @@ -87,4 +116,7 @@ class StrokeRepository(context: Context) { return db.getById(strokeId) } + fun getStrokesInRectangle(rect: Rect, pageId: String): List { + return db.getStrokesInRectangle(rect.left, rect.top, rect.right, rect.bottom, pageId) + } } \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/modals/AppSettings.kt b/app/src/main/java/com/ethran/notable/modals/AppSettings.kt new file mode 100644 index 00000000..2c4f0af5 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/modals/AppSettings.kt @@ -0,0 +1,360 @@ +package com.ethran.notable.modals + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.Button +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Switch +import androidx.compose.material.TabRowDefaults.Divider +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.ethran.notable.BuildConfig +import com.ethran.notable.classes.showHint +import com.ethran.notable.components.SelectMenu +import com.ethran.notable.db.KvProxy +import com.ethran.notable.utils.isLatestVersion +import com.ethran.notable.utils.isNext +import com.ethran.notable.utils.noRippleClickable +import kotlinx.serialization.Serializable +import kotlin.concurrent.thread + +// Define the target page size (A4 in points: 595 x 842) +const val A4_WIDTH = 595 +const val A4_HEIGHT = 842 +const val BUTTON_SIZE = 37 + + +object GlobalAppSettings { + private val _current = mutableStateOf(AppSettings(version = 1)) + val current: AppSettings + get() = _current.value + + fun update(settings: AppSettings) { + _current.value = settings + } +} + + +@Serializable +data class AppSettings( + val version: Int, + val defaultNativeTemplate: String = "blank", + val quickNavPages: List = listOf(), + val debugMode: Boolean = false, + val neoTools: Boolean = false, + val toolbarPosition: Position = Position.Top, + + val doubleTapAction: GestureAction? = defaultDoubleTapAction, + val twoFingerTapAction: GestureAction? = defaultTwoFingerTapAction, + val swipeLeftAction: GestureAction? = defaultSwipeLeftAction, + val swipeRightAction: GestureAction? = defaultSwipeRightAction, + val twoFingerSwipeLeftAction: GestureAction? = defaultTwoFingerSwipeLeftAction, + val twoFingerSwipeRightAction: GestureAction? = defaultTwoFingerSwipeRightAction, + val holdAction: GestureAction? = defaultHoldAction, + + ) { + companion object { + val defaultDoubleTapAction get() = GestureAction.Undo + val defaultTwoFingerTapAction get() = GestureAction.ChangeTool + val defaultSwipeLeftAction get() = GestureAction.NextPage + val defaultSwipeRightAction get() = GestureAction.PreviousPage + val defaultTwoFingerSwipeLeftAction get() = GestureAction.ToggleZen + val defaultTwoFingerSwipeRightAction get() = GestureAction.ToggleZen + val defaultHoldAction get() = GestureAction.Select + } + + enum class GestureAction { + Undo, Redo, PreviousPage, NextPage, ChangeTool, ToggleZen, Select + } + + enum class Position { + Top, Bottom, // Left,Right, + } + +} + +@Composable +fun AppSettingsModal(onClose: () -> Unit) { + val context = LocalContext.current + val kv = KvProxy(context) + + var isLatestVersion by remember { mutableStateOf(true) } + LaunchedEffect(key1 = Unit, block = { thread { isLatestVersion = isLatestVersion(context) } }) + + val settings = GlobalAppSettings.current + + if (settings == null) return + + Dialog( + onDismissRequest = { onClose() }, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Column( + modifier = Modifier + .padding(40.dp) + .background(Color.White) + .fillMaxWidth() + .border(2.dp, Color.Black, RectangleShape) + ) { + Column(Modifier.padding(20.dp, 10.dp)) { + Text( + text = "App setting - v${BuildConfig.VERSION_NAME}${if (isNext) " [NEXT]" else ""}", + style = MaterialTheme.typography.h5, + ) + } + Box( + Modifier + .height(0.5.dp) + .fillMaxWidth() + .background(Color.Black) + ) + + Column(Modifier.padding(20.dp, 10.dp)) { + Row { + Text(text = "Default Page Background Template") + Spacer(Modifier.width(10.dp)) + SelectMenu( + options = listOf( + "blank" to "Blank page", + "dotted" to "Dot grid", + "lined" to "Lines", + "squared" to "Small squares grid", + "hexed" to "Hexagon grid", + ), + onChange = { + kv.setAppSettings(settings!!.copy(defaultNativeTemplate = it)) + }, + value = settings.defaultNativeTemplate + ) + } + Spacer(Modifier.height(10.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "Debug Mode (show changed area)") + Spacer(Modifier.width(10.dp)) + Switch( + checked = settings?.debugMode ?: false, + onCheckedChange = { isChecked -> + kv.setAppSettings(settings!!.copy(debugMode = isChecked)) + } + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "Use Onyx NeoTools (may cause crashes)") + Spacer(Modifier.width(10.dp)) + Switch( + checked = settings?.neoTools ?: false, + onCheckedChange = { isChecked -> + kv.setAppSettings(settings!!.copy(neoTools = isChecked)) + } + ) + } + Spacer(Modifier.height(10.dp)) + + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = "Toolbar Position (Work in progress)") + Spacer(modifier = Modifier.width(10.dp)) + + SelectMenu( + options = listOf( + AppSettings.Position.Top to "Top", + AppSettings.Position.Bottom to "Bottom" + ), + value = settings.toolbarPosition, + onChange = { newPosition -> + settings?.let { + kv.setAppSettings(it.copy(toolbarPosition = newPosition)) + } + } + ) + } + } + Spacer(Modifier.height(16.dp)) + + Text( + text = "Gesture Settings", + style = MaterialTheme.typography.h6, + modifier = Modifier.padding(start = 35.dp, bottom = 8.dp) + ) + + Divider( + color = Color.LightGray, + thickness = 1.dp, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) + + Spacer(Modifier.height(8.dp)) + GestureSelectorRow( + title = "Double Tap Action", + kv = kv, + settings = settings, + update = { copy(doubleTapAction = it) }, + default = AppSettings.defaultDoubleTapAction, + override = { doubleTapAction } + ) + Spacer(Modifier.height(10.dp)) + + GestureSelectorRow( + title = "Two Finger Tap Action", + kv = kv, + settings = settings, + update = { copy(twoFingerTapAction = it) }, + default = AppSettings.defaultTwoFingerTapAction, + override = { twoFingerTapAction } + ) + Spacer(Modifier.height(10.dp)) + + GestureSelectorRow( + title = "Swipe Left Action", + kv = kv, + settings = settings, + update = { copy(swipeLeftAction = it) }, + default = AppSettings.defaultSwipeLeftAction, + override = { swipeLeftAction } + ) + Spacer(Modifier.height(10.dp)) + + GestureSelectorRow( + title = "Swipe Right Action", + kv = kv, + settings = settings, + update = { copy(swipeRightAction = it) }, + default = AppSettings.defaultSwipeRightAction, + override = { swipeRightAction } + ) + Spacer(Modifier.height(10.dp)) + + GestureSelectorRow( + title = "Two Finger Swipe Left Action", + kv = kv, + settings = settings, + update = { copy(twoFingerSwipeLeftAction = it) }, + default = AppSettings.defaultTwoFingerSwipeLeftAction, + override = { twoFingerSwipeLeftAction } + ) + Spacer(Modifier.height(10.dp)) + + GestureSelectorRow( + title = "Two Finger Swipe Right Action", + kv = kv, + settings = settings, + update = { copy(twoFingerSwipeRightAction = it) }, + default = AppSettings.defaultTwoFingerSwipeRightAction, + override = { twoFingerSwipeRightAction } + ) + Spacer(Modifier.height(10.dp)) + + if (!isLatestVersion) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "It seems a new version of Notable is available on GitHub.", + fontStyle = FontStyle.Italic, + style = MaterialTheme.typography.h6, + ) + + Spacer(Modifier.height(10.dp)) + + Button( + onClick = { + val urlIntent = Intent( + Intent.ACTION_VIEW, + Uri.parse("https://github.com/ethran/notable/releases") + ) + context.startActivity(urlIntent) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "See release in browser", + ) + } + } + Spacer(Modifier.height(10.dp)) + } else { + Button( + onClick = { + thread { + isLatestVersion = isLatestVersion(context, true) + if (isLatestVersion) { + showHint( + "You are on the latest version.", + duration = 1000 + ) + } + } + }, + modifier = Modifier.fillMaxWidth() // Adjust the modifier as needed + ) { + Text(text = "Check for newer version") + } + + } + } + } + } +} + +@Composable +fun GestureSelectorRow( + title: String, + kv: KvProxy, + settings: AppSettings?, + update: AppSettings.(AppSettings.GestureAction?) -> AppSettings, + default: AppSettings.GestureAction, + override: AppSettings.() -> AppSettings.GestureAction?, +) { + Row { + Text(text = title) + Spacer(Modifier.width(10.dp)) + SelectMenu( + options = listOf( + null to "None", + AppSettings.GestureAction.Undo to "Undo", + AppSettings.GestureAction.Redo to "Redo", + AppSettings.GestureAction.PreviousPage to "Previous Page", + AppSettings.GestureAction.NextPage to "Next Page", + AppSettings.GestureAction.ChangeTool to "Toggle Pen / Eraser", + AppSettings.GestureAction.ToggleZen to "Toggle Zen Mode", + ), + value = if (settings != null) settings.override() else default, + onChange = { + if (settings != null) { + kv.setAppSettings(settings.update(it)) + } + }, + ) + } +} diff --git a/app/src/main/java/com/olup/notable/modals/FolderConfig.kt b/app/src/main/java/com/ethran/notable/modals/FolderConfig.kt similarity index 72% rename from app/src/main/java/com/olup/notable/modals/FolderConfig.kt rename to app/src/main/java/com/ethran/notable/modals/FolderConfig.kt index 5cc5304f..e7fd28e1 100644 --- a/app/src/main/java/com/olup/notable/modals/FolderConfig.kt +++ b/app/src/main/java/com/ethran/notable/modals/FolderConfig.kt @@ -1,20 +1,29 @@ -package com.olup.notable +package com.ethran.notable.modals -import io.shipbook.shipbooksdk.Log import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.input.key.* import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.TextStyle @@ -25,15 +34,17 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog -import androidx.navigation.NavController -import com.olup.notable.db.FolderRepository +import com.ethran.notable.TAG +import com.ethran.notable.db.FolderRepository +import com.ethran.notable.utils.noRippleClickable +import io.shipbook.shipbooksdk.Log @ExperimentalComposeUiApi @Composable -fun FolderConfigDialog(folderId: String, onClose : ()->Unit) { +fun FolderConfigDialog(folderId: String, onClose: () -> Unit) { val folderRepository = FolderRepository(LocalContext.current) - val folder = folderRepository.get(folderId) ?: return + val folder = folderRepository.get(folderId) var folderTitle by remember { mutableStateOf(folder.title) @@ -70,7 +81,7 @@ fun FolderConfigDialog(folderId: String, onClose : ()->Unit) { Modifier.padding(20.dp, 10.dp) ) { - Row() { + Row { Text( text = "Folder Title", fontWeight = FontWeight.Bold @@ -92,12 +103,15 @@ fun FolderConfigDialog(folderId: String, onClose : ()->Unit) { keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - modifier = Modifier.background(Color(230,230,230,255)).padding(10.dp, 0.dp).onFocusChanged { focusState -> - if (!focusState.isFocused) { - val updatedFolder = folder.copy(title = folderTitle) - folderRepository.update(updatedFolder) + modifier = Modifier + .background(Color(230, 230, 230, 255)) + .padding(10.dp, 0.dp) + .onFocusChanged { focusState -> + if (!focusState.isFocused) { + val updatedFolder = folder.copy(title = folderTitle) + folderRepository.update(updatedFolder) + } } - } ) diff --git a/app/src/main/java/com/ethran/notable/modals/NotebookConfig.kt b/app/src/main/java/com/ethran/notable/modals/NotebookConfig.kt new file mode 100644 index 00000000..fe2021bb --- /dev/null +++ b/app/src/main/java/com/ethran/notable/modals/NotebookConfig.kt @@ -0,0 +1,289 @@ +package com.ethran.notable.modals + + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.ethran.notable.TAG +import com.ethran.notable.classes.LocalSnackContext +import com.ethran.notable.classes.SnackConf +import com.ethran.notable.components.BreadCrumb +import com.ethran.notable.components.PagePreview +import com.ethran.notable.components.SelectMenu +import com.ethran.notable.components.ShowConfirmationDialog +import com.ethran.notable.components.ShowExportDialog +import com.ethran.notable.components.ShowFolderSelectionDialog +import com.ethran.notable.db.BookRepository +import io.shipbook.shipbooksdk.Log +import kotlinx.coroutines.launch + + +@ExperimentalComposeUiApi +@Composable +fun NotebookConfigDialog(bookId: String, onClose: () -> Unit) { + val bookRepository = BookRepository(LocalContext.current) + val book by bookRepository.getByIdLive(bookId).observeAsState() + val context = LocalContext.current + val scope = rememberCoroutineScope() + val snackManager = LocalSnackContext.current + + if (book == null) return + + var bookTitle by remember { + mutableStateOf(book!!.title) + } + val formattedCreatedAt = + remember { android.text.format.DateFormat.format("dd MMM yyyy HH:mm", book!!.createdAt) } + val formattedUpdatedAt = + remember { android.text.format.DateFormat.format("dd MMM yyyy HH:mm", book!!.updatedAt) } + var showDeleteDialog by remember { mutableStateOf(false) } + var showMoveDialog by remember { mutableStateOf(false) } + var showExportDialog by remember { mutableStateOf(false) } + + var bookFolder by remember { mutableStateOf(book?.parentFolderId)} + + + // Confirmation Dialog for Deletion + if (showDeleteDialog) { + ShowConfirmationDialog( + title = "Confirm Deletion", + message = "Are you sure you want to delete \"${book!!.title}\"?", + onConfirm = { + bookRepository.delete(bookId) + showDeleteDialog = false + onClose() + }, + onCancel = { + showDeleteDialog = false + } + ) + return + } + // Confirmation Dialog for Deletion + if (showExportDialog) { + ShowExportDialog( + snackManager = snackManager, + bookId = bookId, + context = context, + onConfirm = { + showExportDialog = false + onClose() + }, + onCancel = { + showExportDialog = false + } + ) + return + } + // Folder Selection Dialog + if (showMoveDialog) { + + ShowFolderSelectionDialog( + book = book!!, + notebookName = book!!.title, + initialFolderId = book!!.parentFolderId, + onCancel = { showMoveDialog = false }, + onConfirm = { selectedFolder -> + showMoveDialog = false + onClose() + Log.i(TAG, "folder:" + selectedFolder.toString()) + val updatedBook = book!!.copy(parentFolderId = selectedFolder) + bookFolder = selectedFolder + scope.launch { + // be careful, not to cause race condition. + bookRepository.update(updatedBook) + } + } + ) + } + + Dialog( + onDismissRequest = { + onClose() + } + ) { + val focusManager = LocalFocusManager.current + + Column( + modifier = Modifier + .background(Color.White) + .fillMaxWidth() + .border(2.dp, Color.Black, RectangleShape) + .padding(16.dp) + .padding(top = 24.dp, bottom = 16.dp) + ) { + // Header Section + Row(Modifier.padding(bottom = 16.dp)) { + Box( + modifier = Modifier + .size(200.dp, 250.dp) + .background(Color.Gray), + contentAlignment = Alignment.Center + ) { + val pageId = book!!.pageIds[0] + PagePreview( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(3f / 4f) + .border(1.dp, Color.Black, RectangleShape), pageId + ) + } + Spacer(Modifier.width(16.dp)) + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row { + Text( + text = "Title:", + fontWeight = FontWeight.Bold, + fontSize = 24.sp + ) + Spacer(Modifier.width(20.dp)) + BasicTextField( + value = bookTitle, + textStyle = TextStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Light, + fontSize = 24.sp + ), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = androidx.compose.ui.text.input.ImeAction.Done + ), + onValueChange = { bookTitle = it }, + keyboardActions = KeyboardActions(onDone = { + focusManager.clearFocus() + }), + modifier = Modifier + .background(Color(230, 230, 230, 255)) + .padding(10.dp, 0.dp) + .onFocusChanged { focusState -> + if (!focusState.isFocused) { + Log.i(TAG, "loose focus") + if (book!!.title != bookTitle) { + val updatedBook = book!!.copy(title = bookTitle) + bookRepository.update(updatedBook) + } + } + } + + + ) + } + + Row { + Text(text = "Default Background Template") + + Spacer(Modifier.width(30.dp)) + SelectMenu( + options = listOf( + "blank" to "Blank page", + "dotted" to "Dot grid", + "lined" to "Lines", + "squared" to "Small squares grid", + "hexed" to "Hexagon grid", + ), + onChange = { + if (book!!.defaultNativeTemplate != it) { + val updatedBook = book!!.copy(defaultNativeTemplate = it) + bookRepository.update(updatedBook) + } + }, + // this once thrown null ptr exception, when deleting notebook. + value = book!!.defaultNativeTemplate + ) + + } + Text("Pages: ${book!!.pageIds.size}") + Text("Size: TODO!") + Row { + Text("In Folder: ") + BreadCrumb(bookFolder) { } + } + Text("Created: $formattedCreatedAt") + Text("Last Updated: $formattedUpdatedAt") + } + } + + Spacer(Modifier.height(16.dp)) + + // Grid Actions Section + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceAround + ) { + ActionButton("Delete") { + showDeleteDialog = true + } + ActionButton("Move") { + showMoveDialog = true + } + ActionButton("Export") { + showExportDialog=true + } + ActionButton("Copy") { + scope.launch { + snackManager.displaySnack( + SnackConf(text = "Not implemented!", duration = 2000) + ) + } + } + + } + } + + } + +} + +@Composable +fun ActionButton(text: String, onClick: () -> Unit) { + Box( + modifier = Modifier + .size(100.dp, 40.dp) + .background(Color.LightGray, RectangleShape) + .border(1.dp, Color.Black, RectangleShape) + .clickable { onClick() }, + contentAlignment = Alignment.Center + ) { + Text(text, fontWeight = FontWeight.Bold) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/olup/notable/modals/PageSettings.kt b/app/src/main/java/com/ethran/notable/modals/PageSettings.kt similarity index 66% rename from app/src/main/java/com/olup/notable/modals/PageSettings.kt rename to app/src/main/java/com/ethran/notable/modals/PageSettings.kt index 9cd5fe37..77cefb5e 100644 --- a/app/src/main/java/com/olup/notable/modals/PageSettings.kt +++ b/app/src/main/java/com/ethran/notable/modals/PageSettings.kt @@ -1,16 +1,30 @@ -package com.olup.notable +package com.ethran.notable.modals import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog -import com.olup.notable.components.SelectMenu +import com.ethran.notable.classes.DrawCanvas +import com.ethran.notable.classes.PageView +import com.ethran.notable.components.SelectMenu import kotlinx.coroutines.launch @Composable @@ -43,7 +57,7 @@ fun PageSettingsModal(pageView: PageView, onClose: () -> Unit) { Modifier.padding(20.dp, 10.dp) ) { - Row() { + Row { Text(text = "Background Template") Spacer(Modifier.width(10.dp)) SelectMenu( @@ -51,12 +65,13 @@ fun PageSettingsModal(pageView: PageView, onClose: () -> Unit) { "blank" to "Blank page", "dotted" to "Dot grid", "lined" to "Lines", - "squared" to "Small squares grid" + "squared" to "Small squares grid", + "hexed" to "Hexagon grid", ), onChange = { val updatedPage = pageView.pageFromDb!!.copy(nativeTemplate = it) pageView.updatePageSettings(updatedPage) - scope.launch { DrawCanvas.refreshUi.emit(Unit) } + scope.launch { DrawCanvas.refreshUi.emit(Unit) } pageTemplate = pageView.pageFromDb!!.nativeTemplate }, value = pageTemplate diff --git a/app/src/main/java/com/olup/notable/ui/theme/Color.kt b/app/src/main/java/com/ethran/notable/ui/theme/Color.kt similarity index 83% rename from app/src/main/java/com/olup/notable/ui/theme/Color.kt rename to app/src/main/java/com/ethran/notable/ui/theme/Color.kt index 9a86bcfc..a81c1137 100644 --- a/app/src/main/java/com/olup/notable/ui/theme/Color.kt +++ b/app/src/main/java/com/ethran/notable/ui/theme/Color.kt @@ -1,4 +1,4 @@ -package com.olup.notable.ui.theme +package com.ethran.notable.ui.theme import androidx.compose.ui.graphics.Color diff --git a/app/src/main/java/com/olup/notable/ui/theme/Shape.kt b/app/src/main/java/com/ethran/notable/ui/theme/Shape.kt similarity index 88% rename from app/src/main/java/com/olup/notable/ui/theme/Shape.kt rename to app/src/main/java/com/ethran/notable/ui/theme/Shape.kt index c42e881a..d5a33a27 100644 --- a/app/src/main/java/com/olup/notable/ui/theme/Shape.kt +++ b/app/src/main/java/com/ethran/notable/ui/theme/Shape.kt @@ -1,4 +1,4 @@ -package com.olup.notable.ui.theme +package com.ethran.notable.ui.theme import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Shapes diff --git a/app/src/main/java/com/olup/notable/ui/theme/Theme.kt b/app/src/main/java/com/ethran/notable/ui/theme/Theme.kt similarity index 96% rename from app/src/main/java/com/olup/notable/ui/theme/Theme.kt rename to app/src/main/java/com/ethran/notable/ui/theme/Theme.kt index 3d8287ca..2d6bfaec 100644 --- a/app/src/main/java/com/olup/notable/ui/theme/Theme.kt +++ b/app/src/main/java/com/ethran/notable/ui/theme/Theme.kt @@ -1,4 +1,4 @@ -package com.olup.notable.ui.theme +package com.ethran.notable.ui.theme import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material.MaterialTheme diff --git a/app/src/main/java/com/olup/notable/ui/theme/Type.kt b/app/src/main/java/com/ethran/notable/ui/theme/Type.kt similarity index 95% rename from app/src/main/java/com/olup/notable/ui/theme/Type.kt rename to app/src/main/java/com/ethran/notable/ui/theme/Type.kt index c3840bad..59d7a0eb 100644 --- a/app/src/main/java/com/olup/notable/ui/theme/Type.kt +++ b/app/src/main/java/com/ethran/notable/ui/theme/Type.kt @@ -1,4 +1,4 @@ -package com.olup.notable.ui.theme +package com.ethran.notable.ui.theme import androidx.compose.material.Typography import androidx.compose.ui.text.TextStyle diff --git a/app/src/main/java/com/ethran/notable/utils/EditorState.kt b/app/src/main/java/com/ethran/notable/utils/EditorState.kt new file mode 100644 index 00000000..680a6673 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/utils/EditorState.kt @@ -0,0 +1,62 @@ +package com.ethran.notable.utils + +import android.graphics.Color +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.ethran.notable.classes.ClipboardContent +import com.ethran.notable.classes.PageView +import com.ethran.notable.classes.SelectionState +import com.ethran.notable.datastore.EditorSettingCacheManager + +enum class Mode { + Draw, Erase, Select, Line +} + +class EditorState(val bookId: String? = null, val pageId: String, val pageView: PageView) { + + private val persistedEditorSettings = EditorSettingCacheManager.getEditorSettings() + + var mode by mutableStateOf(persistedEditorSettings?.mode ?: Mode.Draw) // should save + var pen by mutableStateOf(persistedEditorSettings?.pen ?: Pen.BALLPEN) // should save + var eraser by mutableStateOf(persistedEditorSettings?.eraser ?: Eraser.PEN) // should save + var isDrawing by mutableStateOf(true) + var isToolbarOpen by mutableStateOf( + persistedEditorSettings?.isToolbarOpen ?: false + ) // should save + var penSettings by mutableStateOf( + persistedEditorSettings?.penSettings ?: mapOf( + Pen.BALLPEN.penName to PenSetting(5f, Color.BLACK), + Pen.REDBALLPEN.penName to PenSetting(5f, Color.RED), + Pen.BLUEBALLPEN.penName to PenSetting(5f, Color.BLUE), + Pen.GREENBALLPEN.penName to PenSetting(5f, Color.GREEN), + Pen.PENCIL.penName to PenSetting(5f, Color.BLACK), + Pen.BRUSH.penName to PenSetting(5f, Color.BLACK), + Pen.MARKER.penName to PenSetting(40f, Color.LTGRAY), + Pen.FOUNTAIN.penName to PenSetting(5f, Color.BLACK) + ) + ) + + val selectionState = SelectionState() + + private var _clipboard by mutableStateOf(Clipboard.content) + var clipboard + get() = _clipboard + set(value) { + this._clipboard = value + + // The clipboard content must survive the EditorState, so we store a copy in + // a singleton that lives outside of the EditorState + Clipboard.content = value + } +} + +// if state is Move then applySelectionDisplace() will delete original strokes and images +enum class PlacementMode { + Move, + Paste +} + +object Clipboard { + var content: ClipboardContent? = null; +} \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/utils/FileUtils.kt b/app/src/main/java/com/ethran/notable/utils/FileUtils.kt new file mode 100644 index 00000000..2d6a69f3 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/utils/FileUtils.kt @@ -0,0 +1,70 @@ +package com.ethran.notable.utils + +import android.content.Context +import android.net.Uri +import android.os.Environment +import android.provider.OpenableColumns +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream + + +// adapted from: +// https://stackoverflow.com/questions/71241337/copy-image-from-uri-in-another-folder-with-another-name-in-kotlin-android +fun createFileFromContentUri(context: Context, fileUri: Uri): File { + var fileName = "" + + // Get the display name of the file + context.contentResolver.query(fileUri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + cursor.moveToFirst() + fileName = cursor.getString(nameIndex) + } + + // Extract the MIME type if needed +// val fileType: String? = context.contentResolver.getType(fileUri) + + // Open the input stream + val iStream: InputStream = context.contentResolver.openInputStream(fileUri)!! + + // Set up the output file destination + + val outputDir = ensureImagesFolder() + + + fileName = sanitizeFileName(fileName) + val outputFile = File(outputDir, fileName) + + // Copy the input stream to the output file + copyStreamToFile(iStream, outputFile) + iStream.close() + return outputFile +} + +fun sanitizeFileName(fileName: String): String { + return fileName.replace(Regex("[^a-zA-Z0-9._-]"), "_") +} + +fun copyStreamToFile(inputStream: InputStream, outputFile: File) { + inputStream.use { input -> + FileOutputStream(outputFile).use { output -> + val buffer = ByteArray(4 * 1024) // buffer size + while (true) { + val byteCount = input.read(buffer) + if (byteCount < 0) break + output.write(buffer, 0, byteCount) + } + output.flush() + } + } +} + +fun ensureImagesFolder(): File { + val documentsDir = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS) + val dbDir = File(File(documentsDir, "notabledb"), "images") + if (!dbDir.exists()) { + dbDir.mkdirs() + } + return dbDir +} diff --git a/app/src/main/java/com/ethran/notable/utils/draw.kt b/app/src/main/java/com/ethran/notable/utils/draw.kt new file mode 100644 index 00000000..f73b0086 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/utils/draw.kt @@ -0,0 +1,397 @@ +package com.ethran.notable.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.DashPathEffect +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PointF +import android.graphics.Rect +import android.net.Uri +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.toOffset +import com.ethran.notable.SCREEN_HEIGHT +import com.ethran.notable.SCREEN_WIDTH +import com.ethran.notable.TAG +import com.ethran.notable.classes.DrawCanvas +import com.ethran.notable.classes.pressure +import com.ethran.notable.db.Image +import com.ethran.notable.db.Stroke +import com.ethran.notable.modals.GlobalAppSettings +import com.onyx.android.sdk.data.note.ShapeCreateArgs +import com.onyx.android.sdk.data.note.TouchPoint +import com.onyx.android.sdk.pen.NeoBrushPen +import com.onyx.android.sdk.pen.NeoCharcoalPen +import com.onyx.android.sdk.pen.NeoFountainPen +import com.onyx.android.sdk.pen.NeoMarkerPen +import io.shipbook.shipbooksdk.Log +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.sin +import kotlin.math.sqrt + + +fun drawBallPenStroke( + canvas: Canvas, paint: Paint, strokeSize: Float, points: List +) { + val copyPaint = Paint(paint).apply { + this.strokeWidth = strokeSize + this.style = Paint.Style.STROKE + this.strokeCap = Paint.Cap.ROUND + this.strokeJoin = Paint.Join.ROUND + + this.isAntiAlias = true + } + + val path = Path() + val prePoint = PointF(points[0].x, points[0].y) + path.moveTo(prePoint.x, prePoint.y) + + for (point in points) { + // skip strange jump point. + if (abs(prePoint.y - point.y) >= 30) continue + path.quadTo(prePoint.x, prePoint.y, point.x, point.y) + prePoint.x = point.x + prePoint.y = point.y + } + + canvas.drawPath(path, copyPaint) +} + +fun drawMarkerStroke( + canvas: Canvas, paint: Paint, strokeSize: Float, points: List +) { + val copyPaint = Paint(paint).apply { + this.strokeWidth = strokeSize + this.style = Paint.Style.STROKE + this.strokeCap = Paint.Cap.ROUND + this.strokeJoin = Paint.Join.ROUND + this.isAntiAlias = true + this.alpha = 100 + + } + + val path = pointsToPath(points.map { SimplePointF(it.x, it.y) }) + + canvas.drawPath(path, copyPaint) +} + +fun drawFountainPenStroke( + canvas: Canvas, paint: Paint, strokeSize: Float, points: List +) { + val copyPaint = Paint(paint).apply { + this.strokeWidth = strokeSize + this.style = Paint.Style.STROKE + this.strokeCap = Paint.Cap.ROUND + this.strokeJoin = Paint.Join.ROUND +// this.blendMode = BlendMode.OVERLAY + this.isAntiAlias = true + } + + val path = Path() + val prePoint = PointF(points[0].x, points[0].y) + path.moveTo(prePoint.x, prePoint.y) + + for (point in points) { + // skip strange jump point. + if (abs(prePoint.y - point.y) >= 30) continue + path.quadTo(prePoint.x, prePoint.y, point.x, point.y) + prePoint.x = point.x + prePoint.y = point.y + copyPaint.strokeWidth = + (1.5f - strokeSize / 40f) * strokeSize * (1 - cos(0.5f * 3.14f * point.pressure / pressure)) + point.tiltX + point.tiltY + point.timestamp + + canvas.drawPath(path, copyPaint) + path.reset() + path.moveTo(point.x, point.y) + } +} + +fun drawStroke(canvas: Canvas, stroke: Stroke, offset: IntOffset) { + //canvas.save() + //canvas.translate(offset.x.toFloat(), offset.y.toFloat()) + + val paint = Paint().apply { + color = stroke.color + this.strokeWidth = stroke.size + } + + val points = strokeToTouchPoints(offsetStroke(stroke, offset.toOffset())) + + // Trying to find what throws error when drawing quickly + try { + when (stroke.pen) { + Pen.BALLPEN -> drawBallPenStroke(canvas, paint, stroke.size, points) + Pen.REDBALLPEN -> drawBallPenStroke(canvas, paint, stroke.size, points) + Pen.GREENBALLPEN -> drawBallPenStroke(canvas, paint, stroke.size, points) + Pen.BLUEBALLPEN -> drawBallPenStroke(canvas, paint, stroke.size, points) + // TODO: this functions for drawing are slow and unreliable + // replace them with something better + Pen.PENCIL -> NeoCharcoalPen.drawNormalStroke( + null, + canvas, + paint, + points, + stroke.color, + stroke.size, + ShapeCreateArgs(), + Matrix(), + false + ) + + Pen.BRUSH -> NeoBrushPen.drawStroke(canvas, paint, points, stroke.size, pressure, false) + Pen.MARKER -> { + if (GlobalAppSettings.current.neoTools) + NeoMarkerPen.drawStroke(canvas, paint, points, stroke.size, false) + else + drawMarkerStroke(canvas, paint, stroke.size, points) + } + + Pen.FOUNTAIN -> { + if (GlobalAppSettings.current.neoTools) + NeoFountainPen.drawStroke( + canvas, + paint, + points, + 1f, + stroke.size, + pressure, + false + ) + else + drawFountainPenStroke(canvas, paint, stroke.size, points) + } + + + } + } catch (e: Exception) { + Log.e(TAG, "draw.kt: Drawing strokes failed: ${e.message}") + } + //canvas.restore() +} + + +/** + * Draws an image onto the provided Canvas at a specified location and size, using its URI. + * + * This function performs the following steps: + * 1. Converts the URI of the image into a `Bitmap` object. + * 2. Converts the `ImageBitmap` to a software-backed `Bitmap` for compatibility. + * 3. Clears the value of `DrawCanvas.addImageByUri` to null. + * 4. Draws the specified bitmap onto the provided Canvas within a destination rectangle + * defined by the `Image` object coordinates (`x`, `y`) and its dimensions (`width`, `height`), + * adjusted by the `offset`. + * 5. Logs the success or failure of the operation. + * + * @param context The context used to retrieve the image from the URI. + * @param canvas The Canvas object where the image will be drawn. + * @param image The `Image` object containing details about the image (URI, position, and size). + * @param offset The `IntOffset` used to adjust the drawing position relative to the Canvas. + */ +fun drawImage(context: Context, canvas: Canvas, image: Image, offset: IntOffset) { + val imageBitmap = uriToBitmap(context, Uri.parse(image.uri))?.asImageBitmap() + if (imageBitmap != null) { + // Convert the image to a software-backed bitmap + val softwareBitmap = + imageBitmap.asAndroidBitmap().copy(Bitmap.Config.ARGB_8888, true) + + DrawCanvas.addImageByUri.value = null + + val rectOnImage = Rect(0, 0, imageBitmap.width, imageBitmap.height) + val rectOnCanvas = Rect( + image.x + offset.x, + image.y + offset.y, + image.x + image.width + offset.x, + image.y + image.height + offset.y + ) + // Draw the bitmap on the canvas at the center of the page + canvas.drawBitmap(softwareBitmap, rectOnImage, rectOnCanvas, null) + + // Log after drawing + Log.i(TAG, "Image drawn successfully at center!") + } else + Log.e(TAG, "Could not get image from: ${image.uri}") +} + + +const val padding = 0 +const val lineHeight = 80 +const val dotSize = 6f +const val hexVerticalCount = 26 + +fun drawLinedBg(canvas: Canvas, scroll: Int, scale: Float) { + val height = (canvas.height / scale).toInt() + val width = (canvas.width / scale).toInt() + + // white bg + canvas.drawColor(Color.WHITE) + + // paint + val paint = Paint().apply { + this.color = Color.GRAY + this.strokeWidth = 1f + } + + // lines + for (y in 0..height) { + val line = scroll + y + if (line % lineHeight == 0) { + canvas.drawLine( + padding.toFloat(), y.toFloat(), (width - padding).toFloat(), y.toFloat(), paint + ) + } + } +} + +fun drawDottedBg(canvas: Canvas, offset: Int, scale: Float) { + val height = (canvas.height / scale).toInt() + val width = (canvas.width / scale).toInt() + + // white bg + canvas.drawColor(Color.WHITE) + + // paint + val paint = Paint().apply { + this.color = Color.GRAY + this.strokeWidth = 1f + } + + // dots + for (y in 0..height) { + val line = offset + y + if (line % lineHeight == 0 && line >= padding) { + for (x in padding..width - padding step lineHeight) { + canvas.drawOval( + x.toFloat() - dotSize / 2, + y.toFloat() - dotSize / 2, + x.toFloat() + dotSize / 2, + y.toFloat() + dotSize / 2, + paint + ) + } + } + } + +} + +fun drawSquaredBg(canvas: Canvas, scroll: Int, scale: Float) { + val height = (canvas.height / scale).toInt() + val width = (canvas.width / scale).toInt() + + // white bg + canvas.drawColor(Color.WHITE) + + // paint + val paint = Paint().apply { + this.color = Color.GRAY + this.strokeWidth = 1f + } + + // lines + for (y in 0..height) { + val line = scroll + y + if (line % lineHeight == 0) { + canvas.drawLine( + padding.toFloat(), y.toFloat(), (width - padding).toFloat(), y.toFloat(), paint + ) + } + } + + for (x in padding..width - padding step lineHeight) { + canvas.drawLine( + x.toFloat(), padding.toFloat(), x.toFloat(), height.toFloat(), paint + ) + } +} + +fun drawHexedBg(canvas: Canvas, scroll: Int, scale: Float) { + val height = (canvas.height / scale) + val width = (canvas.width / scale) + + // background + canvas.drawColor(Color.WHITE) + + // stroke + val paint = Paint().apply { + this.color = Color.GRAY + this.strokeWidth = 1f + this.style = Paint.Style.STROKE + } + + // https://www.redblobgames.com/grids/hexagons/#spacing + val r = max(width, height) / (hexVerticalCount * 1.5f) + val hexHeight = r * 2 + val hexWidth = r * sqrt(3f) + + val rows = (height / hexVerticalCount).toInt() + val cols = (width / hexWidth).toInt() + + for (row in 0..rows) { + val offsetX = if (row % 2 == 0) 0f else hexWidth / 2 + + for (col in 0..cols) { + val x = col * hexWidth + offsetX + val y = row * hexHeight * 0.75f - scroll.toFloat().mod(hexHeight * 1.5f) + drawHexagon(canvas, x, y, r, paint) + } + } +} + +fun drawHexagon(canvas: Canvas, centerX: Float, centerY: Float, r: Float, paint: Paint) { + val path = Path() + for (i in 0..5) { + val angle = Math.toRadians((30 + 60 * i).toDouble()) + val x = (centerX + r * cos(angle)).toFloat() + val y = (centerY + r * sin(angle)).toFloat() + if (i == 0) { + path.moveTo(x, y) + } else { + path.lineTo(x, y) + } + } + path.close() + canvas.drawPath(path, paint) +} + +fun drawBg(canvas: Canvas, nativeTemplate: String, scroll: Int, scale: Float = 1f) { + when (nativeTemplate) { + "blank" -> canvas.drawColor(Color.WHITE) + "dotted" -> drawDottedBg(canvas, scroll, scale) + "lined" -> drawLinedBg(canvas, scroll, scale) + "squared" -> drawSquaredBg(canvas, scroll, scale) + "hexed" -> drawHexedBg(canvas, scroll, scale) + } + + // in landscape orientation add margin to indicate what will be visible in vertical orientation. + if (SCREEN_WIDTH > SCREEN_HEIGHT) { + val paint = Paint().apply { + this.color = Color.MAGENTA + this.strokeWidth = 2f + } + // Draw vertical line with x= SCREEN_HEIGHT + canvas.drawLine( + SCREEN_HEIGHT.toFloat(), + padding.toFloat(), + SCREEN_HEIGHT.toFloat(), + (SCREEN_HEIGHT - padding).toFloat(), + paint + ) + } +} + +val selectPaint = Paint().apply { + strokeWidth = 5f + style = Paint.Style.STROKE + pathEffect = DashPathEffect(floatArrayOf(20f, 10f), 0f) + isAntiAlias = true + color = Color.GRAY +} \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/utils/eraser.kt b/app/src/main/java/com/ethran/notable/utils/eraser.kt new file mode 100644 index 00000000..2aae32fb --- /dev/null +++ b/app/src/main/java/com/ethran/notable/utils/eraser.kt @@ -0,0 +1,6 @@ +package com.ethran.notable.utils + +enum class Eraser(val _name: String) { + PEN("PEN"), + SELECT("SELECT"), +} \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/utils/export.kt b/app/src/main/java/com/ethran/notable/utils/export.kt new file mode 100644 index 00000000..913bf8aa --- /dev/null +++ b/app/src/main/java/com/ethran/notable/utils/export.kt @@ -0,0 +1,160 @@ +package com.ethran.notable.utils + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.graphics.pdf.PdfDocument +import android.os.Environment +import android.provider.MediaStore +import com.ethran.notable.TAG +import com.ethran.notable.db.BookRepository +import com.ethran.notable.db.PageRepository +import io.shipbook.shipbooksdk.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException +import java.io.OutputStream + +suspend fun exportBook(context: Context, bookId: String): String { + val book = BookRepository(context).getById(bookId) ?: return "Book ID not found" + val pages = PageRepository(context) + Log.v(TAG, "Exporting book: " + book.title) + + val result = saveFile(context, book.title, "pdf") { outputStream -> + val document = PdfDocument() + book.pageIds.forEachIndexed { i, pageId -> + document.writePage(context, i + 1, pages, pageId) + } + document.writeTo(outputStream) + document.close() + } + + copyBookPdfLinkForObsidian(context, bookId, book.title) + return result +} + +suspend fun exportPage(context: Context, pageId: String): String { + val pages = PageRepository(context) + val result = saveFile(context, "notable-page-${pageId}", "pdf") { outputStream -> + val document = PdfDocument() + document.writePage(context, 1, pages, pageId) + document.writeTo(outputStream) + document.close() + } + return result +} + +suspend fun exportBookToPng(context: Context, bookId: String): String { + val book = BookRepository(context).getById(bookId) ?: return "Book ID not found" + book.pageIds.forEachIndexed { _, pageId -> + val bitmap = drawCanvas(context, pageId) + saveFile(context, pageId, "png", book.title) { outputStream -> + try { + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) + bitmap.recycle() + } catch (e: Exception) { + io.shipbook.shipbooksdk.Log.e(TAG + "ExportPNG", "Error saving PNG: ${e.message}") + throw e + } + } + } + return "Book saved successfully at notable/${book.title}" +} + + +suspend fun exportPageToJpeg(context: Context, pageId: String): String { + val bitmap = drawCanvas(context, pageId) + + return saveFile(context, "notable-page-${pageId}", "jpg") { outputStream -> + try { + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) + bitmap.recycle() + } catch (e: Exception) { + io.shipbook.shipbooksdk.Log.e(TAG + "ExportJpeg", "Error saving JPEG: ${e.message}") + throw e + } + } +} + +suspend fun exportPageToPng(context: Context, pageId: String): String { + val bitmap = drawCanvas(context, pageId) + + return saveFile(context, "notable-page-${pageId}", "png") { outputStream -> + try { + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) + bitmap.recycle() + copyPagePngLinkForObsidian(context, pageId) + } catch (e: Exception) { + io.shipbook.shipbooksdk.Log.e(TAG + "ExportPNG", "Error saving PNG: ${e.message}") + throw e + } + } +} + +suspend fun saveFile( + context: Context, + fileName: String, + format: String, + dictionary: String = "", + generateContent: (OutputStream) -> Unit +): String = withContext(Dispatchers.IO) { + try { + val mimeType = when (format.lowercase()) { + "pdf" -> "application/pdf" + "jpg", "jpeg" -> "image/jpeg" + "png" -> "image/png" + else -> return@withContext "Unsupported file format" + } + + val contentValues = ContentValues().apply { + put(MediaStore.Files.FileColumns.DISPLAY_NAME, "$fileName.$format") + put(MediaStore.Files.FileColumns.MIME_TYPE, mimeType) + put( + MediaStore.Files.FileColumns.RELATIVE_PATH, + Environment.DIRECTORY_DOCUMENTS + "/Notable/" + dictionary + ) + } + + val resolver = context.contentResolver + val uri = resolver.insert(MediaStore.Files.getContentUri("external"), contentValues) + ?: throw IOException("Failed to create Media Store entry") + + resolver.openOutputStream(uri)?.use { outputStream -> + generateContent(outputStream) + } + + "File saved successfully as $fileName.$format" + } catch (e: SecurityException) { + Log.e(TAG + "SaveFile", "Permission error: ${e.message}") + "Permission denied. Please allow storage access and try again." + } catch (e: IOException) { + Log.e(TAG + "SaveFile", "I/O error while saving file: ${e.message}") + "An error occurred while saving the file." + } catch (e: Exception) { + Log.e(TAG + "SaveFile", "Unexpected error: ${e.message}") + "Unexpected error occurred. Please try again." + } +} + +fun copyPagePngLinkForObsidian(context: Context, pageId: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val textToCopy = """ + [[../attachments/Notable/Pages/notable-page-${pageId}.png]] + [[Notable Link][notable://page-${pageId}]] + """.trimIndent() + val clip = ClipData.newPlainText("Notable Page Link", textToCopy) + clipboard.setPrimaryClip(clip) +} + + +fun copyBookPdfLinkForObsidian(context: Context, bookId: String, bookName: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val textToCopy = """ + [[../attachments/Notable/Notebooks/${bookName}.pdf]] + [[Notable Book Link][notable://book-${bookId}]] + """.trimIndent() + val clip = ClipData.newPlainText("Notable Book PDF Link", textToCopy) + clipboard.setPrimaryClip(clip) +} \ No newline at end of file diff --git a/app/src/main/java/com/olup/notable/utils/history.kt b/app/src/main/java/com/ethran/notable/utils/history.kt similarity index 58% rename from app/src/main/java/com/olup/notable/utils/history.kt rename to app/src/main/java/com/ethran/notable/utils/history.kt index 2dc1587b..b78f2aa8 100644 --- a/app/src/main/java/com/olup/notable/utils/history.kt +++ b/app/src/main/java/com/ethran/notable/utils/history.kt @@ -1,7 +1,13 @@ -package com.olup.notable +package com.ethran.notable.utils import android.graphics.Rect -import com.olup.notable.db.Stroke +import com.ethran.notable.classes.DrawCanvas +import com.ethran.notable.classes.PageView +import com.ethran.notable.classes.SnackConf +import com.ethran.notable.classes.SnackState +import com.ethran.notable.db.Image +import com.ethran.notable.db.Stroke +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch @@ -10,6 +16,8 @@ import kotlinx.coroutines.launch sealed class Operation { data class DeleteStroke(val strokeIds: List) : Operation() data class AddStroke(val strokes: List) : Operation() + data class AddImage(val images: List) : Operation() + data class DeleteImage(val imageIds: List) : Operation() } typealias OperationBlock = List @@ -21,7 +29,9 @@ enum class UndoRedoType { } sealed class HistoryBusActions { - data class RegisterHistoryOperationBlock(val operationBlock: OperationBlock) : HistoryBusActions() + data class RegisterHistoryOperationBlock(val operationBlock: OperationBlock) : + HistoryBusActions() + data class MoveHistory(val type: UndoRedoType) : HistoryBusActions() } @@ -29,15 +39,16 @@ class History(coroutineScope: CoroutineScope, pageView: PageView) { private var undoList: OperationList = mutableListOf() private var redoList: OperationList = mutableListOf() - val pageModel = pageView + private val pageModel = pageView // TODO maybe not in a companion object ? companion object { val historyBus = MutableSharedFlow() - suspend fun registerHistoryOperationBlock(operationBlock : OperationBlock){ + suspend fun registerHistoryOperationBlock(operationBlock: OperationBlock) { historyBus.emit(HistoryBusActions.RegisterHistoryOperationBlock(operationBlock)) } - suspend fun moveHistory(type: UndoRedoType){ + + suspend fun moveHistory(type: UndoRedoType) { historyBus.emit(HistoryBusActions.MoveHistory(type)) } } @@ -48,12 +59,31 @@ class History(coroutineScope: CoroutineScope, pageView: PageView) { historyBus.collect { when (it) { is HistoryBusActions.MoveHistory -> { + // Wait for commit to history to complete + if(it.type == UndoRedoType.Undo){ + DrawCanvas.commitCompletion = CompletableDeferred() + DrawCanvas.commitHistorySignalImmediately.emit(Unit) + DrawCanvas.commitCompletion.await() + } val zoneAffected = undoRedo(type = it.type) - if(zoneAffected != null) { + if (zoneAffected != null) { pageView.drawArea(pageAreaToCanvasArea(zoneAffected, pageView.scroll)) + //moved to refresh after drawing + DrawCanvas.refreshUi.emit(Unit) + } else { + SnackState.globalSnackFlow.emit( + SnackConf( + text = "Nothing to undo/redo", + duration = 3000, + ) + ) } } - is HistoryBusActions.RegisterHistoryOperationBlock -> { addOperationsToHistory(it.operationBlock)} + + is HistoryBusActions.RegisterHistoryOperationBlock -> { + addOperationsToHistory(it.operationBlock) + } + else -> {} } } @@ -64,13 +94,28 @@ class History(coroutineScope: CoroutineScope, pageView: PageView) { return when (operation) { is Operation.AddStroke -> { pageModel.addStrokes(operation.strokes) - return Operation.DeleteStroke(strokeIds = operation.strokes.map{it.id}) to strokeBounds(operation.strokes) + return Operation.DeleteStroke(strokeIds = operation.strokes.map { it.id }) to strokeBounds( + operation.strokes + ) } + is Operation.DeleteStroke -> { val strokes = pageModel.getStrokes(operation.strokeIds).filterNotNull() pageModel.removeStrokes(operation.strokeIds) return Operation.AddStroke(strokes = strokes) to strokeBounds(strokes) } + is Operation.AddImage -> { + pageModel.addImage(operation.images) + return Operation.DeleteImage(imageIds = operation.images.map { it.id }) to imageBoundsInt( + operation.images + ) + } + is Operation.DeleteImage -> { + val images = pageModel.getImages(operation.imageIds).filterNotNull() + pageModel.removeImages(operation.imageIds) + return Operation.AddImage(images = images) to imageBoundsInt(images) + } + else -> { throw (java.lang.Error("Unhandled history operation")) } @@ -85,7 +130,7 @@ class History(coroutineScope: CoroutineScope, pageView: PageView) { if (originList.size == 0) return null - val operationBlock = originList.removeLast() + val operationBlock = originList.removeAt(originList.lastIndex) val revertOperations = mutableListOf() val zoneAffected = Rect() for (operation in operationBlock) { @@ -101,7 +146,7 @@ class History(coroutineScope: CoroutineScope, pageView: PageView) { fun addOperationsToHistory(operations: OperationBlock) { undoList.add(operations) - if(undoList.size > 5) undoList.removeFirst() + if (undoList.size > 5) undoList.removeAt(0) redoList.clear() } } \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/utils/page.kt b/app/src/main/java/com/ethran/notable/utils/page.kt new file mode 100644 index 00000000..2679292b --- /dev/null +++ b/app/src/main/java/com/ethran/notable/utils/page.kt @@ -0,0 +1,120 @@ +package com.ethran.notable.utils + + +import android.content.ContentResolver +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.ImageDecoder +import android.graphics.pdf.PdfDocument +import android.net.Uri +import android.os.Looper +import androidx.compose.ui.unit.IntOffset +import com.ethran.notable.SCREEN_HEIGHT +import com.ethran.notable.SCREEN_WIDTH +import com.ethran.notable.TAG +import com.ethran.notable.db.PageRepository +import com.ethran.notable.db.Stroke +import com.ethran.notable.modals.A4_HEIGHT +import com.ethran.notable.modals.A4_WIDTH +import io.shipbook.shipbooksdk.Log + + +fun drawCanvas(context: Context, pageId: String): Bitmap { + if (Looper.getMainLooper().isCurrentThread) + Log.e(TAG, "Exporting is done on main thread.") + + val pages = PageRepository(context) + val (page, strokes) = pages.getWithStrokeById(pageId) + val (_, images) = pages.getWithImageById(pageId) + + val strokeHeight = if (strokes.isEmpty()) 0 else strokes.maxOf(Stroke::bottom).toInt() + 50 + val strokeWidth = if (strokes.isEmpty()) 0 else strokes.maxOf(Stroke::right).toInt() + 50 + + val height = strokeHeight.coerceAtLeast(SCREEN_HEIGHT) + val width = strokeWidth.coerceAtLeast(SCREEN_WIDTH) + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + + // Draw background + drawBg(canvas, page.nativeTemplate, 0) + + // Draw strokes + for (stroke in strokes) { + drawStroke(canvas, stroke, IntOffset(0, 0)) + } + for (image in images) { + drawImage(context, canvas, image, IntOffset(0, 0)) + } + return bitmap +} + +fun PdfDocument.writePage(context: Context, number: Int, repo: PageRepository, id: String) { + if (Looper.getMainLooper().isCurrentThread) + Log.e(TAG, "Exporting is done on main thread.") + + val (page, strokes) = repo.getWithStrokeById(id) + //TODO: improve that function + val (_, images) = repo.getWithImageById(id) + + val strokeHeight = if (strokes.isEmpty()) 0 else strokes.maxOf(Stroke::bottom).toInt() + 50 + val strokeWidth = if (strokes.isEmpty()) 0 else strokes.maxOf(Stroke::right).toInt() + 50 + val scaleFactor = A4_WIDTH.toFloat() / SCREEN_WIDTH + + // todo do not rely on this anymore + // I slightly modified it, should be better + val contentHeight = strokeHeight.coerceAtLeast(SCREEN_HEIGHT) + val pageHeight = (contentHeight * scaleFactor).toInt() + val contentWidth = strokeWidth.coerceAtLeast(SCREEN_WIDTH) + + + val documentPage = + startPage(PdfDocument.PageInfo.Builder(A4_WIDTH, pageHeight, number).create()) + + // Center content on the A4 page + val offsetX = (A4_WIDTH - (contentWidth * scaleFactor)) / 2 + val offsetY = (A4_HEIGHT - (contentHeight * scaleFactor)) / 2 + + documentPage.canvas.scale(scaleFactor, scaleFactor) + drawBg(documentPage.canvas, page.nativeTemplate, 0, scaleFactor) + + for (image in images) { + drawImage(context, documentPage.canvas, image, IntOffset(0, 0)) + } + + for (stroke in strokes) { + drawStroke(documentPage.canvas, stroke, IntOffset(0, 0)) + } + + finishPage(documentPage) +} + + +/** + * Converts a URI to a Bitmap using the provided [context] and [uri]. + * + * @param context The context used to access the content resolver. + * @param uri The URI of the image to be converted to a Bitmap. + * @return The Bitmap representation of the image, or null if conversion fails. + * https://medium.com/@munbonecci/how-to-display-an-image-loaded-from-the-gallery-using-pick-visual-media-in-jetpack-compose-df83c78a66bf + */ +fun uriToBitmap(context: Context, uri: Uri): Bitmap? { + return try { + // Obtain the content resolver from the context + val contentResolver: ContentResolver = context.contentResolver + + // Since the minimum SDK is 29, we can directly use ImageDecoder to decode the Bitmap + val source = ImageDecoder.createSource(contentResolver, uri) + ImageDecoder.decodeBitmap(source) + } catch (e: SecurityException) { + Log.e(TAG, "SecurityException: ${e.message}", e) + null + } catch (e: ImageDecoder.DecodeException) { + Log.e(TAG, "DecodeException: ${e.message}", e) + null + } catch (e: Exception) { + Log.e(TAG, "Unexpected error: ${e.message}", e) + null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/utils/pen.kt b/app/src/main/java/com/ethran/notable/utils/pen.kt new file mode 100644 index 00000000..39817802 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/utils/pen.kt @@ -0,0 +1,45 @@ +package com.ethran.notable.utils + + +import com.onyx.android.sdk.pen.style.StrokeStyle + + +enum class Pen(val penName: String) { + BALLPEN("BALLPEN"), + REDBALLPEN("REDBALLPEN"), + GREENBALLPEN("GREENBALLPEN"), + BLUEBALLPEN("BLUEBALLPEN"), + PENCIL("PENCIL"), + BRUSH("BRUSH"), + MARKER("MARKER"), + FOUNTAIN("FOUNTAIN"); + + companion object { + fun fromString(name: String?): Pen { + return entries.find { it.penName.equals(name, ignoreCase = true) } ?: BALLPEN + } + } +} + +fun penToStroke(pen: Pen): Int { + return when (pen) { + Pen.BALLPEN -> StrokeStyle.PENCIL + Pen.REDBALLPEN -> StrokeStyle.PENCIL + Pen.GREENBALLPEN -> StrokeStyle.PENCIL + Pen.BLUEBALLPEN -> StrokeStyle.PENCIL + Pen.PENCIL -> StrokeStyle.CHARCOAL + Pen.BRUSH -> StrokeStyle.NEO_BRUSH + Pen.MARKER -> StrokeStyle.MARKER + Pen.FOUNTAIN -> StrokeStyle.FOUNTAIN + } +} + + +@kotlinx.serialization.Serializable +data class PenSetting( + var strokeSize: Float, + //TODO: Rename to strokeColor + var color: Int +) + +typealias NamedSettings = Map \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/utils/utils.kt b/app/src/main/java/com/ethran/notable/utils/utils.kt new file mode 100644 index 00000000..138fd279 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/utils/utils.kt @@ -0,0 +1,557 @@ +package com.ethran.notable.utils + +import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Point +import android.graphics.PointF +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.Region +import android.net.Uri +import android.util.TypedValue +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.Dp +import androidx.core.app.ShareCompat +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.core.graphics.createBitmap +import androidx.core.graphics.toRect +import androidx.core.graphics.toRegion +import com.ethran.notable.R +import com.ethran.notable.TAG +import com.ethran.notable.classes.AppRepository +import com.ethran.notable.classes.PageView +import com.ethran.notable.db.Image +import com.ethran.notable.db.Stroke +import com.ethran.notable.db.StrokePoint +import com.ethran.notable.modals.AppSettings +import com.onyx.android.sdk.data.note.TouchPoint +import io.shipbook.shipbooksdk.Log +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +fun Modifier.noRippleClickable( + onClick: () -> Unit +): Modifier = composed { + clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { + onClick() + } +} + + +fun convertDpToPixel(dp: Dp, context: Context): Float { +// val resources = context.resources +// val metrics: DisplayMetrics = resources.displayMetrics + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + dp.value, + context.resources.displayMetrics + ) +} + +// TODO move this to repository +fun deletePage(context: Context, pageId: String) { + val appRepository = AppRepository(context) + val page = appRepository.pageRepository.getById(pageId) ?: return + val proxy = appRepository.kvProxy + val settings = proxy.get("APPS_SETTINGS", AppSettings.serializer()) + + + runBlocking { + // remove from book + if (page.notebookId != null) { + appRepository.bookRepository.removePage(page.notebookId, pageId) + } + + // remove from quick nav + if (settings != null && settings.quickNavPages.contains(pageId)) { + proxy.setKv( + "APPS_SETTINGS", + settings.copy(quickNavPages = settings.quickNavPages - pageId), + AppSettings.serializer() + ) + } + + launch { + appRepository.pageRepository.delete(pageId) + } + launch { + val imgFile = File(context.filesDir, "pages/previews/thumbs/$pageId") + if (imgFile.exists()) { + imgFile.delete() + } + } + launch { + val imgFile = File(context.filesDir, "pages/previews/full/$pageId") + if (imgFile.exists()) { + imgFile.delete() + } + } + + } +} + +fun Flow.withPrevious(): Flow> = flow { + var prev: T? = null + this@withPrevious.collect { + emit(prev to it) + prev = it + } +} + +fun pointsToPath(points: List): Path { + val path = Path() + val prePoint = PointF(points[0].x, points[0].y) + path.moveTo(prePoint.x, prePoint.y) + + for (point in points) { + // skip strange jump point. + //if (abs(prePoint.y - point.y) >= 30) continue + path.quadTo(prePoint.x, prePoint.y, point.x, point.y) + prePoint.x = point.x + prePoint.y = point.y + } + return path +} + +// points is in page coordinates +fun handleErase( + page: PageView, + history: History, + points: List, + eraser: Eraser +) { + val paint = Paint().apply { + this.strokeWidth = 30f + this.style = Paint.Style.STROKE + this.strokeCap = Paint.Cap.ROUND + this.strokeJoin = Paint.Join.ROUND + this.isAntiAlias = true + } + val path = pointsToPath(points) + var outPath = Path() + + if (eraser == Eraser.SELECT) { + path.close() + outPath = path + } + + + if (eraser == Eraser.PEN) { + paint.getFillPath(path, outPath) + } + + val deletedStrokes = selectStrokesFromPath(page.strokes, outPath) + + val deletedStrokeIds = deletedStrokes.map { it.id } + page.removeStrokes(deletedStrokeIds) + + history.addOperationsToHistory(listOf(Operation.AddStroke(deletedStrokes))) + + page.drawArea( + area = pageAreaToCanvasArea(strokeBounds(deletedStrokes), page.scroll) + ) +} + +enum class SelectPointPosition { + LEFT, + RIGHT, + CENTER +} + +// touchpoints is in view coordinates +fun handleDraw( + page: PageView, + historyBucket: MutableList, + strokeSize: Float, + color: Int, + pen: Pen, + touchPoints: List +) { + try { + val initialPoint = touchPoints[0] + val boundingBox = RectF( + initialPoint.x, + initialPoint.y + page.scroll, + initialPoint.x, + initialPoint.y + page.scroll + ) + + val points = touchPoints.map { + boundingBox.union(it.x, it.y + page.scroll) + StrokePoint( + x = it.x, + y = it.y + page.scroll, + pressure = it.pressure, + size = it.size, + tiltX = it.tiltX, + tiltY = it.tiltY, + timestamp = it.timestamp, + ) + } + + boundingBox.inset(-strokeSize, -strokeSize) + + val stroke = Stroke( + size = strokeSize, + pen = pen, + pageId = page.id, + top = boundingBox.top, + bottom = boundingBox.bottom, + left = boundingBox.left, + right = boundingBox.right, + points = points, + color = color + ) + page.addStrokes(listOf(stroke)) + // this is causing lagging and crushing, neo pens are not good + page.drawArea(pageAreaToCanvasArea(strokeBounds(stroke).toRect(), page.scroll)) + historyBucket.add(stroke.id) + } catch (e: Exception) { + Log.e(TAG, "Handle Draw: An error occurred while handling the drawing: ${e.message}") + } +} + +/* +* Gets list of points, and return line from first point to last. +* The line consist of 100 points, I do not know how it works (for 20 it want draw correctly) +* Then it cals handle draw to make mark on canvas. + */ +fun handleLine( + page: PageView, + historyBucket: MutableList, + strokeSize: Float, + color: Int, + pen: Pen, + touchPoints: List +) { + val startPoint = touchPoints.first() + val endPoint = touchPoints.last() + + // Setting intermediate values for tilt and pressure + startPoint.tiltX = touchPoints[touchPoints.size / 10].tiltX + startPoint.tiltY = touchPoints[touchPoints.size / 10].tiltY + startPoint.pressure = touchPoints[touchPoints.size / 10].pressure + endPoint.tiltX = touchPoints[9 * touchPoints.size / 10].tiltX + endPoint.tiltY = touchPoints[9 * touchPoints.size / 10].tiltY + endPoint.pressure = touchPoints[9 * touchPoints.size / 10].pressure + + // Helper function to interpolate between two values + fun lerp(start: Float, end: Float, fraction: Float) = start + (end - start) * fraction + + val numberOfPoints = 100 // Define how many points should line have + val points2 = List(numberOfPoints) { i -> + val fraction = i.toFloat() / (numberOfPoints - 1) + val x = lerp(startPoint.x, endPoint.x, fraction) + val y = lerp(startPoint.y, endPoint.y, fraction) + val pressure = lerp(startPoint.pressure, endPoint.pressure, fraction) + val size = lerp(startPoint.size, endPoint.size, fraction) + val tiltX = (lerp(startPoint.tiltX.toFloat(), endPoint.tiltX.toFloat(), fraction)).toInt() + val tiltY = (lerp(startPoint.tiltY.toFloat(), endPoint.tiltY.toFloat(), fraction)).toInt() + val timestamp = System.currentTimeMillis() + + TouchPoint(x, y, pressure, size, tiltX, tiltY, timestamp) + } + + handleDraw(page, historyBucket, strokeSize, color, pen, points2) +} + + +inline fun Modifier.ifTrue(predicate: Boolean, builder: () -> Modifier) = + then(if (predicate) builder() else Modifier) + +fun strokeToTouchPoints(stroke: Stroke): List { + return stroke.points.map { + TouchPoint( + it.x, + it.y, + it.pressure, + stroke.size, + it.tiltX, + it.tiltY, + it.timestamp + ) + } +} + +fun pageAreaToCanvasArea(pageArea: Rect, scroll: Int): Rect { + return Rect( + pageArea.left, pageArea.top - scroll, pageArea.right, pageArea.bottom - scroll + ) +} + +fun strokeBounds(stroke: Stroke): RectF { + return RectF( + stroke.left, stroke.top, stroke.right, stroke.bottom + ) +} + +fun imageBounds(image: Image): RectF { + return RectF( + image.x.toFloat(), + image.y.toFloat(), + image.x + image.width.toFloat(), + image.y + image.height.toFloat() + ) +} + +fun imagePoints(image: Image): Array { + return arrayOf( + Point(image.x, image.y), + Point(image.x, image.y + image.height), + Point(image.x + image.width, image.y), + Point(image.x + image.width, image.y + image.height), + ) +} + +fun strokeBounds(strokes: List): Rect { + if (strokes.isEmpty()) return Rect() + val stroke = strokes[0] + val rect = Rect( + stroke.left.toInt(), stroke.top.toInt(), stroke.right.toInt(), stroke.bottom.toInt() + ) + strokes.forEach { + rect.union( + Rect( + it.left.toInt(), it.top.toInt(), it.right.toInt(), it.bottom.toInt() + ) + ) + } + return rect +} + +fun imageBoundsInt(image: Image, padding: Int = 0): Rect { + return Rect( + image.x + padding, + image.y + padding, + image.x + image.width + padding, + image.y + image.height + padding + ) +} + +fun imageBoundsInt(images: List): Rect { + if (images.isEmpty()) return Rect() + val rect = imageBoundsInt(images[0]) + images.forEach { + rect.union( + imageBoundsInt(it) + ) + } + return rect +} + +//data class SimplePoint(val x: Int, val y: Int) +data class SimplePointF(val x: Float, val y: Float) + +fun pathToRegion(path: Path): Region { + val bounds = RectF() + // TODO: it deprecated, find replacement. + path.computeBounds(bounds, true) + val region = Region() + region.setPath( + path, + bounds.toRegion() + ) + return region +} + +fun divideStrokesFromCut( + strokes: List, + cutLine: List +): Pair, List> { + val maxY = cutLine.maxOfOrNull { it.y } + val cutArea = listOf(SimplePointF(0f, maxY!!)) + cutLine + listOf( + SimplePointF( + cutLine.last().x, + maxY + ) + ) + val cutPath = pointsToPath(cutArea) + cutPath.close() + + val bounds = RectF().apply { + cutPath.computeBounds(this, true) + } + val cutRegion = pathToRegion(cutPath) + + val strokesOver: MutableList = mutableListOf() + val strokesUnder: MutableList = mutableListOf() + + strokes.forEach { stroke -> + if (stroke.top > bounds.bottom) strokesUnder.add(stroke) + else if (stroke.bottom < bounds.top) strokesOver.add(stroke) + else { + if (stroke.points.any { point -> + cutRegion.contains( + point.x.toInt(), + point.y.toInt() + ) + }) strokesUnder.add(stroke) + else strokesOver.add(stroke) + } + } + + return strokesOver to strokesUnder +} + +fun selectStrokesFromPath(strokes: List, path: Path): List { + val bounds = RectF() + path.computeBounds(bounds, true) + + //region is only 16 bit, so we need to move our region + val translatedPath = Path(path) + translatedPath.offset(0f, -bounds.top) + val region = pathToRegion(translatedPath) + + return strokes.filter { + strokeBounds(it).intersect(bounds) + }.filter { it.points.any { region.contains(it.x.toInt(), (it.y - bounds.top).toInt()) } } +} + +fun selectImagesFromPath(images: List, path: Path): List { + val bounds = RectF() + path.computeBounds(bounds, true) + + //region is only 16 bit, so we need to move our region + val translatedPath = Path(path) + translatedPath.offset(0f, -bounds.top) + val region = pathToRegion(translatedPath) + + return images.filter { + imageBounds(it).intersect(bounds) + }.filter { + // include image if all its corners are within region + imagePoints(it).all { region.contains(it.x, (it.y - bounds.top).toInt()) } + } +} + +fun offsetStroke(stroke: Stroke, offset: Offset): Stroke { + return stroke.copy( + points = stroke.points.map { p -> p.copy(x = p.x + offset.x, y = p.y + offset.y) }, + top = stroke.top + offset.y, + bottom = stroke.bottom + offset.y, + left = stroke.left + offset.x, + right = stroke.right + offset.x, + ) +} + +fun offsetImage(image: Image, offset: Offset): Image { + return image.copy( + x = image.x + offset.x.toInt(), + y = image.y + offset.y.toInt(), + height = image.height, + width = image.width, + uri = image.uri, + pageId = image.pageId + ) +} + +// Why it is needed? I try to removed it, and sharing bimap seems to work. +class Provider : FileProvider(R.xml.file_paths) + +fun shareBitmap(context: Context, bitmap: Bitmap) { + val bmpWithBackground = createBitmap(bitmap.width, bitmap.height) + val canvas = Canvas(bmpWithBackground) + canvas.drawColor(Color.WHITE) + canvas.drawBitmap(bitmap, 0f, 0f, null) + + val cachePath = File(context.cacheDir, "images") + Log.i(TAG, cachePath.toString()) + cachePath.mkdirs() + try { + val stream = FileOutputStream(File(cachePath, "share.png")) + bmpWithBackground.compress(Bitmap.CompressFormat.PNG, 100, stream) + stream.close() + } catch (e: IOException) { + e.printStackTrace() + return + } + + val bitmapFile = File(cachePath, "share.png") + val contentUri = FileProvider.getUriForFile( + context, + "com.ethran.notable.provider", //(use your app signature + ".provider" ) + bitmapFile + ) + + // Use ShareCompat for safe sharing + val shareIntent = ShareCompat.IntentBuilder.from(context as Activity) + .setStream(contentUri) + .setType("image/png") + .intent + .apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + context.startActivity(Intent.createChooser(shareIntent, "Choose an app")) +} + + + +// move to SelectionState? +fun copyBitmapToClipboard(context: Context, bitmap: Bitmap) { + // Save bitmap to cache and get a URI + val uri = saveBitmapToCache(context, bitmap) ?: return + + // Grant temporary permission to read the URI + context.grantUriPermission( + context.packageName, + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + // Create a ClipData holding the URI + val clipData = ClipData.newUri(context.contentResolver, "Image", uri) + + // Set the ClipData to the clipboard + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(clipData) +} + +fun saveBitmapToCache(context: Context, bitmap: Bitmap): Uri? { + val bmpWithBackground = createBitmap(bitmap.width, bitmap.height) + val canvas = Canvas(bmpWithBackground) + canvas.drawColor(Color.WHITE) + canvas.drawBitmap(bitmap, 0f, 0f, null) + + val cachePath = File(context.cacheDir, "images") + Log.i(TAG, cachePath.toString()) + cachePath.mkdirs() + try { + val stream = + FileOutputStream("$cachePath/share.png") + bmpWithBackground.compress( + Bitmap.CompressFormat.PNG, + 100, + stream + ) + stream.close() + } catch (e: IOException) { + e.printStackTrace() + } + + val bitmapFile = File(cachePath, "share.png") + return FileProvider.getUriForFile( + context, + "com.ethran.notable.provider", //(use your app signature + ".provider" ) + bitmapFile + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/olup/notable/utils/versionChecker.kt b/app/src/main/java/com/ethran/notable/utils/versionChecker.kt similarity index 77% rename from app/src/main/java/com/olup/notable/utils/versionChecker.kt rename to app/src/main/java/com/ethran/notable/utils/versionChecker.kt index 03889625..40f98ff8 100644 --- a/app/src/main/java/com/olup/notable/utils/versionChecker.kt +++ b/app/src/main/java/com/ethran/notable/utils/versionChecker.kt @@ -1,11 +1,13 @@ -package com.olup.notable +package com.ethran.notable.utils import android.content.Context import android.content.pm.PackageManager +import com.ethran.notable.BuildConfig +import com.ethran.notable.TAG +import com.ethran.notable.classes.showHint import io.shipbook.shipbooksdk.Log -import java.net.URL -import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json +import java.net.URL @kotlinx.serialization.Serializable data class ghVersion(val name: String, val prerelease: Boolean, val html_url: String) @@ -68,27 +70,36 @@ fun isLatestVersion(context: Context, force: Boolean = false): Boolean { if (!force && isLatestVersion != null) return isLatestVersion!! try { + if (BuildConfig.VERSION_NAME.contains("next")) { + // TODO + } val version = getCurrentVersionName(context) - val latestVersion = getLatestReleaseVersion("olup", "notable") - Log.i(TAG, "Version is ${version} and latest on repo is ${latestVersion}") + val latestVersion = getLatestReleaseVersion("ethran", "notable") + Log.i(TAG, "Version is $version and latest on repo is $latestVersion") // If either version is null, we can't compare them if (latestVersion == null || version == null) { throw Exception("One of the version is null - comparison is impossible") } - val versionVersion = Version.fromString(version!!) - val latestVersionVersion = Version.fromString(latestVersion!!) + val versionVersion = Version.fromString(version) + val latestVersionVersion = Version.fromString(latestVersion) // If either version does not fit simple semantic version don't compare if (latestVersionVersion == null || versionVersion == null) { throw Exception( - "One of the version doesn't match simple semantic - comparison is impossible" + "One of the version doesn't match simple semantic - comparison is impossible" ) } isLatestVersion = versionVersion.compareTo(latestVersionVersion) != -1 - + if (!isLatestVersion!!) { + showHint( + "A newer version is available!\nYou are using version $version, " + + "while the latest version available is $latestVersion.", + duration = 5000 + ) + } return isLatestVersion!! } catch (e: Exception) { Log.i(TAG, "Failed to fetch latest release version: ${e.message}") @@ -96,4 +107,4 @@ fun isLatestVersion(context: Context, force: Boolean = false): Boolean { } } -val isNext = BuildConfig.IS_NEXT +const val isNext = BuildConfig.IS_NEXT diff --git a/app/src/main/java/com/olup/notable/views/EditorView.kt b/app/src/main/java/com/ethran/notable/views/EditorView.kt similarity index 50% rename from app/src/main/java/com/olup/notable/views/EditorView.kt rename to app/src/main/java/com/ethran/notable/views/EditorView.kt index 50f2bf43..744a121f 100644 --- a/app/src/main/java/com/olup/notable/views/EditorView.kt +++ b/app/src/main/java/com/ethran/notable/views/EditorView.kt @@ -1,19 +1,45 @@ -package com.olup.notable +package com.ethran.notable.views -import io.shipbook.shipbooksdk.Log import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.gestures.* -import androidx.compose.foundation.layout.* -import androidx.compose.material.* -import androidx.compose.runtime.* +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.* import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.unit.dp import androidx.navigation.NavController -import com.olup.notable.db.* -import com.olup.notable.ui.theme.InkaTheme -import com.onyx.android.sdk.pen.* +import com.ethran.notable.TAG +import com.ethran.notable.classes.AppRepository +import com.ethran.notable.classes.DrawCanvas +import com.ethran.notable.classes.EditorControlTower +import com.ethran.notable.classes.PageView +import com.ethran.notable.components.EditorGestureReceiver +import com.ethran.notable.components.EditorSurface +import com.ethran.notable.components.ScrollIndicator +import com.ethran.notable.components.SelectedBitmap +import com.ethran.notable.components.Toolbar +import com.ethran.notable.datastore.EditorSettingCacheManager +import com.ethran.notable.modals.AppSettings +import com.ethran.notable.modals.GlobalAppSettings +import com.ethran.notable.ui.theme.InkaTheme +import com.ethran.notable.utils.EditorState +import com.ethran.notable.utils.History +import com.ethran.notable.utils.convertDpToPixel +import io.shipbook.shipbooksdk.Log @OptIn(ExperimentalComposeUiApi::class) @@ -37,9 +63,9 @@ fun EditorView( return } - BoxWithConstraints() { - var height = convertDpToPixel(this.maxHeight, context).toInt() - var width = convertDpToPixel(this.maxWidth, context).toInt() + BoxWithConstraints { + val height = convertDpToPixel(this.maxHeight, context).toInt() + val width = convertDpToPixel(this.maxWidth, context).toInt() val page = remember { @@ -53,6 +79,21 @@ fun EditorView( ) } + //cancel loading strokes. + DisposableEffect(Unit) { + onDispose { + page.cleanJob() + } + } + + // Dynamically update the page width when the Box constraints change + LaunchedEffect(width, height) { + if (page.width != width || page.viewHeight != height) { + page.updateDimensions(width, height) + DrawCanvas.refreshUi.emit(Unit) + } + } + val editorState = remember { EditorState(bookId = _bookId, pageId = _pageId, pageView = page) } @@ -79,7 +120,7 @@ fun EditorView( editorState.penSettings, editorState.mode ) { - Log.i(TAG, "saving") + Log.i(TAG, "EditorView: saving") EditorSettingCacheManager.setEditorSettings( context, EditorSettingCacheManager.EditorSettings( @@ -97,7 +138,7 @@ fun EditorView( fun goToNextPage() { if (_bookId != null) { val newPageId = appRepository.getNextPageIdFromBookAndPage( - pageId = _pageId, notebookId = _bookId!! + pageId = _pageId, notebookId = _bookId ) navController.navigate("books/${_bookId}/pages/${newPageId}") { popUpTo(lastRoute!!.destination.id) { @@ -110,13 +151,13 @@ fun EditorView( fun goToPreviousPage() { if (_bookId != null) { val newPageId = appRepository.getPreviousPageIdFromBookAndPage( - pageId = _pageId, notebookId = _bookId!! + pageId = _pageId, notebookId = _bookId ) if (newPageId != null) navController.navigate("books/${_bookId}/pages/${newPageId}") } } - + val toolbarPosition = GlobalAppSettings.current.toolbarPosition InkaTheme { EditorSurface( @@ -128,14 +169,41 @@ fun EditorView( controlTower = editorControlTower, state = editorState ) - SelectedBitmap(editorState = editorState, controlTower = editorControlTower) - Row(modifier = Modifier.fillMaxWidth().fillMaxHeight()){ + SelectedBitmap( + context = context, + editorState = editorState, + controlTower = editorControlTower + ) + Row( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { Spacer(modifier = Modifier.weight(1f)) ScrollIndicator(context = context, state = editorState) } - Toolbar( - navController = navController, state = editorState - ) + // Toolbar at Top or Bottom + when (toolbarPosition) { + AppSettings.Position.Top -> { + Toolbar( + navController = navController, + state = editorState, + controlTower = editorControlTower + ) + } + + AppSettings.Position.Bottom -> { + Column(Modifier.fillMaxWidth().fillMaxHeight()) { //this fixes this + Spacer(modifier = Modifier.weight(1f)) + // Top/center content here + Toolbar( + navController = navController, + state = editorState, + controlTower = editorControlTower + ) + } + } + } } } diff --git a/app/src/main/java/com/ethran/notable/views/FloatingEditorView.kt b/app/src/main/java/com/ethran/notable/views/FloatingEditorView.kt new file mode 100644 index 00000000..f18a2c3b --- /dev/null +++ b/app/src/main/java/com/ethran/notable/views/FloatingEditorView.kt @@ -0,0 +1,94 @@ +package com.ethran.notable.views + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.navigation.NavController +import com.ethran.notable.classes.AppRepository +import com.ethran.notable.db.Page +import com.ethran.notable.modals.AppSettings +import com.ethran.notable.modals.GlobalAppSettings +import com.ethran.notable.ui.theme.InkaTheme + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun FloatingEditorView( + navController: NavController, + bookId: String? = null, + pageId: String? = null, + onDismissRequest: () -> Unit +) { + // TODO: + var isFullScreen by remember { mutableStateOf(false) } // State for full-screen mode + + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true) + ) { + InkaTheme { + Box( + modifier = Modifier + .fillMaxSize() // Ensure it fills the entire screen + .background(Color.White) + ) { + Column { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .fillMaxHeight() + .background(Color.White) + ) { + if (pageId != null) { + EditorView( + navController = navController, + _bookId = null, + _pageId = pageId + ) + } else if (bookId != null) { + // get first page of notebook and use it as pageId + val appRepository = AppRepository(LocalContext.current) + val firstPageId = + appRepository.bookRepository.getById(bookId)?.pageIds?.firstOrNull() + if (firstPageId == null) { + // new page uuid + val page = Page( + notebookId = null, + parentFolderId = null, + nativeTemplate = GlobalAppSettings.current.defaultNativeTemplate + ) + EditorView( + navController = navController, + _bookId = bookId, + _pageId = page.id + ) + } else { + EditorView( + navController = navController, + _bookId = bookId, + _pageId = firstPageId + ) + } + + + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/ethran/notable/views/HomeView.kt b/app/src/main/java/com/ethran/notable/views/HomeView.kt new file mode 100644 index 00000000..3f97f6e6 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/views/HomeView.kt @@ -0,0 +1,467 @@ +package com.ethran.notable.views + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Badge +import androidx.compose.material.BadgedBox +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.ethran.notable.TAG +import com.ethran.notable.classes.AppRepository +import com.ethran.notable.classes.LocalSnackContext +import com.ethran.notable.classes.SnackConf +import com.ethran.notable.classes.XoppFile +import com.ethran.notable.components.BreadCrumb +import com.ethran.notable.components.PageMenu +import com.ethran.notable.components.PagePreview +import com.ethran.notable.components.ShowConfirmationDialog +import com.ethran.notable.components.Topbar +import com.ethran.notable.db.BookRepository +import com.ethran.notable.db.Folder +import com.ethran.notable.db.Notebook +import com.ethran.notable.db.Page +import com.ethran.notable.modals.AppSettingsModal +import com.ethran.notable.modals.FolderConfigDialog +import com.ethran.notable.modals.GlobalAppSettings +import com.ethran.notable.modals.NotebookConfigDialog +import com.ethran.notable.utils.isLatestVersion +import com.ethran.notable.utils.noRippleClickable +import compose.icons.FeatherIcons +import compose.icons.feathericons.FilePlus +import compose.icons.feathericons.Folder +import compose.icons.feathericons.FolderPlus +import compose.icons.feathericons.Settings +import compose.icons.feathericons.Upload +import io.shipbook.shipbooksdk.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlin.concurrent.thread + +@ExperimentalFoundationApi +@ExperimentalComposeUiApi +@Composable +fun Library(navController: NavController, folderId: String? = null) { + val context = LocalContext.current + + var isSettingsOpen by remember { + mutableStateOf(false) + } + val appRepository = AppRepository(LocalContext.current) + + val books by appRepository.bookRepository.getAllInFolder(folderId).observeAsState() + val singlePages by appRepository.pageRepository.getSinglePagesInFolder(folderId) + .observeAsState() + val folders by appRepository.folderRepository.getAllInFolder(folderId).observeAsState() + val bookRepository = BookRepository(LocalContext.current) + + var isLatestVersion by remember { + mutableStateOf(true) + } + LaunchedEffect(key1 = Unit, block = { + thread { + isLatestVersion = isLatestVersion(context, true) + } + }) + + var importInProgress = false + + var showFloatingEditor by remember { mutableStateOf(false) } + var floatingEditorPageId by remember { mutableStateOf(null) } + + val snackManager = LocalSnackContext.current + + Column( + Modifier.fillMaxSize() + ) { + Topbar { + Row(Modifier.fillMaxWidth()) { + Spacer(modifier = Modifier.weight(1f)) + BadgedBox( + badge = { + if (!isLatestVersion) Badge( + backgroundColor = Color.Black, + modifier = Modifier.offset(-12.dp, 10.dp) + ) + } + ) { + Icon( + imageVector = FeatherIcons.Settings, + contentDescription = "", + Modifier + .padding(8.dp) + .noRippleClickable { + isSettingsOpen = true + }) + } + } + Row( + Modifier + .padding(10.dp) + ) { + BreadCrumb(folderId) { navController.navigate("library" + if (it == null) "" else "?folderId=${it}") } + } +// I do not know what the idea behind it was +// // Add the new "Floating Editor" button here +// Text(text = "Floating Editor", +// textAlign = TextAlign.Center, +// modifier = Modifier +// .noRippleClickable { +// val page = Page( +// notebookId = null, +// parentFolderId = folderId, +// nativeTemplate = appRepository.kvProxy.get( +// "APP_SETTINGS", AppSettings.serializer() +// )?.defaultNativeTemplate ?: "blank" +// ) +// appRepository.pageRepository.create(page) +// floatingEditorPageId = page.id +// showFloatingEditor = true +// } +// .padding(10.dp)) + + } + + Column( + Modifier.padding(10.dp) + ) { + + Spacer(Modifier.height(10.dp)) + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth() + ) { + item { + // Add new folder row + Row( + Modifier + .noRippleClickable { + val folder = Folder(parentFolderId = folderId) + appRepository.folderRepository.create(folder) + } + .border(0.5.dp, Color.Black) + .padding(horizontal = 10.dp, vertical = 5.dp) + ) { + Icon( + imageVector = FeatherIcons.FolderPlus, + contentDescription = "Add Folder Icon", + Modifier.height(20.dp) + ) + Spacer(Modifier.width(10.dp)) + Text(text = "Add new folder") + } + } + if (folders?.isNotEmpty() == true) { + items(folders!!) { folder -> + var isFolderSettingsOpen by remember { mutableStateOf(false) } + if (isFolderSettingsOpen) FolderConfigDialog( + folderId = folder.id, + onClose = { + Log.i(TAG, "Closing Directory Dialog") + isFolderSettingsOpen = false + }) + Row( + Modifier + .combinedClickable( + onClick = { + navController.navigate("library?folderId=${folder.id}") + }, + onLongClick = { + isFolderSettingsOpen = !isFolderSettingsOpen + }, + ) + .border(0.5.dp, Color.Black) + .padding(10.dp, 5.dp) + ) { + Icon( + imageVector = FeatherIcons.Folder, + contentDescription = "folder icon", + Modifier.height(20.dp) + ) + Spacer(Modifier.width(10.dp)) + Text(text = folder.title) + } + } + } + } + Spacer(Modifier.height(10.dp)) + Text(text = "Quick pages") + Spacer(Modifier.height(10.dp)) + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth() + ) { + // Add the "Add quick page" button + item { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .width(100.dp) + .aspectRatio(3f / 4f) + .border(1.dp, Color.Gray, RectangleShape) + .noRippleClickable { + val page = Page( + notebookId = null, + parentFolderId = folderId, + nativeTemplate = GlobalAppSettings.current.defaultNativeTemplate + ) + appRepository.pageRepository.create(page) + navController.navigate("pages/${page.id}") + } + ) { + Icon( + imageVector = FeatherIcons.FilePlus, + contentDescription = "Add Quick Page", + tint = Color.Gray, + modifier = Modifier.size(40.dp), + ) + } + } + // Render existing pages + if (singlePages?.isNotEmpty() == true) { + items(singlePages!!.reversed()) { page -> + val pageId = page.id + var isPageSelected by remember { mutableStateOf(false) } + Box { + PagePreview( + modifier = Modifier + .combinedClickable( + onClick = { + navController.navigate("pages/$pageId") + }, + onLongClick = { + isPageSelected = true + }, + ) + .width(100.dp) + .aspectRatio(3f / 4f) + .border(1.dp, Color.Black, RectangleShape), + pageId = pageId + ) + if (isPageSelected) PageMenu( + pageId = pageId, + canDelete = true, + onClose = { isPageSelected = false }) + } + } + } + } + Spacer(Modifier.height(10.dp)) + Text(text = "Notebooks") + Spacer(Modifier.height(10.dp)) + + LazyVerticalGrid( + columns = GridCells.Adaptive(100.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { + Box( + modifier = Modifier + .width(100.dp) + .aspectRatio(3f / 4f) + .border(1.dp, Color.Gray, RectangleShape), + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + // Create New Notebook Button (Top Half) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(1f) // Takes half the height + .fillMaxWidth() + .background(Color.LightGray.copy(alpha = 0.3f)) + .noRippleClickable { + appRepository.bookRepository.create( + Notebook( + parentFolderId = folderId, + defaultNativeTemplate = GlobalAppSettings.current.defaultNativeTemplate + ) + ) + } + .border(2.dp, Color.Black, RectangleShape) + ) { + Icon( + imageVector = FeatherIcons.FilePlus, + contentDescription = "Add Quick Page", + tint = Color.Gray, + modifier = Modifier.size(40.dp), + ) + } + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + uri?.let { + CoroutineScope(Dispatchers.IO).launch { + val removeSnack = + snackManager.displaySnack( + SnackConf(text = "importing from xopp file") + ) + importInProgress = true + XoppFile.importBook(context, uri, folderId) + importInProgress = false + removeSnack() + } + } + } + // Import Notebook (Bottom Half) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .background(Color.LightGray.copy(alpha = 0.3f)) + .noRippleClickable { + launcher.launch( + arrayOf( + "application/x-xopp", + "application/gzip", + "application/octet-stream" + ) + ) + } + .border(2.dp, Color.Black, RectangleShape) + + ) { + Icon( + imageVector = FeatherIcons.Upload, + contentDescription = "Import Notebook", + tint = Color.Gray, + modifier = Modifier.size(40.dp), + ) + } + } + } + } + if (books?.isNotEmpty() == true) { + items(books!!.reversed()) { item -> + if (item.pageIds.isEmpty()) { + if (!importInProgress) { + ShowConfirmationDialog( + title = "There is a book without pages!!!", + message = "We suggest deleting book title \"${item.title}\", it was created at ${item.createdAt}. Do you want to do it?", + onConfirm = { + bookRepository.delete(item.id) + }, + onCancel = { } + ) + } + return@items + } + var isSettingsOpen by remember { mutableStateOf(false) } + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(3f / 4f) + .border(1.dp, Color.Black, RectangleShape) + .background(Color.White) + .clip(RoundedCornerShape(2)) + ) { + Box { + val pageId = item.pageIds[0] + + PagePreview( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(3f / 4f) + .border(1.dp, Color.Black, RectangleShape) + .combinedClickable( + onClick = { + val bookId = item.id + val pageId = item.openPageId ?: item.pageIds[0] + navController.navigate("books/$bookId/pages/$pageId") + }, + onLongClick = { + isSettingsOpen = true + }, + ), pageId + ) + } + Text( + text = item.pageIds.size.toString(), + modifier = Modifier + .background(Color.Black) + .padding(5.dp), + color = Color.White + ) + Text( + text = item.title, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.BottomEnd) + .fillMaxWidth() + .padding(bottom = 8.dp) // Add some padding above the row + .background(Color.White) + ) + } + if (isSettingsOpen) NotebookConfigDialog( + bookId = item.id, + onClose = { isSettingsOpen = false }) + } + } + } + } + } + + if (isSettingsOpen) AppSettingsModal(onClose = { isSettingsOpen = false }) + +// Add the FloatingEditorView here + if (showFloatingEditor && floatingEditorPageId != null) { + FloatingEditorView( + navController = navController, + pageId = floatingEditorPageId!!, + onDismissRequest = { + showFloatingEditor = false + floatingEditorPageId = null + } + ) + } +} + + + diff --git a/app/src/main/java/com/olup/notable/views/PagesView.kt b/app/src/main/java/com/ethran/notable/views/PagesView.kt similarity index 70% rename from app/src/main/java/com/olup/notable/views/PagesView.kt rename to app/src/main/java/com/ethran/notable/views/PagesView.kt index 2c39613f..01e70ca9 100644 --- a/app/src/main/java/com/olup/notable/views/PagesView.kt +++ b/app/src/main/java/com/ethran/notable/views/PagesView.kt @@ -1,28 +1,42 @@ -package com.olup.notable +package com.ethran.notable.views import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.navigation.NavController -import com.olup.notable.AppRepository +import com.ethran.notable.classes.AppRepository +import com.ethran.notable.components.PageMenu +import com.ethran.notable.components.PagePreview +import com.ethran.notable.components.Topbar +import com.ethran.notable.utils.noRippleClickable @ExperimentalFoundationApi @Composable fun PagesView(navController: NavController, bookId: String) { val appRepository = AppRepository(LocalContext.current) val book by appRepository.bookRepository.getByIdLive(bookId).observeAsState() - if(book == null) return + if (book == null) return val pageIds = book!!.pageIds val openPageId = book?.openPageId @@ -70,7 +84,12 @@ fun PagesView(navController: NavController, bookId: String) { ), pageId ) - if (selectedPageId == pageId) PageMenu(bookId, pageId, pageIndex, canDelete = pageIds.size > 1) { + if (selectedPageId == pageId) PageMenu( + bookId, + pageId, + pageIndex, + canDelete = pageIds.size > 1 + ) { selectedPageId = null } diff --git a/app/src/main/java/com/olup/notable/views/Router.kt b/app/src/main/java/com/ethran/notable/views/Router.kt similarity index 59% rename from app/src/main/java/com/olup/notable/views/Router.kt rename to app/src/main/java/com/ethran/notable/views/Router.kt index bea48cb2..77ea8246 100644 --- a/app/src/main/java/com/olup/notable/views/Router.kt +++ b/app/src/main/java/com/ethran/notable/views/Router.kt @@ -1,40 +1,49 @@ -package com.olup.notable +package com.ethran.notable.views -import android.widget.Space import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.* -import androidx.compose.runtime.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInteropFilter import androidx.compose.ui.unit.dp import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument -import com.google.accompanist.navigation.animation.AnimatedNavHost -import com.google.accompanist.navigation.animation.composable -import com.google.accompanist.navigation.animation.rememberAnimatedNavController +import com.ethran.notable.classes.DrawCanvas +import com.ethran.notable.components.QuickNav @ExperimentalAnimationApi @ExperimentalFoundationApi @ExperimentalComposeUiApi @Composable fun Router() { - val navController = rememberAnimatedNavController() + val navController = rememberNavController() var isQuickNavOpen by remember { mutableStateOf(false) } - LaunchedEffect(key1 = isQuickNavOpen, block = { + LaunchedEffect(isQuickNavOpen) { DrawCanvas.isDrawing.emit(!isQuickNavOpen) - }) + } - AnimatedNavHost( + NavHost( navController = navController, startDestination = "library?folderId={folderId}", @@ -45,36 +54,41 @@ fun Router() { ) { composable( route = "library?folderId={folderId}", - arguments = listOf(navArgument("folderId") { nullable = true }) + arguments = listOf(navArgument("folderId") { nullable = true }), ) { - /* Using composable function */ - Library(navController = navController, folderId = it.arguments?.getString("folderId")) + Library( + navController = navController, + folderId = it.arguments?.getString("folderId"), + ) } composable( route = "books/{bookId}/pages/{pageId}", - arguments = listOf(navArgument("bookId") { - /* configuring arguments for navigation */ - type = NavType.StringType - }, navArgument("pageId") { - type = NavType.StringType - }) + arguments = listOf( + navArgument("bookId") { + /* configuring arguments for navigation */ + type = NavType.StringType + }, + navArgument("pageId") { + type = NavType.StringType + }, + ), ) { EditorView( navController = navController, _bookId = it.arguments?.getString("bookId")!!, - _pageId = it.arguments?.getString("pageId")!! + _pageId = it.arguments?.getString("pageId")!!, ) } composable( route = "pages/{pageId}", arguments = listOf(navArgument("pageId") { type = NavType.StringType - }) + }), ) { EditorView( navController = navController, _bookId = null, - _pageId = it.arguments?.getString("pageId")!! + _pageId = it.arguments?.getString("pageId")!!, ) } composable( @@ -82,17 +96,19 @@ fun Router() { arguments = listOf(navArgument("bookId") { /* configuring arguments for navigation */ type = NavType.StringType - }) + }), ) { PagesView( navController = navController, - bookId = it.arguments?.getString("bookId")!! + bookId = it.arguments?.getString("bookId")!!, ) } } - if (isQuickNavOpen) QuickNav(navController = navController, { isQuickNavOpen = false }) - else Column( + if (isQuickNavOpen) QuickNav( + navController = navController, + onClose = { isQuickNavOpen = false }, + ) else Column( modifier = Modifier .fillMaxWidth() .fillMaxHeight() @@ -103,17 +119,16 @@ fun Router() { .fillMaxWidth() .height(50.dp) .pointerInteropFilter { - if(it.size == 0f) return@pointerInteropFilter true + if (it.size == 0f) return@pointerInteropFilter true false } .pointerInput(Unit) { - detectTapGestures( onDoubleTap = { isQuickNavOpen = true } ) - }) + } + ) } - -} +} \ No newline at end of file diff --git a/app/src/main/java/com/olup/notable/MainActivity.kt b/app/src/main/java/com/olup/notable/MainActivity.kt deleted file mode 100644 index 34f57d85..00000000 --- a/app/src/main/java/com/olup/notable/MainActivity.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.olup.notable - -import android.content.pm.ActivityInfo -import android.os.Bundle -import io.shipbook.shipbooksdk.Log -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import androidx.lifecycle.lifecycleScope -import com.olup.notable.ui.theme.InkaTheme -import com.onyx.android.sdk.api.device.epd.EpdController -import io.shipbook.shipbooksdk.ShipBook -import kotlinx.coroutines.launch - - -var SCREEN_WIDTH = EpdController.getEpdHeight().toInt() -var SCREEN_HEIGHT = EpdController.getEpdWidth().toInt() - -var TAG = "MainActivity" -@ExperimentalAnimationApi -@ExperimentalComposeUiApi -@ExperimentalFoundationApi -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - ShipBook.start(this.application, "648adf9364c9825976c1d57e", - "7c53dffa949e3b55e37ab04672138feb"); - - Log.i(TAG, "Notable started") - - - if(SCREEN_WIDTH == 0){ - SCREEN_WIDTH = applicationContext.resources.displayMetrics.widthPixels - SCREEN_HEIGHT = applicationContext.resources.displayMetrics.heightPixels - } - - val snackState = SnackState() - // Refactor - we prob don't need this - EditorSettingCacheManager.init(applicationContext) - - - //EpdDeviceManager.enterAnimationUpdate(true); - - - setContent { - InkaTheme { - CompositionLocalProvider(SnackContext provides snackState ) { - Box( - Modifier - .background(Color.White) - ) { - Router() - } - Box( - Modifier - .fillMaxWidth() - .height(1.dp) - .background(Color.Black) - ) - SnackBar(state = snackState) - } - } - } - } - - - override fun onRestart() { - super.onRestart() - // redraw after device sleep - this.lifecycleScope.launch { - DrawCanvas.restartAfterConfChange.emit(Unit) - } - } - - override fun onPause() { - super.onPause() - this.lifecycleScope.launch { - DrawCanvas.refreshUi.emit(Unit) - } - } - - override fun onWindowFocusChanged(hasFocus: Boolean) { - super.onWindowFocusChanged(hasFocus) - this.lifecycleScope.launch { - DrawCanvas.refreshUi.emit(Unit) - } - } - - - - - - override fun onContentChanged() { - super.onContentChanged() - setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT); - } -} \ No newline at end of file diff --git a/app/src/main/java/com/olup/notable/classes/DrawCanvas.kt b/app/src/main/java/com/olup/notable/classes/DrawCanvas.kt deleted file mode 100644 index c61f2ddb..00000000 --- a/app/src/main/java/com/olup/notable/classes/DrawCanvas.kt +++ /dev/null @@ -1,363 +0,0 @@ -package com.olup.notable - -import android.content.Context -import android.graphics.* -import io.shipbook.shipbooksdk.Log -import android.view.SurfaceHolder -import android.view.SurfaceView -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.unit.dp -import com.onyx.android.sdk.api.device.epd.EpdController -import com.onyx.android.sdk.data.note.TouchPoint -import com.onyx.android.sdk.pen.RawInputCallback -import com.onyx.android.sdk.pen.TouchHelper -import com.onyx.android.sdk.pen.data.TouchPointList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.launch -import kotlin.concurrent.thread - - -val pressure = EpdController.getMaxTouchPressure() - -// keep reference of the surface view presently associated to the singleton touchhelper -var referencedSurfaceView: String = "" - - -class DrawCanvas( - val _context: Context, - val coroutineScope: CoroutineScope, - val state: EditorState, - val page: PageView, - val history: History -) : SurfaceView(_context) { - - private val strokeHistoryBatch = mutableListOf() - private val commitHistorySignal = MutableSharedFlow() - - - companion object { - var forceUpdate = MutableSharedFlow() - var refreshUi = MutableSharedFlow() - var isDrawing = MutableSharedFlow() - var restartAfterConfChange = MutableSharedFlow() - } - - fun getActualState(): EditorState { - return this.state - } - - private val inputCallback: RawInputCallback = object : RawInputCallback() { - - override fun onBeginRawDrawing(p0: Boolean, p1: TouchPoint?) { - } - - override fun onEndRawDrawing(p0: Boolean, p1: TouchPoint?) { - } - - override fun onRawDrawingTouchPointMoveReceived(p0: TouchPoint?) { - } - - override fun onRawDrawingTouchPointListReceived(plist: TouchPointList) { - thread(true) { - if (getActualState().mode == Mode.Erase) { - handleErase( - this@DrawCanvas.page, - history, - plist.points.map { SimplePointF(it.x, it.y + page.scroll) }, - eraser = getActualState().eraser - ) - drawCanvasToView() - refreshUi() - } - - if (getActualState().mode == Mode.Draw) { - handleDraw( - this@DrawCanvas.page, - strokeHistoryBatch, - getActualState().penSettings[getActualState().pen.penName]!!.strokeSize, - getActualState().pen, - plist.points - ) - coroutineScope.launch { - commitHistorySignal.emit(Unit) - } - } - - if (getActualState().mode == Mode.Select) { - handleSelect(coroutineScope, - this@DrawCanvas.page, - getActualState(), - plist.points.map { SimplePointF(it.x, it.y + page.scroll) }) - drawCanvasToView() - refreshUi() - } - } - } - - - override fun onBeginRawErasing(p0: Boolean, p1: TouchPoint?) { - } - - override fun onEndRawErasing(p0: Boolean, p1: TouchPoint?) { - } - - override fun onRawErasingTouchPointListReceived(plist: TouchPointList?) { - if (plist == null) return - handleErase( - this@DrawCanvas.page, - history, - plist.points.map { SimplePointF(it.x, it.y + page.scroll) }, - eraser = getActualState().eraser - ) - drawCanvasToView() - refreshUi() - } - - override fun onRawErasingTouchPointMoveReceived(p0: TouchPoint?) { - } - } - - private val touchHelper by lazy { - referencedSurfaceView = this.hashCode().toString() - TouchHelper.create(this, inputCallback) - } - - fun init() { - Log.i(TAG, "Initializing") - - val surfaceView = this - - val surfaceCallback: SurfaceHolder.Callback = object : SurfaceHolder.Callback { - override fun surfaceCreated(holder: SurfaceHolder) { - Log.i(TAG, "surface created ${holder}") - // set up the drawing surface - updateActiveSurface() - // This is supposed to let the ui update while the old surface is being unmounted - coroutineScope.launch { - forceUpdate.emit(null) - } - } - - override fun surfaceChanged( - holder: SurfaceHolder, format: Int, width: Int, height: Int - ) { - Log.i(TAG, "surface changed ${holder}") - drawCanvasToView() - updatePenAndStroke() - refreshUi() - } - - override fun surfaceDestroyed(holder: SurfaceHolder) { - Log.i( - TAG, - "surface destroyed ${ - this@DrawCanvas.hashCode().toString() - } - ref ${referencedSurfaceView}" - ) - holder.removeCallback(this) - if (referencedSurfaceView == this@DrawCanvas.hashCode().toString()) { - touchHelper.closeRawDrawing() - } - } - } - - this.holder.addCallback(surfaceCallback) - - } - - fun registerObservers() { - - // observe forceUpdate - coroutineScope.launch { - forceUpdate.collect { zoneAffected -> - Log.i(TAG, "Force update zone $zoneAffected") - - if (zoneAffected != null) page.drawArea( - area = Rect( - zoneAffected.left, - zoneAffected.top - page.scroll, - zoneAffected.right, - zoneAffected.bottom - page.scroll - ), - ) - - refreshUi() - } - } - - // observe refreshUi - coroutineScope.launch { - refreshUi.collect { - refreshUi() - } - } - coroutineScope.launch { - isDrawing.collect { - state.isDrawing = it - } - } - - - // observe restartcount - coroutineScope.launch { - restartAfterConfChange.collect { - init() - drawCanvasToView() - } - } - - // observe paen and stroke size - coroutineScope.launch { - snapshotFlow { state.pen }.drop(1).collect { - Log.i(TAG, "pen change: ${state.pen}") - updatePenAndStroke() - refreshUi() - } - } - coroutineScope.launch { - snapshotFlow { state.penSettings.toMap() }.drop(1).collect { - Log.i(TAG, "pen settings change: ${state.penSettings}") - updatePenAndStroke() - refreshUi() - } - } - coroutineScope.launch { - snapshotFlow { state.eraser }.drop(1).collect { - Log.i(TAG, "eraser change: ${state.eraser}") - updatePenAndStroke() - refreshUi() - } - } - - // observe is drawing - coroutineScope.launch { - snapshotFlow { state.isDrawing }.drop(1).collect { - Log.i(TAG, "isDrawing change: ${state.isDrawing}") - updateIsDrawing() - } - } - - // observe toolbar open - coroutineScope.launch { - snapshotFlow { state.isToolbarOpen }.drop(1).collect { - Log.i(TAG, "istoolbaropen change: ${state.isToolbarOpen}") - updateActiveSurface() - } - } - - // observe mode - coroutineScope.launch { - snapshotFlow { getActualState().mode }.drop(1).collect { - Log.i(TAG, "mode change: ${getActualState().mode}") - updatePenAndStroke() - refreshUi() - } - } - - coroutineScope.launch { - commitHistorySignal.debounce(500).collect { - Log.i(TAG, "Commiting") - if (strokeHistoryBatch.size > 0) history.addOperationsToHistory( - operations = listOf( - Operation.DeleteStroke(strokeHistoryBatch.map { it }) - ) - ) - strokeHistoryBatch.clear() - } - } - - } - - fun refreshUi() { - Log.i(TAG, "Refreshing ui. isDrawing : ${state.isDrawing}") - drawCanvasToView() - - if (state.isDrawing) { - // reset screen freeze - touchHelper.setRawDrawingEnabled(false) - touchHelper.setRawDrawingEnabled(true) // screen won't freeze until you actually stoke - } - } - - fun drawCanvasToView() { - Log.i(TAG, "Draw canvas") - val canvas = this.holder.lockCanvas() ?: return - canvas.drawBitmap(page.windowedBitmap, 0f, 0f, Paint()); - - if (getActualState().mode == Mode.Select) { - // render selection - if (getActualState().selectionState.firstPageCut != null) { - Log.i(TAG, "rendercut") - - val path = pointsToPath(getActualState().selectionState.firstPageCut!!.map { - SimplePointF( - it.x, it.y - page.scroll - ) - }) - canvas.drawPath(path, selectPaint) - } - } - - // finish rendering - this.holder.unlockCanvasAndPost(canvas) - } - - fun updateIsDrawing() { - Log.i(TAG, "Update is drawing : ${state.isDrawing}") - if (state.isDrawing) { - touchHelper.setRawDrawingEnabled(true) - } else { - drawCanvasToView() - touchHelper.setRawDrawingEnabled(false) - } - } - - fun updatePenAndStroke() { - Log.i(TAG, "Update pen and stroke") - when (state.mode) { - Mode.Draw -> touchHelper.setStrokeStyle(penToStroke(state.pen)) - ?.setStrokeWidth(state.penSettings[state.pen.penName]!!.strokeSize) - ?.setStrokeColor(state.penSettings[state.pen.penName]!!.color) - Mode.Erase -> { - when (state.eraser) { - Eraser.PEN -> touchHelper.setStrokeStyle(penToStroke(Pen.MARKER)) - ?.setStrokeWidth(30f) - ?.setStrokeColor(Color.GRAY) - Eraser.SELECT -> touchHelper.setStrokeStyle(penToStroke(Pen.BALLPEN)) - ?.setStrokeWidth(3f) - ?.setStrokeColor(Color.GRAY) - } - } - Mode.Select -> touchHelper.setStrokeStyle(penToStroke(Pen.BALLPEN))?.setStrokeWidth(3f) - ?.setStrokeColor(Color.GRAY) - } - } - - fun updateActiveSurface() { - Log.i(TAG, "Update editable surface") - - val exclusionHeight = - if (state.isToolbarOpen) convertDpToPixel(40.dp, context).toInt() else 0 - - touchHelper.setRawDrawingEnabled(false) - touchHelper.closeRawDrawing() - - touchHelper.setLimitRect( - mutableListOf( - android.graphics.Rect( - 0, 0, this.width, this.height - ) - ) - ).setExcludeRect(listOf(android.graphics.Rect(0, 0, this.width, exclusionHeight))) - .openRawDrawing() - - touchHelper.setRawDrawingEnabled(true) - updatePenAndStroke() - - refreshUi() - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/olup/notable/classes/EditorControlTower.kt b/app/src/main/java/com/olup/notable/classes/EditorControlTower.kt deleted file mode 100644 index d194ba82..00000000 --- a/app/src/main/java/com/olup/notable/classes/EditorControlTower.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.olup.notable - -import android.graphics.Rect -import androidx.compose.ui.unit.toOffset -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -class EditorControlTower( - val scope: CoroutineScope, val page: PageView, val history: History, val state: EditorState -) { - - fun onSingleFingerVerticalSwipe(startPosition: SimplePointF, delta: Int) { - if (state.mode == Mode.Select) { - if (state.selectionState.firstPageCut != null) { - onOpenPageCut(delta) - } else { - onPageScroll(-delta) - } - } else { - onPageScroll(-delta) - } - - scope.launch { DrawCanvas.refreshUi.emit(Unit) } - - } - - fun onOpenPageCut(offset: Int) { - if (offset < 0) return - var cutLine = state.selectionState.firstPageCut!! - - val (_, previousStrokes) = divideStrokesFromCut(page.strokes, cutLine) - - // calculate new strokes to add to the page - val nextStrokes = previousStrokes.map { - it.copy(points = it.points.map { - it.copy(x = it.x, y = it.y + offset) - }, top = it.top + offset, bottom = it.bottom + offset) - } - - // remove and paste - page.removeStrokes(strokeIds = previousStrokes.map { it.id }) - page.addStrokes(nextStrokes) - - // commit to history - history.addOperationsToHistory( - listOf( - Operation.DeleteStroke(nextStrokes.map { it.id }), - Operation.AddStroke(previousStrokes) - ) - ) - - state.selectionState.reset() - page.drawArea( - pageAreaToCanvasArea( - strokeBounds(previousStrokes + nextStrokes), page.scroll - ) - ) - } - - fun onPageScroll(delta: Int) { - page!!.updateScroll(delta) - } - - fun applySelectionDisplace() { - val selectedStrokes = state.selectionState.selectedStrokes!! - val offset = state.selectionState.selectionDisplaceOffset!! - val finalZone = Rect(state.selectionState.selectionRect!!) - finalZone.offset(offset.x, offset.y) - - val displacedStrokes = selectedStrokes.map { - offsetStroke(it, offset = offset.toOffset()) - } - - if (state.selectionState.placementMode == PlacementMode.Move) page.removeStrokes(selectedStrokes.map{it.id}) - - page.addStrokes(displacedStrokes) - page.drawArea(finalZone) - - - if (offset.x > 0 || offset.y > 0) { - // A displacement happened, we can create a history for this - var operationList = - listOf(Operation.DeleteStroke(displacedStrokes.map { it.id })) - // in case we are on a move operation, this history point re-adds the original strokes - if (state.selectionState.placementMode == PlacementMode.Move) operationList += Operation.AddStroke( - selectedStrokes - ) - history.addOperationsToHistory(operationList) - } - - - scope.launch { - DrawCanvas.refreshUi.emit(Unit) - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/olup/notable/classes/PageView.kt b/app/src/main/java/com/olup/notable/classes/PageView.kt deleted file mode 100644 index 76b9bda0..00000000 --- a/app/src/main/java/com/olup/notable/classes/PageView.kt +++ /dev/null @@ -1,267 +0,0 @@ -package com.olup.notable - -import android.content.Context -import android.graphics.* -import io.shipbook.shipbooksdk.Log -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.unit.IntOffset -import androidx.core.graphics.toRect -import com.olup.notable.db.AppDatabase -import com.olup.notable.db.Page -import com.olup.notable.db.Stroke -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.launch -import java.io.BufferedOutputStream -import java.io.File -import java.io.FileOutputStream -import java.nio.file.Files -import kotlin.io.path.Path -import kotlin.math.max -import kotlin.system.measureTimeMillis - -class PageView( - val context: Context, - val coroutineScope: CoroutineScope, - val id: String, - val width: Int, - val viewWidth: Int, - val viewHeight: Int -) { - - val windowedBitmap = Bitmap.createBitmap(viewWidth, viewHeight, Bitmap.Config.ARGB_8888) - val windowedCanvas = Canvas(windowedBitmap) - var strokes = listOf() - var strokesById: HashMap = hashMapOf() - var scroll by mutableStateOf(0) // is observed by ui - val saveTopic = MutableSharedFlow() - - var height by mutableStateOf(viewHeight) // is observed by ui - - var pageFromDb = AppRepository(context).pageRepository.getById(id) - - var db = AppDatabase.getDatabase(context)?.strokeDao()!! - - init { - coroutineScope.launch { - saveTopic.debounce(1000).collect { - launch { persistBitmap() } - launch { persistBitmapThumbnail() } - } - } - - windowedCanvas.drawColor(Color.WHITE) - drawBg(windowedCanvas, pageFromDb?.nativeTemplate!!, scroll) - - val isCached = loadBitmap() - initFromPersistLayer(isCached) - } - - fun indexStrokes() { - coroutineScope.launch { - strokesById = hashMapOf(*strokes.map { s -> s.id to s }.toTypedArray()) - } - } - - private fun initFromPersistLayer(isCached: Boolean) { - // pageInfos - // TODO page might not exists yet - val page = AppRepository(context).pageRepository.getById(id) - scroll = page!!.scroll - - coroutineScope.launch { - val pageWithStrokes = AppRepository(context).pageRepository.getWithStrokeById(id) - strokes = pageWithStrokes.strokes - indexStrokes() - computeHeight() - - if (!isCached) { - // we draw and cache - drawBg(windowedCanvas, page.nativeTemplate, scroll) - drawArea(Rect(0, 0, windowedCanvas.width, windowedCanvas.height)) - persistBitmap() - persistBitmapThumbnail() - } - } - } - - fun addStrokes(strokesToAdd: List) { - strokes += strokesToAdd - strokesToAdd.forEach { - val bottomPlusPadding = it.bottom + 50 - if (bottomPlusPadding > height) height = bottomPlusPadding.toInt() - } - - saveStrokesToPersistLayer(strokesToAdd) - indexStrokes() - - persistBitmapDebounced() - } - - fun removeStrokes(strokeIds: List) { - strokes = strokes.filter { s -> !strokeIds.contains(s.id) } - removeStrokesFromPersistLayer(strokeIds) - indexStrokes() - computeHeight() - - persistBitmapDebounced() - } - - fun getStrokes(strokeIds: List): List { - return strokeIds.map { s -> strokesById[s] } - } - - private fun saveStrokesToPersistLayer(strokes: List) { - db.create(strokes) - } - - fun computeHeight() { - if (strokes.isEmpty()) { - height = viewHeight - return - } - val maxStrokeBottom = strokes.maxOf { it.bottom }.plus(50) ?: 0 - height = max(maxStrokeBottom.toInt(), viewHeight) - } - - fun computeWidth(): Int { - if (strokes.isEmpty()) { - return viewWidth - } - val maxStrokeRight = strokes.maxOf { it.right }.plus(50) ?: 0 - return max(maxStrokeRight.toInt(), viewWidth) - } - - private fun removeStrokesFromPersistLayer(strokeIds: List) { - AppRepository(context).strokeRepository.deleteAll(strokeIds) - } - - private fun loadBitmap(): Boolean { - val imgFile = File(context.filesDir, "pages/previews/full/$id") - var imgBitmap: Bitmap? = null - if (imgFile.exists()) { - imgBitmap = BitmapFactory.decodeFile(imgFile.absolutePath) - if (imgBitmap != null) { - windowedCanvas.drawBitmap(imgBitmap, 0f, 0f, Paint()); - Log.i(TAG, "Page rendered from cache") - // let's control that the last preview fits the present orientation. Otherwise we'll ask for a redraw. - if(imgBitmap.height == windowedCanvas.height && imgBitmap.width == windowedCanvas.width){ - return true - } else { - Log.i(TAG, "Image preview does not fit canvas area - redrawing") - } - } else { - Log.i(TAG, "Cannot read cache image") - } - } else { - Log.i(TAG, "Cannot find cache image") - } - return false - } - - private fun persistBitmap() { - val file = File(context.filesDir, "pages/previews/full/$id") - Files.createDirectories(Path(file.absolutePath).parent) - val os = BufferedOutputStream(FileOutputStream(file)) - windowedBitmap.compress(Bitmap.CompressFormat.PNG, 100, os); - os.close() - } - - private fun persistBitmapThumbnail() { - val file = File(context.filesDir, "pages/previews/thumbs/$id") - Files.createDirectories(Path(file.absolutePath).parent) - val os = BufferedOutputStream(FileOutputStream(file)) - val ratio = windowedBitmap.height.toFloat() / windowedBitmap.width.toFloat() - Bitmap.createScaledBitmap(windowedBitmap, 500, (500 * ratio).toInt(), false) - .compress(Bitmap.CompressFormat.JPEG, 80, os); - os.close() - } - - fun drawArea(area: Rect, ignoredStrokeIds: List = listOf(), canvas: Canvas? = null) { - val activeCanvas = canvas ?: windowedCanvas - val pageArea = Rect( - area.left, - area.top + scroll, - area.right, - area.bottom + scroll - ) - - activeCanvas.save(); - activeCanvas.clipRect(area); - activeCanvas.drawColor(Color.BLACK) - - val timeToBg = measureTimeMillis { - drawBg(activeCanvas, pageFromDb?.nativeTemplate ?: "blank", scroll) - } - Log.i(TAG, "Took $timeToBg to draw the BG") - - val timeToDraw = measureTimeMillis { - strokes.forEach { stroke -> - if (ignoredStrokeIds.contains(stroke.id)) return@forEach - val bounds = strokeBounds(stroke) - // if stroke is not inside page section - if (!bounds.toRect().intersect(pageArea)) return@forEach - - drawStroke( - activeCanvas, stroke, IntOffset(0, -scroll) - ) - - } - } - Log.i(TAG, "Drew area in ${timeToDraw}ms") - activeCanvas.restore(); - } - - fun updateScroll(_delta: Int) { - var delta = _delta - if (scroll + delta < 0) delta = 0 - scroll - - scroll += delta - - // scroll bitmap - val tmp = windowedBitmap.copy(windowedBitmap.config, false) - drawBg(windowedCanvas, pageFromDb?.nativeTemplate ?: "blank", scroll) - - windowedCanvas.drawBitmap(tmp, 0f, -delta.toFloat(), Paint()) - tmp.recycle() - - // where is the new rendering area starting ? - val canvasOffset = if (delta > 0) windowedCanvas.height - delta else 0 - - drawArea( - area = Rect( - 0, - canvasOffset, - windowedCanvas.width, - canvasOffset + Math.abs(delta) - ), - ) - - persistBitmapDebounced() - saveToPersistLayer() - } - - fun updatePageSettings(page: Page) { - AppRepository(context).pageRepository.update(page) - pageFromDb = AppRepository(context).pageRepository.getById(id) - drawArea(Rect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)) - persistBitmapDebounced() - } - - private fun persistBitmapDebounced() { - coroutineScope.launch { - saveTopic.emit(Unit) - } - } - - private fun saveToPersistLayer() { - coroutineScope.launch { - AppRepository(context).pageRepository.updateScroll(id, scroll) - pageFromDb = AppRepository(context).pageRepository.getById(id) - } - } -} - diff --git a/app/src/main/java/com/olup/notable/components/BreadCrumb.kt b/app/src/main/java/com/olup/notable/components/BreadCrumb.kt deleted file mode 100644 index 0a2db724..00000000 --- a/app/src/main/java/com/olup/notable/components/BreadCrumb.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.olup.notable - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.material.Icon -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.style.TextDecoration -import com.olup.notable.db.Folder -import com.olup.notable.db.FolderRepository -import compose.icons.FeatherIcons -import compose.icons.feathericons.ChevronRight - -@Composable -fun BreadCrumb(folderId : String? = null, onSelectFolderId:(String?)->Unit){ - val context = LocalContext.current - - fun getFolderList (folderId : String) : List{ - var folderList = listOf(FolderRepository(context).get(folderId)) - val parentId = folderList.first().parentFolderId - if (parentId != null) { - folderList += getFolderList(parentId) - } - return folderList - } - - Row() { - Text(text = "Library", textDecoration = TextDecoration.Underline,modifier = Modifier.noRippleClickable { onSelectFolderId(null) }) - if (folderId != null) { - val folders = getFolderList(folderId).reversed() - - folders.map{ f-> - Icon(imageVector = FeatherIcons.ChevronRight, contentDescription = "") - Text(text = f.title, textDecoration = TextDecoration.Underline, modifier = Modifier.noRippleClickable { onSelectFolderId(f.id) }) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/olup/notable/components/EditorGestureReceiver.kt b/app/src/main/java/com/olup/notable/components/EditorGestureReceiver.kt deleted file mode 100644 index f45a044d..00000000 --- a/app/src/main/java/com/olup/notable/components/EditorGestureReceiver.kt +++ /dev/null @@ -1,145 +0,0 @@ -package com.olup.notable - -import io.shipbook.shipbooksdk.Log -import androidx.compose.foundation.gestures.* -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.PointerType -import androidx.compose.ui.input.pointer.pointerInput -import com.olup.notable.EditorControlTower -import kotlinx.coroutines.launch - - -@Composable -@ExperimentalComposeUiApi -fun EditorGestureReceiver( - goToNextPage: () -> Unit, - goToPreviousPage: () -> Unit, - controlTower: EditorControlTower, - state: EditorState -) { - - val coroutineScope = rememberCoroutineScope() - Box( - modifier = Modifier - .pointerInput(Unit) { - awaitEachGesture { - - val down = awaitFirstDown() - val inputId = down.id - - val initialPosition = down.position - val initialTimestamp = System.currentTimeMillis(); - - var lastPosition = initialPosition - var lastTimestamp = initialTimestamp - - var inputsCount = 0 - - // ignore non-touch - if (down.type != PointerType.Touch) return@awaitEachGesture - - - do { - val event = awaitPointerEvent() - val fingerChange = event.changes.filter { it.type == PointerType.Touch } - // is already consumed return - if (fingerChange.find { it.isConsumed } != null) { - return@awaitEachGesture - Log.i(TAG, "Canceling gesture - already consumemd") - } - fingerChange.forEach { it.consume() } - - val eventReference = - fingerChange.find { it.id.value == inputId.value } ?: break - - lastPosition = eventReference.position - lastTimestamp = System.currentTimeMillis(); - inputsCount = fingerChange.size - - if (fingerChange.any { !it.pressed }) break - } while (true) - - Log.i(TAG, "leaving gesture") - - val totalDelta = (initialPosition - lastPosition).getDistance() - val gestureDuration = lastTimestamp - initialTimestamp - - if (totalDelta == 0f && gestureDuration < 150) { - // in case of double tap - if (withTimeoutOrNull(100) { - awaitFirstDown() - if (inputsCount == 1) { - state.isToolbarOpen = !state.isToolbarOpen - } - } != null) return@awaitEachGesture - - // in case of single tap - if (inputsCount == 2) { - state.mode = if (state.mode == Mode.Draw) Mode.Erase else Mode.Draw - } - return@awaitEachGesture - - } - - val verticalDrag = lastPosition.y - initialPosition.y - val horinzontalDrag = lastPosition.x - initialPosition.x - - - if (verticalDrag < -200) { - if (inputsCount == 1) { - coroutineScope.launch { - controlTower.onSingleFingerVerticalSwipe( - SimplePointF( - initialPosition.x, initialPosition.y - ), verticalDrag.toInt() - ) - } - } - } - if (verticalDrag > 200) { - if (inputsCount == 1) { - coroutineScope.launch { - controlTower.onSingleFingerVerticalSwipe( - SimplePointF( - initialPosition.x, initialPosition.y - ), verticalDrag.toInt() - ) - } - } - } - if (horinzontalDrag < -200) { - if (inputsCount == 1) { - goToNextPage() - } else if (inputsCount == 2) { - Log.i(TAG, "Redo") - coroutineScope.launch { - History.moveHistory(UndoRedoType.Redo) - DrawCanvas.refreshUi.emit(Unit) - } - } - } - if (horinzontalDrag > 200) { - if (inputsCount == 1) { - goToPreviousPage() - } else if (inputsCount == 2) { - Log.i(TAG, "Undo") - coroutineScope.launch { - History.moveHistory(UndoRedoType.Undo) - DrawCanvas.refreshUi.emit(Unit) - } - } - - } - - } - } - .fillMaxWidth() - .fillMaxHeight() - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/olup/notable/components/PenToolbarButton.kt b/app/src/main/java/com/olup/notable/components/PenToolbarButton.kt deleted file mode 100644 index 1547d0e4..00000000 --- a/app/src/main/java/com/olup/notable/components/PenToolbarButton.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.olup.notable - -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.* - -@Composable -fun PenToolbarButton( - pen: Pen, - icon: Int, - isSelected: Boolean, - onSelect: () -> Unit, - sizes: List>, - penSetting: PenSetting, - onChangeSetting: (PenSetting) -> Unit, - onStrokeMenuOpenChange: ((Boolean) -> Unit)? = null -) { - var isStrokeMenuOpen by remember { mutableStateOf(false) } - - if(onStrokeMenuOpenChange != null){ - LaunchedEffect(isStrokeMenuOpen) { - onStrokeMenuOpenChange(isStrokeMenuOpen) - } - } - - - Box { - - ToolbarButton( - isSelected = isSelected, - onSelect = { - if (isSelected) isStrokeMenuOpen = !isStrokeMenuOpen - else onSelect() - }, - iconId = icon, - contentDescription = pen.penName - ) - - if (isStrokeMenuOpen) { - StrokeMenu(value = penSetting, onChange = { onChangeSetting(it) }, onClose = {isStrokeMenuOpen = false}, options = sizes) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/olup/notable/components/SelectorBitmap.kt b/app/src/main/java/com/olup/notable/components/SelectorBitmap.kt deleted file mode 100644 index 8f2b6f12..00000000 --- a/app/src/main/java/com/olup/notable/components/SelectorBitmap.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.olup.notable - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.runtime.* -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.PathEffect -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.round -import com.olup.notable.EditorControlTower -import java.util.Date -import java.util.UUID - -val strokeStyle = androidx.compose.ui.graphics.drawscope.Stroke( - width = 2f, - pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f) -) - -@Composable -@ExperimentalComposeUiApi -@ExperimentalFoundationApi -fun SelectedBitmap( - editorState: EditorState, - controlTower: EditorControlTower -) { - val selectionState = editorState.selectionState - if (selectionState.selectedBitmap == null) return - - Box( - Modifier - .fillMaxSize() - .noRippleClickable { - controlTower.applySelectionDisplace() - selectionState.reset() - editorState.isDrawing = true - }) { - Image( - bitmap = selectionState.selectedBitmap!!.asImageBitmap(), - contentDescription = "Selection bitmap", - modifier = Modifier - .offset { - if (selectionState.selectionStartOffset == null) return@offset IntOffset( - 0, - 0 - ) // guard - selectionState.selectionStartOffset!! + selectionState.selectionDisplaceOffset!! - } - .drawBehind { - drawRect( - color = Color.Gray, - topLeft = Offset(0f, 0f), - size = size, - style = strokeStyle - ) - } - .pointerInput(Unit) { - detectDragGestures { change, dragAmount -> - change.consume() - selectionState.selectionDisplaceOffset = - selectionState.selectionDisplaceOffset!! + dragAmount.round() - } - } - .combinedClickable( - indication = null, interactionSource = remember { MutableInteractionSource() }, - onClick = {}, - onDoubleClick = { - // finish ongoind movement - controlTower.applySelectionDisplace() - // set operation to paste only - selectionState.placementMode = PlacementMode.Paste - // change the selected stokes' ids - it's a copy - selectionState.selectedStrokes = selectionState.selectedStrokes!!.map { - it.copy( - id = UUID - .randomUUID() - .toString(), - createdAt = Date() - ) - } - // move the selection a bit, to show the copy - selectionState.selectionDisplaceOffset = IntOffset( - x = selectionState.selectionDisplaceOffset!!.x + 50, - y = selectionState.selectionDisplaceOffset!!.y + 50, - ) - } - ) - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/olup/notable/components/StrokeMenu.kt b/app/src/main/java/com/olup/notable/components/StrokeMenu.kt deleted file mode 100644 index 9901e373..00000000 --- a/app/src/main/java/com/olup/notable/components/StrokeMenu.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.olup.notable - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties - -@Composable -fun StrokeMenu( - value: PenSetting, - onChange: (setting: PenSetting) -> Unit, - onClose: () -> Unit, - options: List> -) { - val context = LocalContext.current - - Popup( - offset = IntOffset(0, convertDpToPixel(43.dp, context).toInt()), onDismissRequest = { - onClose() - }, properties = PopupProperties(focusable = true), alignment = Alignment.TopCenter - ) { - Row( - Modifier - .background(Color.White) - .border(1.dp, Color.Black) - .height(IntrinsicSize.Max) - ) { - options.map { - ToolbarButton( - text = it.first, - isSelected = value.strokeSize == it.second, - onSelect = { onChange(PenSetting(strokeSize = it.second, color = value.color)) }, - modifier = Modifier - ) - } - } - - } -} \ No newline at end of file diff --git a/app/src/main/java/com/olup/notable/components/Toolbar.kt b/app/src/main/java/com/olup/notable/components/Toolbar.kt deleted file mode 100644 index c1767011..00000000 --- a/app/src/main/java/com/olup/notable/components/Toolbar.kt +++ /dev/null @@ -1,289 +0,0 @@ -package com.olup.notable - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.material.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -fun PresentlyUsedToolIcon(mode: Mode, pen: Pen): Int { - return when (mode) { - Mode.Draw -> { - when (pen) { - Pen.BALLPEN -> R.drawable.ballpen - Pen.FOUNTAIN -> R.drawable.fountain - Pen.BRUSH -> R.drawable.brush - Pen.MARKER -> R.drawable.marker - Pen.PENCIL -> R.drawable.pencil - } - } - Mode.Erase -> R.drawable.eraser - Mode.Select -> R.drawable.lasso - } -} - -@Composable -@ExperimentalComposeUiApi -fun Toolbar( - navController: NavController, state: EditorState -) { - val scope = rememberCoroutineScope() - var isStrokeSelectionOpen by remember { mutableStateOf(false) } - var isMenuOpen by remember { mutableStateOf(false) } - var isPageSettingsModalOpen by remember { mutableStateOf(false) } - - val context = LocalContext.current - - LaunchedEffect(isMenuOpen) { - state.isDrawing = !isMenuOpen - } - - fun handleChangePen(pen: Pen) { - if (state.mode == Mode.Draw && state.pen == pen) { - isStrokeSelectionOpen = true - } else { - state.mode = Mode.Draw - state.pen = pen - } - - } - - fun handleEraser() { - state.mode = Mode.Erase - - } - - fun handleSelection() { - state.mode = Mode.Select - } - - fun onChangeStrokeSetting(penName: String, setting: PenSetting) { - val settings = state.penSettings.toMutableMap() - settings[penName] = setting.copy() - state.penSettings = settings - } - - - if (isPageSettingsModalOpen) { - PageSettingsModal(pageView = state.pageView) { - isPageSettingsModalOpen = false - } - } - if (state.isToolbarOpen) { - Column( - modifier = Modifier.fillMaxWidth() - ) { - Row( - Modifier - .background(Color.White) - .height(37.dp) - .fillMaxWidth() - - ) { - ToolbarButton( - onSelect = { - state.isToolbarOpen = !state.isToolbarOpen - }, iconId = R.drawable.topbar_open, contentDescription = "close toolbar" - ) - Box( - Modifier - .fillMaxHeight() - .width(0.5.dp) - .background(Color.Black) - ) - - PenToolbarButton(onStrokeMenuOpenChange = { state.isDrawing = !it }, - pen = Pen.BALLPEN, - icon = R.drawable.ballpen, - isSelected = state.mode == Mode.Draw && state.pen == Pen.BALLPEN, - onSelect = { handleChangePen(Pen.BALLPEN) }, - sizes = listOf("S" to 3f, "M" to 5f, "L" to 10f, "XL" to 20f), - penSetting = state.penSettings[Pen.BALLPEN.penName] ?: return, - onChangeSetting = { onChangeStrokeSetting(Pen.BALLPEN.penName, it) }) - - PenToolbarButton(onStrokeMenuOpenChange = { state.isDrawing = !it }, - pen = Pen.PENCIL, - icon = R.drawable.pencil, - isSelected = state.mode == Mode.Draw && state.pen == Pen.PENCIL, - onSelect = { handleChangePen(Pen.PENCIL) }, - sizes = listOf("S" to 3f, "M" to 5f, "L" to 10f, "XL" to 20f), - penSetting = state.penSettings[Pen.PENCIL.penName] ?: return, - onChangeSetting = { onChangeStrokeSetting(Pen.PENCIL.penName, it) }) - - PenToolbarButton(onStrokeMenuOpenChange = { state.isDrawing = !it }, - pen = Pen.BRUSH, - icon = R.drawable.brush, - isSelected = state.mode == Mode.Draw && state.pen == Pen.BRUSH, - onSelect = { handleChangePen(Pen.BRUSH) }, - sizes = listOf("S" to 3f, "M" to 5f, "L" to 10f, "XL" to 20f), - penSetting = state.penSettings[Pen.BRUSH.penName] ?: return, - onChangeSetting = { onChangeStrokeSetting(Pen.BRUSH.penName, it) }) - - PenToolbarButton(onStrokeMenuOpenChange = { state.isDrawing = !it }, - pen = Pen.FOUNTAIN, - icon = R.drawable.fountain, - isSelected = state.mode == Mode.Draw && state.pen == Pen.FOUNTAIN, - onSelect = { handleChangePen(Pen.FOUNTAIN) }, - sizes = listOf("S" to 3f, "M" to 5f, "L" to 10f, "XL" to 20f), - penSetting = state.penSettings[Pen.FOUNTAIN.penName] ?: return, - onChangeSetting = { onChangeStrokeSetting(Pen.FOUNTAIN.penName, it) }) - - Box( - Modifier - .fillMaxHeight() - .width(0.5.dp) - .background(Color.Black) - ) - - PenToolbarButton(onStrokeMenuOpenChange = { state.isDrawing = !it }, - pen = Pen.MARKER, - icon = R.drawable.marker, - isSelected = state.mode == Mode.Draw && state.pen == Pen.MARKER, - onSelect = { handleChangePen(Pen.MARKER) }, - sizes = listOf("L" to 40f, "XL" to 60f), - penSetting = state.penSettings[Pen.MARKER.penName] ?: return, - onChangeSetting = { - onChangeStrokeSetting( - Pen.MARKER.penName, - it.copy(it.strokeSize, android.graphics.Color.LTGRAY) - ) - }) - - Box( - Modifier - .fillMaxHeight() - .width(0.5.dp) - .background(Color.Black) - ) - EraserToolbarButton(isSelected = state.mode == Mode.Erase, onSelect = { - handleEraser() - }, onMenuOpenChange = { isStrokeSelectionOpen = it }, value = state.eraser, onChange = {state.eraser = it}) - Box( - Modifier - .fillMaxHeight() - .width(0.5.dp) - .background(Color.Black) - ) - ToolbarButton( - isSelected = state.mode == Mode.Select, - onSelect = { handleSelection() }, - iconId = R.drawable.lasso, - contentDescription = "lasso" - ) - Box( - Modifier - .fillMaxHeight() - .width(0.5.dp) - .background(Color.Black) - ) - - Spacer(Modifier.weight(1f)) - - Box( - Modifier - .fillMaxHeight() - .width(0.5.dp) - .background(Color.Black) - ) - - ToolbarButton( - onSelect = { - scope.launch { - History.moveHistory(UndoRedoType.Undo) - DrawCanvas.refreshUi.emit(Unit) - } - }, - iconId = R.drawable.undo, - contentDescription = "undo" - ) - - ToolbarButton( - onSelect = { - scope.launch { - History.moveHistory(UndoRedoType.Redo) - DrawCanvas.refreshUi.emit(Unit) - } - }, - iconId = R.drawable.redo, - contentDescription = "redo" - ) - - Box( - Modifier - .fillMaxHeight() - .width(0.5.dp) - .background(Color.Black) - ) - - if (state.bookId != null) { - val book = AppRepository(context).bookRepository.getById(state.bookId) - - // TODO maybe have generic utils for this ? - val pageNumber = book!!.pageIds.indexOf(state.pageId) + 1 - val totalPageNumber = book!!.pageIds.size - - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .height(35.dp) - .padding(10.dp, 0.dp) - ) { - Text( - text = "${pageNumber}/${totalPageNumber}", - fontWeight = FontWeight.Light, - modifier = Modifier.noRippleClickable { - navController.navigate("books/${state.bookId}/pages") - }, - textAlign = TextAlign.Center - ) - } - Box( - Modifier - .fillMaxHeight() - .width(0.5.dp) - .background(Color.Black) - ) - } - Column { - ToolbarButton( - onSelect = { - isMenuOpen = !isMenuOpen - }, iconId = R.drawable.menu, contentDescription = "menu" - ) - if (isMenuOpen) ToolbarMenu(navController = navController, - state = state, - onClose = { isMenuOpen = false }, - onPageSettingsOpen = { isPageSettingsModalOpen = true }) - } - } - - Box( - Modifier - .fillMaxWidth() - .height(1.dp) - .background(Color.Black) - ) - - - } - } else { - ToolbarButton( - onSelect = { state.isToolbarOpen = true }, - iconId = PresentlyUsedToolIcon(state.mode, state.pen), - contentDescription = "open toolbar", - modifier = Modifier.height(37.dp) - ) - - } -} diff --git a/app/src/main/java/com/olup/notable/components/ToolbarMenu.kt b/app/src/main/java/com/olup/notable/components/ToolbarMenu.kt deleted file mode 100644 index 2a5ae09a..00000000 --- a/app/src/main/java/com/olup/notable/components/ToolbarMenu.kt +++ /dev/null @@ -1,155 +0,0 @@ -package com.olup.notable - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.* -import androidx.compose.material.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties -import androidx.navigation.NavController -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -@Composable -fun ToolbarMenu( - navController: NavController, - state: EditorState, - onClose: () -> Unit, - onPageSettingsOpen: () -> Unit -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val snackManager = SnackContext.current - val page = AppRepository(context).pageRepository.getById(state.pageId)!! - val parentFolder = - if (page.notebookId != null) - AppRepository(context).bookRepository.getById(page.notebookId!!)!! - .parentFolderId - else page.parentFolderId - - Popup( - alignment = Alignment.TopEnd, - onDismissRequest = { onClose() }, - offset = - IntOffset( - convertDpToPixel(-10.dp, context).toInt(), - convertDpToPixel(50.dp, context).toInt() - ), - properties = PopupProperties(focusable = true) - ) { - Column( - Modifier - .border(1.dp, Color.Black, RectangleShape) - .background(Color.White) - .width(IntrinsicSize.Max) - ) { - Box( - Modifier - .fillMaxWidth() - .padding(10.dp) - .noRippleClickable { - navController.navigate( - route = - if (parentFolder != null) "library?folderId=${parentFolder}" - else "library" - ) - } - ) { Text("Library") } - Box( - Modifier - .padding(10.dp) - .noRippleClickable { - scope.launch { - val removeSnack = - snackManager.displaySnack( - SnackConf(text = "Exporting the page to PDF...") - ) - delay(10L) // Why do I need this ? - - exportPage(context, state.pageId) - - removeSnack() - snackManager.displaySnack( - SnackConf(text = "Page exported successfully", duration = 2000) - ) - onClose() - } - } - ) { Text("Export page") } - if (state.bookId != null) - Box( - Modifier - .padding(10.dp) - .noRippleClickable { - scope.launch { - val removeSnack = - snackManager.displaySnack( - SnackConf( - text = "Exporting the book to PDF...", - id = "exportSnack" - ) - ) - delay(10L) // Why do I need this ? - - exportBook(context, state.bookId ?: return@launch) - - removeSnack() - snackManager.displaySnack( - SnackConf( - text = "Book exported successfully", - duration = 3000 - ) - ) - onClose() - } - } - ) { Text("Export book") } - if (state.selectionState.selectedBitmap != null) { - Box( - Modifier - .fillMaxWidth() - .height(0.5.dp) - .background(Color.Black)) - Box( - Modifier - .padding(10.dp) - .noRippleClickable { - shareBitmap(context, state.selectionState.selectedBitmap!!) - } - ) { Text("Share selection") } - } - - Box( - Modifier - .fillMaxWidth() - .height(0.5.dp) - .background(Color.Black)) - Box( - Modifier - .padding(10.dp) - .noRippleClickable { - onPageSettingsOpen() - onClose() - } - ) { Text("Page Settings") } - - /*Box( - Modifier - .fillMaxWidth() - .height(0.5.dp) - .background(Color.Black) - ) - Box(Modifier.padding(10.dp)) { - Text("Refresh page") - }*/ - } - } -} diff --git a/app/src/main/java/com/olup/notable/modals/AppSettings.kt b/app/src/main/java/com/olup/notable/modals/AppSettings.kt deleted file mode 100644 index e975fb76..00000000 --- a/app/src/main/java/com/olup/notable/modals/AppSettings.kt +++ /dev/null @@ -1,124 +0,0 @@ -package com.olup.notable - -import android.content.Intent -import android.net.Uri -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.* -import androidx.compose.material.Text -import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import com.olup.notable.components.SelectMenu -import com.olup.notable.db.KvProxy -import kotlin.concurrent.thread - -@kotlinx.serialization.Serializable -data class AppSettings( - val version: Int, - val defaultNativeTemplate: String = "blank", - val quickNavPages: List = listOf() -) - -@Composable -fun AppSettingsModal(onClose: () -> Unit) { - val context = LocalContext.current - val kv = KvProxy(context) - - var isLatestVersion by remember { mutableStateOf(true) } - LaunchedEffect(key1 = Unit, block = { thread { isLatestVersion = isLatestVersion(context) } }) - - val settings by - kv.observeKv("APP_SETTINGS", AppSettings.serializer(), AppSettings(version = 1)) - .observeAsState() - - if (settings == null) return - - Dialog( - onDismissRequest = { onClose() }, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Column( - modifier = - Modifier.padding(40.dp) - .background(Color.White) - .fillMaxWidth() - .border(2.dp, Color.Black, RectangleShape) - ) { - Column(Modifier.padding(20.dp, 10.dp)) { - Text( - text = - "App setting - v${BuildConfig.VERSION_NAME}${if(isNext) " [NEXT]" else ""}" - ) - } - Box(Modifier.height(0.5.dp).fillMaxWidth().background(Color.Black)) - - Column(Modifier.padding(20.dp, 10.dp)) { - Row() { - Text(text = "Default Page Background Template") - Spacer(Modifier.width(10.dp)) - SelectMenu( - options = - listOf( - "blank" to "Blank page", - "dotted" to "Dot grid", - "lined" to "Lines", - "squared" to "Small squares grid" - ), - onChange = { - kv.setKv( - "APP_SETTINGS", - settings!!.copy(defaultNativeTemplate = it), - AppSettings.serializer() - ) - }, - value = settings?.defaultNativeTemplate ?: "blank" - ) - } - Spacer(Modifier.height(10.dp)) - - if (!isLatestVersion) { - Text( - text = "It seems a new version of Notable is available on github.", - fontStyle = FontStyle.Italic - ) - - Spacer(Modifier.height(10.dp)) - Text( - text = "See release in browser", - textDecoration = TextDecoration.Underline, - modifier = - Modifier.noRippleClickable { - val urlIntent = - Intent( - Intent.ACTION_VIEW, - Uri.parse( - "https://github.com/olup/notable/releases" - ) - ) - context.startActivity(urlIntent) - } - ) - Spacer(Modifier.height(10.dp)) - } else { - Text( - text = "Check for newer version", - textDecoration = TextDecoration.Underline, - modifier = - Modifier.noRippleClickable { - thread { isLatestVersion = isLatestVersion(context, true) } - } - ) - } - } - } - } -} diff --git a/app/src/main/java/com/olup/notable/modals/NotebookConfig.kt b/app/src/main/java/com/olup/notable/modals/NotebookConfig.kt deleted file mode 100644 index d121d7fc..00000000 --- a/app/src/main/java/com/olup/notable/modals/NotebookConfig.kt +++ /dev/null @@ -1,165 +0,0 @@ -package com.olup.notable - -import io.shipbook.shipbooksdk.Log -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.Text -import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.input.key.* -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.navigation.NavController -import com.olup.notable.components.SelectMenu -import com.olup.notable.db.BookRepository -import kotlinx.coroutines.launch - - -@ExperimentalComposeUiApi -@Composable -fun NotebookConfigDialog(bookId: String, onClose : ()->Unit) { - val bookRepository = BookRepository(LocalContext.current) - val book by bookRepository.getByIdLive(bookId).observeAsState() - val context = LocalContext.current - - if(book == null) return - - var bookTitle by remember { - mutableStateOf(book!!.title) - } - - - Dialog( - onDismissRequest = { - onClose() - } - ) { - val focusManager = LocalFocusManager.current - - Column( - modifier = Modifier - .background(Color.White) - .fillMaxWidth() - .border(2.dp, Color.Black, RectangleShape) - ) { - Column( - Modifier.padding(20.dp, 10.dp) - ) { - Text(text = "Notebook Setting", fontWeight = FontWeight.Bold) - } - Box( - Modifier - .height(1.dp) - .fillMaxWidth() - .background(Color.Black) - ) - - Column( - Modifier.padding(20.dp, 10.dp) - ) { - - Row() { - Text( - text = "Notebook Title", - fontWeight = FontWeight.Bold - ) - Spacer(Modifier.width(10.dp)) - BasicTextField( - value = bookTitle, - textStyle = TextStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Light, - fontSize = 16.sp - ), - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = androidx.compose.ui.text.input.ImeAction.Done - ), - onValueChange = { bookTitle = it }, - keyboardActions = KeyboardActions(onDone = { - focusManager.clearFocus() - }), - modifier = Modifier - .background(Color(230, 230, 230, 255)) - .padding(10.dp, 0.dp) - .onFocusChanged { focusState -> - if (!focusState.isFocused) { - Log.i(TAG, "loose focus") - val updatedBook = book!!.copy(title = bookTitle) - bookRepository.update(updatedBook) - } - } - - - ) - - } - } - - Box( - Modifier - .padding(20.dp, 0.dp) - .height(0.5.dp) - .fillMaxWidth() - .background(Color.Black) - ) - - Row(Modifier.padding(20.dp, 10.dp)) { - Text(text = "Default Background Template") - Spacer(Modifier.width(10.dp)) - SelectMenu( - options = listOf( - "blank" to "Blank page", - "dotted" to "Dot grid", - "lined" to "Lines", - "squared" to "Small squares grid" - ), - onChange = { - val updatedBook = book!!.copy(defaultNativeTemplate = it) - bookRepository.update(updatedBook) - }, - value = book!!.defaultNativeTemplate - ) - - } - - Box( - Modifier - .padding(20.dp, 0.dp) - .height(0.5.dp) - .fillMaxWidth() - .background(Color.Black) - ) - - Column( - Modifier.padding(20.dp, 10.dp) - ) { - Text(text = "Delete Notebook", - textAlign = TextAlign.Center, - modifier = Modifier.noRippleClickable { - bookRepository.delete(bookId) - onClose() - }) - } - } - - } -} \ No newline at end of file diff --git a/app/src/main/java/com/olup/notable/utils/EditorState.kt b/app/src/main/java/com/olup/notable/utils/EditorState.kt deleted file mode 100644 index ac72bcaf..00000000 --- a/app/src/main/java/com/olup/notable/utils/EditorState.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.olup.notable - -import android.graphics.Bitmap -import android.graphics.Color -import android.graphics.Rect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.unit.IntOffset -import com.olup.notable.db.Stroke - -enum class Mode { - Draw, Erase, Select -} - -class EditorState(val bookId: String? = null, val pageId: String, val pageView: PageView) { - - val persistedEditorSettings = EditorSettingCacheManager.getEditorSettings() - - var mode by mutableStateOf(persistedEditorSettings?.mode ?: Mode.Draw) // should save - var pen by mutableStateOf(persistedEditorSettings?.pen ?: Pen.BALLPEN) // should save - var eraser by mutableStateOf(persistedEditorSettings?.eraser ?: Eraser.PEN) // should save - var isDrawing by mutableStateOf(true) - var isToolbarOpen by mutableStateOf( - persistedEditorSettings?.isToolbarOpen ?: false - ) // should save - var penSettings by mutableStateOf( - persistedEditorSettings?.penSettings ?: mapOf( - Pen.BALLPEN.penName to PenSetting(5f, Color.BLACK), - Pen.PENCIL.penName to PenSetting(5f, Color.BLACK), - Pen.BRUSH.penName to PenSetting(5f, Color.BLACK), - Pen.MARKER.penName to PenSetting(40f, Color.LTGRAY), - Pen.FOUNTAIN.penName to PenSetting(5f, Color.BLACK) - ) - ) - - val selectionState = SelectionState() -} - -enum class PlacementMode { - Move, - Paste -} - -class SelectionState { - var firstPageCut by mutableStateOf?>(null) - var secondPageCut by mutableStateOf?>(null) - var selectedStrokes by mutableStateOf?>(null) - var selectedBitmap by mutableStateOf(null) - var selectionStartOffset by mutableStateOf(null) - var selectionDisplaceOffset by mutableStateOf(null) - var selectionRect by mutableStateOf(null) - var placementMode by mutableStateOf(null) - - fun reset() { - selectedStrokes = null - secondPageCut = null - firstPageCut = null - selectedBitmap = null - selectionStartOffset = null - selectionRect = null - selectionDisplaceOffset = null - placementMode = null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/olup/notable/utils/draw.kt b/app/src/main/java/com/olup/notable/utils/draw.kt deleted file mode 100644 index fb3377c2..00000000 --- a/app/src/main/java/com/olup/notable/utils/draw.kt +++ /dev/null @@ -1,193 +0,0 @@ -package com.olup.notable - -import android.graphics.* -import io.shipbook.shipbooksdk.Log -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.toOffset -import com.olup.notable.db.Stroke -import com.onyx.android.sdk.data.note.ShapeCreateArgs -import com.onyx.android.sdk.data.note.TouchPoint -import com.onyx.android.sdk.pen.NeoBrushPen -import com.onyx.android.sdk.pen.NeoCharcoalPen -import com.onyx.android.sdk.pen.NeoFountainPen -import kotlin.math.abs - - -fun drawBallPenStroke( - canvas: Canvas, paint: Paint, strokeSize: Float, points: List -) { - val copyPaint = Paint(paint).apply { - this.strokeWidth = strokeSize - this.style = Paint.Style.STROKE - this.strokeCap = Paint.Cap.ROUND - this.strokeJoin = Paint.Join.ROUND - - this.isAntiAlias = true - } - - val path = Path() - val prePoint = PointF(points[0].x, points[0].y) - path.moveTo(prePoint.x, prePoint.y) - - for (point in points) { - // skip strange jump point. - if (abs(prePoint.y - point.y) >= 30) continue - path.quadTo(prePoint.x, prePoint.y, point.x, point.y) - prePoint.x = point.x - prePoint.y = point.y - } - - canvas.drawPath(path, copyPaint) -} - -fun drawMarkerStroke( - canvas: Canvas, paint: Paint, strokeSize: Float, points: List -) { - val copyPaint = Paint(paint).apply { - this.strokeWidth = strokeSize - this.style = Paint.Style.STROKE - this.strokeCap = Paint.Cap.ROUND - this.strokeJoin = Paint.Join.ROUND - this.isAntiAlias = true - this.color = Color.LTGRAY - this.alpha = 100 - - } - - val path = pointsToPath(points.map { SimplePointF(it.x, it.y) }) - - canvas.drawPath(path, copyPaint) -} - -fun drawStroke(canvas: Canvas, stroke: Stroke, offset: IntOffset) { - //canvas.save() - //canvas.translate(offset.x.toFloat(), offset.y.toFloat()) - - val paint = Paint().apply { - color = Color.BLACK - this.strokeWidth = stroke.size - } - - val points = strokeToTouchPoints(offsetStroke(stroke, offset.toOffset())) - - when (stroke.pen) { - Pen.BALLPEN -> drawBallPenStroke(canvas, paint, stroke.size, points) - Pen.PENCIL -> NeoCharcoalPen.drawNormalStroke( - null, canvas, paint, points, -16777216, stroke.size, ShapeCreateArgs(), Matrix(),false - ) - Pen.BRUSH -> NeoBrushPen.drawStroke(canvas, paint, points, stroke.size, pressure, false) - Pen.MARKER -> drawMarkerStroke(canvas, paint, stroke.size, points) - Pen.FOUNTAIN -> NeoFountainPen.drawStroke( - canvas, paint, points, 1f, stroke.size, pressure, false - ) - } - //canvas.restore() -} - - -const val padding = 0 -const val lineHeight = 50 -const val dotSize = 4f - -fun drawLinedBg(canvas: Canvas, scroll: Int) { - val height = canvas.height - val width = canvas.width - - // white bg - canvas.drawColor(Color.WHITE) - - // paint - val paint = Paint().apply { - this.color = Color.GRAY - this.strokeWidth = 1f - } - - // lines - for (y in 0..height) { - val line = scroll + y - if (line % lineHeight == 0) { - canvas.drawLine( - padding.toFloat(), y.toFloat(), (width - padding).toFloat(), y.toFloat(), paint - ) - } - } -} - -fun drawDottedBg(canvas: Canvas, offset: Int) { - val height = canvas.height - val width = canvas.width - - // white bg - canvas.drawColor(Color.WHITE) - - // paint - val paint = Paint().apply { - this.color = Color.GRAY - this.strokeWidth = 1f - } - - // dots - for (y in 0..height) { - val line = offset + y - if (line % lineHeight == 0 && line >= padding) { - for (x in padding..width - padding step lineHeight) { - canvas.drawOval( - x.toFloat() - dotSize / 2, - y.toFloat() - dotSize / 2, - x.toFloat() + dotSize / 2, - y.toFloat() + dotSize / 2, - paint - ) - } - } - } - -} - -fun drawSquaredBg(canvas: Canvas, scroll: Int) { - Log.i(TAG, "Drawing BG") - val height = canvas.height - val width = canvas.width - - // white bg - canvas.drawColor(Color.WHITE) - - // paint - val paint = Paint().apply { - this.color = Color.GRAY - this.strokeWidth = 1f - } - - // lines - for (y in 0..height) { - val line = scroll + y - if (line % lineHeight == 0) { - canvas.drawLine( - padding.toFloat(), y.toFloat(), (width - padding).toFloat(), y.toFloat(), paint - ) - } - } - - for (x in padding..width - padding step lineHeight) { - canvas.drawLine( - x.toFloat(), padding.toFloat(), x.toFloat(), height.toFloat(), paint - ) - } -} - -fun drawBg(canvas: Canvas, nativeTemplate: String, scroll: Int){ - when(nativeTemplate){ - "blank" -> canvas.drawColor(Color.WHITE) - "dotted" -> drawDottedBg(canvas, scroll) - "lined" -> drawLinedBg(canvas, scroll) - "squared" -> drawSquaredBg(canvas, scroll) - } -} - -val selectPaint = Paint().apply { - strokeWidth = 5f - style = Paint.Style.STROKE - pathEffect = DashPathEffect(floatArrayOf(20f, 10f), 0f) - isAntiAlias = true - color = Color.GRAY -} \ No newline at end of file diff --git a/app/src/main/java/com/olup/notable/utils/eraser.kt b/app/src/main/java/com/olup/notable/utils/eraser.kt deleted file mode 100644 index 4af6ff76..00000000 --- a/app/src/main/java/com/olup/notable/utils/eraser.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.olup.notable - -enum class Eraser (val _name : String) { - PEN("PEN"), - SELECT("SELECT"), -} \ No newline at end of file diff --git a/app/src/main/java/com/olup/notable/utils/page.kt b/app/src/main/java/com/olup/notable/utils/page.kt deleted file mode 100644 index 4949e066..00000000 --- a/app/src/main/java/com/olup/notable/utils/page.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.olup.notable - -import android.content.Context -import android.graphics.pdf.PdfDocument -import android.os.Environment -import androidx.compose.ui.unit.IntOffset -import com.olup.notable.db.BookRepository -import com.olup.notable.db.PageRepository -import com.olup.notable.db.Stroke -import io.shipbook.shipbooksdk.Log -import java.io.FileOutputStream -import java.nio.file.Files -import kotlin.io.path.absolutePathString -import kotlin.io.path.div - -fun exportBook(context: Context, bookId: String) { - val book = BookRepository(context).getById(bookId) ?: return - val pages = PageRepository(context) - exportPdf("notebooks", book.title) { - book.pageIds.forEachIndexed { i, pageId -> writePage(i + 1, pages, pageId) } - } -} - -fun exportPage(context: Context, pageId: String) { - val pages = PageRepository(context) - exportPdf("pages", "notable-page-${pageId.takeLast(6)}") { - writePage(1, pages, pageId) - } -} - -private inline fun exportPdf(dir: String, name: String, write: PdfDocument.() -> Unit) { - val document = PdfDocument() - document.write() - val filePath = Environment.getExternalStorageDirectory().toPath() / - Environment.DIRECTORY_DOCUMENTS / "notable" / dir / "$name.pdf" - Files.createDirectories(filePath.parent) - FileOutputStream(filePath.absolutePathString()).use(document::writeTo) - document.close() -} - -private fun PdfDocument.writePage(number: Int, repo: PageRepository, id: String) { - val (page, strokes) = repo.getWithStrokeById(id) - - val strokeHeight = if (strokes.isEmpty()) 0 else strokes.maxOf(Stroke::bottom).toInt() + 50 - val strokeWidth = if (strokes.isEmpty()) 0 else strokes.maxOf(Stroke::right).toInt() + 50 - - val height = strokeHeight.coerceAtLeast(SCREEN_HEIGHT) // todo do not rely on this anymore - val width = strokeWidth.coerceAtLeast(SCREEN_WIDTH) // todo do not rely on this anymore - - val documentPage = - startPage(PdfDocument.PageInfo.Builder(width, height, number).create()) - - drawBg(documentPage.canvas, page.nativeTemplate, 0) - - for (stroke in strokes) { - drawStroke(documentPage.canvas, stroke, IntOffset(0, 0)) - } - - finishPage(documentPage) -} diff --git a/app/src/main/java/com/olup/notable/utils/pen.kt b/app/src/main/java/com/olup/notable/utils/pen.kt deleted file mode 100644 index 6be766e8..00000000 --- a/app/src/main/java/com/olup/notable/utils/pen.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.olup.notable - -import com.onyx.android.sdk.pen.style.StrokeStyle - -enum class Pen (val penName : String) { - BALLPEN("BALLPEN"), - PENCIL("PENCIL"), - BRUSH("BRUSH"), - MARKER("MARKER"), - FOUNTAIN("FOUNTAIN") -} - -fun penToStroke(pen: Pen): Int { - return when (pen) { - Pen.BALLPEN -> StrokeStyle.PENCIL - Pen.PENCIL -> StrokeStyle.CHARCOAL - Pen.BRUSH -> StrokeStyle.NEO_BRUSH - Pen.MARKER -> StrokeStyle.MARKER - Pen.FOUNTAIN -> StrokeStyle.FOUNTAIN - } -} - - -@kotlinx.serialization.Serializable -data class PenSetting( - var strokeSize: Float, - var color: Int -) - -typealias NamedSettings = Map diff --git a/app/src/main/java/com/olup/notable/utils/utils.kt b/app/src/main/java/com/olup/notable/utils/utils.kt deleted file mode 100644 index ffb093c2..00000000 --- a/app/src/main/java/com/olup/notable/utils/utils.kt +++ /dev/null @@ -1,465 +0,0 @@ -package com.olup.notable - -import android.content.Context -import android.content.Intent -import android.graphics.* -import android.util.DisplayMetrics -import android.util.TypedValue -import io.shipbook.shipbooksdk.Log -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.core.content.ContextCompat -import androidx.core.content.FileProvider -import androidx.core.graphics.toRect -import androidx.core.graphics.toRegion -import com.olup.notable.db.* -import com.onyx.android.sdk.data.note.TouchPoint -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import java.io.File -import java.io.FileOutputStream -import java.io.IOException - - -fun Modifier.noRippleClickable( - onClick: () -> Unit -): Modifier = composed { - clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { - onClick() - } -} - - -fun convertDpToPixel(dp: Dp, context: Context): Float { - val resources = context.resources - val metrics: DisplayMetrics = resources.getDisplayMetrics() - return TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - dp.value, - context.resources.displayMetrics - ) -} - -// TODO move this to repository -fun deletePage(context: Context, pageId: String) { - val appRepository = AppRepository(context) - val page = appRepository.pageRepository.getById(pageId) ?: return - val proxy = appRepository.kvProxy - val settings = proxy.get("APPS_SETTINGS", AppSettings.serializer()) - - - runBlocking { - // remove from book - if(page.notebookId != null){ - appRepository.bookRepository.removePage(page.notebookId, pageId) - } - - // remove from quick nav - if(settings != null && settings.quickNavPages.contains(pageId)){ - proxy.setKv("APPS_SETTINGS", settings.copy(quickNavPages = settings.quickNavPages - pageId),AppSettings.serializer()) - } - - launch { - appRepository.pageRepository.delete(pageId) - } - launch { - val imgFile = File(context.filesDir, "pages/previews/thumbs/$pageId") - if (imgFile.exists()) { - imgFile.delete() - } - } - launch { - val imgFile = File(context.filesDir, "pages/previews/full/$pageId") - if (imgFile.exists()) { - imgFile.delete() - } - } - - } -} - -fun Flow.withPrevious(): Flow> = flow { - var prev: T? = null - this@withPrevious.collect { - emit(prev to it) - prev = it - } -} - -fun pointsToPath(points: List): Path { - val path = Path() - val prePoint = PointF(points[0].x, points[0].y) - path.moveTo(prePoint.x, prePoint.y) - - for (point in points) { - // skip strange jump point. - //if (abs(prePoint.y - point.y) >= 30) continue - path.quadTo(prePoint.x, prePoint.y, point.x, point.y) - prePoint.x = point.x - prePoint.y = point.y - } - return path -} - -// points is in page coordinates -fun handleErase( - page: PageView, - history: History, - points: List, - eraser: Eraser -) { - val paint = Paint().apply { - this.strokeWidth = 30f - this.style = Paint.Style.STROKE - this.strokeCap = Paint.Cap.ROUND - this.strokeJoin = Paint.Join.ROUND - this.isAntiAlias = true - } - val path = pointsToPath(points) - var outPath = Path() - - if(eraser == Eraser.SELECT){ - path.close() - outPath = path - } - - - if(eraser == Eraser.PEN) { - paint.getFillPath(path, outPath) - } - - val deletedStrokes = selectStrokesFromPath(page.strokes, outPath) - - val deletedStrokeIds = deletedStrokes.map { it.id } - page.removeStrokes(deletedStrokeIds) - - history.addOperationsToHistory(listOf(Operation.AddStroke(deletedStrokes))) - - page.drawArea( - area = pageAreaToCanvasArea(strokeBounds(deletedStrokes), page.scroll) - ) -} - -enum class SelectPointPosition { - LEFT, - RIGHT, - CENTER -} - -// points is in page coodinates -fun handleSelect( - scope: CoroutineScope, - page: PageView, - editorState: EditorState, - points: List -) { - val state = editorState.selectionState - - val firstPointPosition = - if (points.first().x < 50) SelectPointPosition.LEFT else if (points.first().x > page.width - 50) SelectPointPosition.RIGHT else SelectPointPosition.CENTER - val lastPointPosition = - if (points.last().x < 50) SelectPointPosition.LEFT else if (points.last().x > page.width - 50) SelectPointPosition.RIGHT else SelectPointPosition.CENTER - - if (firstPointPosition != SelectPointPosition.CENTER && lastPointPosition != SelectPointPosition.CENTER && firstPointPosition != lastPointPosition) { - // Page cut situation - val correctedPoints = - if (firstPointPosition === SelectPointPosition.LEFT) points else points.reversed() - // lets make this end to end - val completePoints = - listOf(SimplePointF(0f, correctedPoints.first().y)) + correctedPoints + listOf( - SimplePointF(page.width.toFloat(), correctedPoints.last().y) - ) - if (state.firstPageCut == null) { - // this is the first page cut - state.firstPageCut = completePoints - Log.i(TAG, "Registered first curt") - } else { - // this is the second page cut, we can also select the strokes - // first lets have the cuts in the right order - if (completePoints[0].y > state.firstPageCut!![0].y) state.secondPageCut = - completePoints - else { - state.secondPageCut = state.firstPageCut - state.firstPageCut = completePoints - } - // let's get stroke selection from that - val (_, after) = divideStrokesFromCut(page.strokes, state.firstPageCut!!) - val (middle, _) = divideStrokesFromCut(after, state.secondPageCut!!) - state.selectedStrokes = middle - } - } else { - // lasso selection - // padding inside the dashed selection square - val padding = 30 - - // rcreate the lasso selection - val selectionPath = pointsToPath(points) - selectionPath.close() - - // get the selected strokes - val selectedStrokes = selectStrokesFromPath(page.strokes, selectionPath) - if (selectedStrokes.isEmpty()) return - - // TODO collocate with control tower ? - - state.selectedStrokes = selectedStrokes - - // area of implication - in page and view reference - val pageBounds = strokeBounds(selectedStrokes) - pageBounds.inset(-padding, -padding) - - val bounds = pageAreaToCanvasArea(pageBounds, page.scroll) - - // create bitmap and draw strokes - val selectedBitmap = - Bitmap.createBitmap(bounds.width(), bounds.height(), Bitmap.Config.ARGB_8888) - val selectedCanvas = Canvas(selectedBitmap) - selectedStrokes.forEach { - drawStroke( - selectedCanvas, - it, - IntOffset(-pageBounds.left, -pageBounds.top) - ) - } - - // set state - state.selectedBitmap = selectedBitmap - state.selectionStartOffset = IntOffset(bounds.left, bounds.top) - state.selectionRect = bounds - state.selectionDisplaceOffset = IntOffset(0, 0) - state.placementMode = PlacementMode.Move - -// page.removeStrokes(selectedStrokes.map{it.id}) - page.drawArea(bounds, selectedStrokes.map { it.id }) - - scope.launch { - DrawCanvas.refreshUi.emit(Unit) - editorState.isDrawing = false - } - } -} - - -// touchpoints is in wiew coordinates -fun handleDraw( - page: PageView, - historyBucket: MutableList, - strokeSize: Float, - pen: Pen, - touchPoints: List -) { - val initialPoint = touchPoints[0] - val boundingBox = RectF( - initialPoint.x, - initialPoint.y + page.scroll, - initialPoint.x, - initialPoint.y + page.scroll - ) - - val points = touchPoints.map { - boundingBox.union(it.x, it.y + page.scroll) - StrokePoint( - x = it.x, - y = it.y + page.scroll, - pressure = it.pressure, - size = it.size, - tiltX = it.tiltX, - tiltY = it.tiltY, - timestamp = it.timestamp, - ) - } - - boundingBox.inset(-strokeSize, -strokeSize) - - val stroke = Stroke( - size = strokeSize, - pen = pen, - pageId = page.id, - top = boundingBox.top, - bottom = boundingBox.bottom, - left = boundingBox.left, - right = boundingBox.right, - points = points - ) - page.addStrokes(listOf(stroke)) - page.drawArea(pageAreaToCanvasArea(strokeBounds(stroke).toRect(), page.scroll)) - historyBucket.add(stroke.id) -} - - -inline fun Modifier.ifTrue(predicate: Boolean, builder: () -> Modifier) = - then(if (predicate) builder() else Modifier) - -fun strokeToTouchPoints(stroke: Stroke): List { - return stroke.points.map { - TouchPoint( - it.x, - it.y, - it.pressure, - stroke.size, - it.tiltX, - it.tiltY, - it.timestamp - ) - } -} - -fun pageAreaToCanvasArea(pageArea: Rect, scroll: Int): Rect { - return Rect( - pageArea.left, pageArea.top - scroll, pageArea.right, pageArea.bottom - scroll - ) -} - -fun strokeBounds(stroke: Stroke): RectF { - return RectF( - stroke.left, stroke.top, stroke.right, stroke.bottom - ) -} - -fun strokeBounds(strokes: List): Rect { - if (strokes.size == 0) return Rect() - val stroke = strokes[0] - val rect = Rect( - stroke.left.toInt(), stroke.top.toInt(), stroke.right.toInt(), stroke.bottom.toInt() - ) - strokes.forEach { - rect.union( - Rect( - it.left.toInt(), it.top.toInt(), it.right.toInt(), it.bottom.toInt() - ) - ) - } - return rect -} - -data class SimplePoint(val x: Int, val y: Int) -data class SimplePointF(val x: Float, val y: Float) - -fun pathToRegion(path: Path): Region { - val bounds = RectF() - path.computeBounds(bounds, true) - val region = Region() - region.setPath( - path, - bounds.toRegion() - ) - return region -} - -fun divideStrokesFromCut( - strokes: List, - cutLine: List -): Pair, List> { - val maxY = cutLine.maxOfOrNull { it.y } - val cutArea = listOf(SimplePointF(0f, maxY!!)) + cutLine + listOf( - SimplePointF( - cutLine.last().x, - maxY - ) - ) - val cutPath = pointsToPath(cutArea) - cutPath.close() - - val bounds = RectF().apply { - cutPath.computeBounds(this, true) - } - val cutRegion = pathToRegion(cutPath) - - val strokesOver: MutableList = mutableListOf() - val strokesUnder: MutableList = mutableListOf() - - strokes.forEach { stroke -> - if (stroke.top > bounds.bottom) strokesUnder.add(stroke) - else if (stroke.bottom < bounds.top) strokesOver.add(stroke) - else { - if (stroke.points.any { point -> - cutRegion.contains( - point.x.toInt(), - point.y.toInt() - ) - }) strokesUnder.add(stroke) - else strokesOver.add(stroke) - } - } - - return strokesOver to strokesUnder -} - -fun selectStrokesFromPath(strokes: List, path: Path): List { - val bounds = RectF() - path.computeBounds(bounds, true) - val region = pathToRegion(path) - - return strokes.filter { - strokeBounds(it).intersect(bounds) - }.filter() { it.points.any { region.contains(it.x.toInt(), it.y.toInt()) } } -} - -fun offsetStroke(stroke: Stroke, offset: Offset): Stroke { - return stroke.copy( - points = stroke.points.map { p -> p.copy(x = p.x + offset.x, y = p.y + offset.y) }, - top = stroke.top + offset.y, - bottom = stroke.bottom + offset.y, - left = stroke.left + offset.x, - right = stroke.right + offset.x, - ) -} - -public class Provider : FileProvider(R.xml.paths) { -} - -fun shareBitmap(context: Context, bitmap: Bitmap) { - val bmpWithBackground = - Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bmpWithBackground) - canvas.drawColor(Color.WHITE) - canvas.drawBitmap(bitmap, 0f, 0f, null) - - val cachePath = File(context.cacheDir, "images") - Log.i(TAG, cachePath.toString()) - cachePath.mkdirs() - - try { - val stream = - FileOutputStream("$cachePath/share.png") - bmpWithBackground.compress( - Bitmap.CompressFormat.PNG, - 100, - stream - ) - stream.close() - } catch (e: IOException) { - e.printStackTrace() - } - - val bitmapFile = File(cachePath, "share.png") - val contentUri = FileProvider.getUriForFile( - context, - "com.olup.notable.provider", //(use your app signature + ".provider" ) - bitmapFile - ); - - val sendIntent = Intent().apply { - if (contentUri != null) { - action = Intent.ACTION_SEND - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) // temp permission for receiving app to read this file - putExtra(Intent.EXTRA_STREAM, contentUri); - type = "image/png"; - } - - context.grantUriPermission("android", contentUri, Intent.FLAG_GRANT_READ_URI_PERMISSION); - } - - ContextCompat.startActivity(context, Intent.createChooser(sendIntent, "Choose an app"), null) -} - - diff --git a/app/src/main/java/com/olup/notable/views/HomeView.kt b/app/src/main/java/com/olup/notable/views/HomeView.kt deleted file mode 100644 index 08bc40ac..00000000 --- a/app/src/main/java/com/olup/notable/views/HomeView.kt +++ /dev/null @@ -1,272 +0,0 @@ -package com.olup.notable - -import io.shipbook.shipbooksdk.Log -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Badge -import androidx.compose.material.BadgedBox -import androidx.compose.material.Icon -import androidx.compose.material.Text -import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.Alignment.Companion.CenterVertically -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import com.olup.notable.AppRepository -import com.olup.notable.db.Folder -import com.olup.notable.db.Notebook -import com.olup.notable.db.Page -import compose.icons.FeatherIcons -import compose.icons.feathericons.Folder -import compose.icons.feathericons.Settings -import java.net.URL -import kotlin.concurrent.thread - -@ExperimentalFoundationApi -@ExperimentalComposeUiApi -@Composable -fun Library(navController: NavController, folderId: String? = null) { - val context = LocalContext.current - - var isSettingsOpen by remember { - mutableStateOf(false) - } - val appRepository = AppRepository(LocalContext.current) - - val books by appRepository.bookRepository.getAllInFolder(folderId).observeAsState() - val singlePages by appRepository.pageRepository.getSinglePagesInFolder(folderId) - .observeAsState() - val folders by appRepository.folderRepository.getAllInFolder(folderId).observeAsState() - - var isLatestVersion by remember { - mutableStateOf(true) - } - LaunchedEffect(key1 = Unit, block = { - thread { - isLatestVersion = isLatestVersion(context, true) - } - }) - - Column( - Modifier.fillMaxSize() - ) { - Topbar( - ) { - Row(Modifier.fillMaxWidth()) { - Spacer(modifier = Modifier.weight(1f)) - BadgedBox( - badge = { if(!isLatestVersion) Badge( backgroundColor = Color.Black, modifier = Modifier.offset(-12.dp, 10.dp) ) } - ) { - Icon( - imageVector = FeatherIcons.Settings, - contentDescription = "", - Modifier - .padding(8.dp) - .noRippleClickable { - isSettingsOpen = true - }) - } - - } - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - Text(text = "Add quick page", - textAlign = TextAlign.Center, - modifier = Modifier - .noRippleClickable { - val page = Page( - notebookId = null, - parentFolderId = folderId, - nativeTemplate = appRepository.kvProxy.get( - "APP_SETTINGS", AppSettings.serializer() - )?.defaultNativeTemplate ?: "blank" - ) - appRepository.pageRepository.create(page) - navController.navigate("pages/${page.id}") - } - .padding(10.dp)) - - Text(text = "Add notebook", - textAlign = TextAlign.Center, - modifier = Modifier - .noRippleClickable { - appRepository.bookRepository.create( - Notebook(parentFolderId = folderId) - ) - } - .padding(10.dp)) - - Text(text = "Add Folder", - textAlign = TextAlign.Center, - modifier = Modifier - .noRippleClickable { - val folder = Folder(parentFolderId = folderId) - appRepository.folderRepository.create(folder) - } - .padding(10.dp)) - } - } - Row( - Modifier - .padding(10.dp) - ) { - BreadCrumb(folderId) { navController.navigate("library" + if (it == null) "" else "?folderId=${it}") } - } - Column( - Modifier.padding(10.dp) - ) { - - if (folders?.isEmpty()?.not() == true) { - Text(text = "Folders") - Spacer(Modifier.height(10.dp)) - - LazyRow( - horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.fillMaxWidth() - ) { - items(folders!!) { folder -> - var isFolderSettingsOpen by remember { mutableStateOf(false) } - if (isFolderSettingsOpen) FolderConfigDialog( - folderId = folder.id, - onClose = { - Log.i(TAG, "Closing Directory Dialog") - isFolderSettingsOpen = false - }) - - Row( - Modifier - .combinedClickable( - onClick = { - navController.navigate("library?folderId=${folder.id}") - }, - onLongClick = { - isFolderSettingsOpen = !isFolderSettingsOpen - }, - ) - .border(0.5.dp, Color.Black) - .padding(10.dp, 5.dp) - ) { - Icon( - imageVector = FeatherIcons.Folder, - contentDescription = "folder icon", - Modifier.height(20.dp) - ) - Spacer(Modifier.width(10.dp)) - Text(text = folder.title) - } - } - } - Spacer(Modifier.height(10.dp)) - } - - if (singlePages?.isEmpty()?.not() == true) { - Text(text = "Quick pages") - Spacer(Modifier.height(10.dp)) - - LazyRow( - horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.fillMaxWidth() - ) { - items(singlePages!!.reversed()) { page -> - val pageId = page.id - var isPageSelected by remember { mutableStateOf(false) } - Box { - PagePreview( - modifier = Modifier - .combinedClickable( - onClick = { - navController.navigate("pages/$pageId") - }, - onLongClick = { - isPageSelected = true - }, - ) - .width(100.dp) - .aspectRatio(3f / 4f) - .border(1.dp, Color.Black, RectangleShape), - pageId = pageId - ) - if (isPageSelected) PageMenu( - pageId = pageId, - canDelete = true, - onClose = { isPageSelected = false }) - } - } - - } - Spacer(Modifier.height(10.dp)) - } - - if (books?.isEmpty()?.not() == true) { - Text(text = "Notebooks") - Spacer(Modifier.height(10.dp)) - - LazyVerticalGrid( - columns = GridCells.Adaptive(100.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - items(books!!) { item -> - var isSettingsOpen by remember { mutableStateOf(false) } - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(3f / 4f) - .border(1.dp, Color.Black, RectangleShape) - .background(Color.White) - .clip(RoundedCornerShape(2)) - .combinedClickable( - onClick = { - val bookId = item.id - val pageId = item.openPageId ?: item.pageIds[0] - navController.navigate("books/$bookId/pages/$pageId") - }, - onLongClick = { - isSettingsOpen = true - }, - ) - ) { - Text( - text = item.pageIds.size.toString(), - modifier = Modifier - .background(Color.Black) - .padding(5.dp), - color = Color.White - ) - Row(Modifier.fillMaxSize()) { - Text( - text = item.title, - textAlign = TextAlign.Center, - modifier = Modifier - .align(CenterVertically) - .fillMaxWidth() - ) - } - } - - if (isSettingsOpen) NotebookConfigDialog( - bookId = item.id, - onClose = { isSettingsOpen = false }) - } - } - } - } - } - - if (isSettingsOpen) AppSettingsModal(onClose = { isSettingsOpen = false }) -} - - - diff --git a/app/src/main/res/drawable/ballpenblue.xml b/app/src/main/res/drawable/ballpenblue.xml new file mode 100644 index 00000000..44455749 --- /dev/null +++ b/app/src/main/res/drawable/ballpenblue.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ballpengreen.xml b/app/src/main/res/drawable/ballpengreen.xml new file mode 100644 index 00000000..205350ae --- /dev/null +++ b/app/src/main/res/drawable/ballpengreen.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ballpenred.xml b/app/src/main/res/drawable/ballpenred.xml new file mode 100644 index 00000000..4d90bf66 --- /dev/null +++ b/app/src/main/res/drawable/ballpenred.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/close.xml b/app/src/main/res/drawable/close.xml new file mode 100644 index 00000000..f8ca0c64 --- /dev/null +++ b/app/src/main/res/drawable/close.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/delete.xml b/app/src/main/res/drawable/delete.xml new file mode 100644 index 00000000..e7591350 --- /dev/null +++ b/app/src/main/res/drawable/delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/fullscreen.xml b/app/src/main/res/drawable/fullscreen.xml new file mode 100644 index 00000000..860d4003 --- /dev/null +++ b/app/src/main/res/drawable/fullscreen.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/fullscreen_exit.xml b/app/src/main/res/drawable/fullscreen_exit.xml new file mode 100644 index 00000000..eca4254c --- /dev/null +++ b/app/src/main/res/drawable/fullscreen_exit.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/home.xml b/app/src/main/res/drawable/home.xml new file mode 100644 index 00000000..85cedb3d --- /dev/null +++ b/app/src/main/res/drawable/home.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/image.xml b/app/src/main/res/drawable/image.xml new file mode 100644 index 00000000..80c4d270 --- /dev/null +++ b/app/src/main/res/drawable/image.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/library.xml b/app/src/main/res/drawable/library.xml index 903e92d8..ec57edb4 100644 --- a/app/src/main/res/drawable/library.xml +++ b/app/src/main/res/drawable/library.xml @@ -1,10 +1,5 @@ - - + + + + diff --git a/app/src/main/res/drawable/line.xml b/app/src/main/res/drawable/line.xml new file mode 100644 index 00000000..8ba5892b --- /dev/null +++ b/app/src/main/res/drawable/line.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/line_weight.xml b/app/src/main/res/drawable/line_weight.xml new file mode 100644 index 00000000..ea6ec428 --- /dev/null +++ b/app/src/main/res/drawable/line_weight.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/minus.xml b/app/src/main/res/drawable/minus.xml new file mode 100644 index 00000000..f9e62769 --- /dev/null +++ b/app/src/main/res/drawable/minus.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/palette.xml b/app/src/main/res/drawable/palette.xml new file mode 100644 index 00000000..89ef6f73 --- /dev/null +++ b/app/src/main/res/drawable/palette.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/plus.xml b/app/src/main/res/drawable/plus.xml new file mode 100644 index 00000000..b74729a6 --- /dev/null +++ b/app/src/main/res/drawable/plus.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml similarity index 100% rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to app/src/main/res/mipmap-anydpi/ic_launcher.xml diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml similarity index 100% rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to app/src/main/res/mipmap-anydpi/ic_launcher_round.xml diff --git a/app/src/main/res/xml/paths.xml b/app/src/main/res/xml/file_paths.xml similarity index 100% rename from app/src/main/res/xml/paths.xml rename to app/src/main/res/xml/file_paths.xml diff --git a/app/src/test/java/com/olup/notable/ExampleUnitTest.kt b/app/src/test/java/com/ethran/notable/ExampleUnitTest.kt similarity index 92% rename from app/src/test/java/com/olup/notable/ExampleUnitTest.kt rename to app/src/test/java/com/ethran/notable/ExampleUnitTest.kt index de891644..23617280 100644 --- a/app/src/test/java/com/olup/notable/ExampleUnitTest.kt +++ b/app/src/test/java/com/ethran/notable/ExampleUnitTest.kt @@ -1,4 +1,4 @@ -package com.olup.notable +package com.ethran.notable import org.junit.Assert.assertEquals import org.junit.Test diff --git a/build.gradle b/build.gradle index 1049f7d4..1d77cbbc 100644 --- a/build.gradle +++ b/build.gradle @@ -1,25 +1,24 @@ buildscript { repositories { - // Make sure that you have the following two repositories - google() // Google's Maven repository - mavenCentral() // Maven Central repository + google() + mavenCentral() } dependencies { // Add the dependency for the Google services Gradle plugin - classpath 'com.google.gms:google-services:4.3.15' + classpath 'com.google.gms:google-services:4.4.2' } ext { - compose_version = '1.3.1' + compose_version = '1.7.8' } }// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '7.4.1' apply false - id 'com.android.library' version '7.4.1' apply false - id 'org.jetbrains.kotlin.android' version '1.7.10' apply false - id 'org.jetbrains.kotlin.jvm' version '1.7.10' - id 'org.jetbrains.kotlin.plugin.serialization' version '1.7.10' - id 'com.google.devtools.ksp' version '1.7.10-1.0.6' - + id 'com.android.application' version '8.9.1' apply false + id 'com.android.library' version '8.9.1' apply false + id 'org.jetbrains.kotlin.android' version '2.1.10' apply false + id 'org.jetbrains.kotlin.jvm' version '2.1.10' + id 'org.jetbrains.kotlin.plugin.serialization' version '2.1.10' + id 'com.google.devtools.ksp' version '2.1.10-1.0.30' + id 'org.jetbrains.kotlin.plugin.compose' version '2.1.10' apply false } diff --git a/gradle.properties b/gradle.properties index 30e360f6..f6e6f8e1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,4 +22,4 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -android.defaults.buildfeatures.buildconfig=true \ No newline at end of file +android.nonFinalResIds=false \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 3a2c9a79..bcf6c2d8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Sun Jan 29 13:21:50 CET 2023 +#Wed Dec 25 00:06:39 CET 2024 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/readme.md b/readme.md index 5d776a26..c771d7d3 100644 --- a/readme.md +++ b/readme.md @@ -1,85 +1,204 @@ - -# Notable - - - - - - - - - +[![License][license-shield]][license-url] +[![Total Downloads][downloads-shield]][downloads-url] +[![Discord][discord-shield]][discord-url] + +![Notable App][logo] + +# Notable (Fork) + +A maintained and customized fork of the archived [olup/notable](https://github.com/olup/notable) project. + +[![π Report Bug][bug-shield]][bug-url] +[![Download Latest][download-shield]][download-url] +[![π‘ Request Feature][feature-shield]][feature-url] + + + - - + + + - -[Features](#features) β’ -[Download](#download) β’ -[Gestures](#gestures) β’ -[Contribute](#contribute) - + +--- + + Table of Contents + +- [About This Fork](#about-this-fork) +- [Features](#features) +- [Download](#download) +- [Gestures](#gestures) +- [Supported Devices](#supported-devices) +- [Roadmap](#roadmap) +- [Screenshots](#screenshots) +- [Contribute](#contribute) + + + -Notable is a **custom note-taking app designed specifically for BOOX e-ink devices.** It offers a clean, minimalist design, with a range of special features and optimizations to enhance the note-taking experience. +--- -*β οΈ This is alpha software with a couple of part time individuals pushing it further. We try to make it as stable as possible and to support a smooth update experience, but be prepared for the occasionnal bug and possible breaking changes.* +## About This Fork +This fork is maintained by **Ethran** as a continuation and personal enhancement of the original Notable app. Development is semi-active and tailored toward personal utility while welcoming community suggestions. + +### What's New? +- Regular updates and experimental features +- Improved usability and speed +- Custom features suited for e-ink devices and note-taking + +> β οΈ Note: Features may reflect personal preferences. + +--- ## Features -* β‘ **Fast Page Turn with Caching:** Notable leverages caching techniques to ensure smooth and swift page transitions, allowing you to navigate through your notes seamlessly. +* β‘ **Fast Page Turn with Caching:** Notable leverages caching techniques to ensure smooth and swift page transitions, allowing you to navigate through your notes seamlessly. (next and previous pages are cached) * βοΈ **Infinite Vertical Scroll:** Enjoy a virtually endless canvas for your notes. Scroll vertically without limitations. * π **Quick Pages:** Quickly create a new page using the Quick Pages feature. -* π **Notebooks:** Keep related notes together and easily switch between different notebooks based on your needs. +* π **Notebooks:** Keep related notes together and easily switch between different notebooοΈοΈks based on your needs. * π **Folders:** Create folders to organize your notes. * π€ **Editors' Mode Gestures:** [Intuitive gesture controls](#gestures) to enhance the editing experience. +* π **Images:** Add, move, scale, and remove images. +* οΈοΈα οΈβ€ **Selection export:** share selected text. ## Download -**Download the latest stable version of the [Notable app here.](https://github.com/olup/notable/releases/latest)** +**Download the latest stable version of the [Notable app here.](https://github.com/Ethran/notable/releases/latest)** -Alternatively, get the latest build from main from the ["next" release](https://github.com/olup/notable/releases/next) +Alternatively, get the latest build from main from the ["next" release](https://github.com/Ethran/notable/releases/next) Open up the '**Assets**' from the release, and select the `.apk` file. β Where can I see alternative/older releases? -Select the projects 'Releases' and download alternative versions of the Notable app. +You can go to original olup 'Releases' and download alternative versions of the Notable app. β What is a 'next' release? -The 'next' release is a pre-release, and will contain features imlemented but not yet released as part of a version - and sometimes experiments that could very well not be part a release. +The 'next' release is a pre-release, and will contain features implemented but not yet released as part of a version - and sometimes experiments that could very well not be part a release. +--- + ## Gestures Notable features intuitive gestures controls within Editor's Mode, to optimize the editing experience: #### βοΈ 1 Finger * **Swipe up or down**: Scroll the page. * **Swipe left or right:** Change to the previous/next page (only available in notebooks). -* **Double tap:** Show or hide the toolbar. -* **Double tap bottom part of the screen:** Show quick navigation. - +* **Double tap:** Undo +* **Hold and drag:** select text and images #### βοΈ 2 Fingers -* **Swipe left or right:** Undo/redo your changes. +* **Swipe left or right:** Show or hide the toolbar. * **Single tap:** Switch between writing modes and eraser modes. #### π² Selection * **Drag:** Move the selected writing around. * **Double tap:** Copy the selected writing. +## Supported Devices + +The following table lists devices confirmed by users to be compatible with specific versions of Notable. +This does not imply any commitment from the developers. +| Device Name | v0.0.10 | v0.0.11dev | v0.0.14+ | | | +|---------------------------------------------------------------------------------------|---------|------------|--------|--------|--------| +| [ONYX BOOX Go 10.3](https://onyxboox.com/boox_go103) | β | ? | β | | | +| [Onyx Boox Note Air 4 C](https://onyxboox.pl/en/ebook-readers/onyx-boox-note-air-4-c) | β | β | β | | | +| [Onyx Boox Note Air 3 C](https://onyxboox.pl/en/ebook-readers/onyx-boox-note-air-3-c) | β | β | β | | | +| [Onyx Boox Note Max](https://shop.boox.com/products/notemax) | β | β | β | | | +| [Boox Note 3](https://onyxboox.pl/en/ebook-readers/onyx-boox-note-3) | β | β https://github.com/Ethran/notable/issues/24 | β | | | + +Feel free to add your device if tested successfully! + +## Roadmap + +Features Iβd like to implement in the future (some might take a while β or a long while): + +- [ ] Bookmarks support, tags, and internal links β [Issue #52](https://github.com/Ethran/notable/issues/52) + - [ ] Export links to PDF + +- [ ] Better notebook covers, provide default styles of title page + +- [ ] PDF annotation + +- [ ] Figure and text recognition β [Issue #44](https://github.com/Ethran/notable/issues/44) + - [ ] Searchable notes + - [ ] Automatic creation of tag descriptions + - [ ] Shape recognition + +- [ ] Better selection tools + - [ ] Stroke editing: color, size, etc. + - [ ] Rotate + - [ ] Flip selection + - [ ] Auto-scroll when dragging selection to screen edges + - [ ] Easier selection movement (e.g. dragging to scroll page) + +- [ ] More dynamic page and notebook movement. Currently, pages can only be moved left/right β add drag-and-drop support + +- [ ] Custom drawing tools, might not be possible. + + +--- + +## Screenshots + + + + + + + + + +--- + ## Contribute -Notable is an open-source project and welcomes contributions from the community. -To start working with the project, see [the guide on how to start contributing](https://docs.github.com/en/get-started/quickstart/contributing-to-projects) to the project. -***Important:*** Be sure to edit the `DEBUG_STORE_FILE` variable in the `/app/gradle.properties` file to the keystore on your own device. This is likely stored in the `.android` directory on your device. +Notable is an open-source project, and contributions are welcome. If you'd like to get started, please refer to [GitHub's contributing guide](https://docs.github.com/en/get-started/quickstart/contributing-to-projects). + +### Development Notes + +- Edit the `DEBUG_STORE_FILE` in `/app/gradle.properties` to point to your local keystore file. This is typically located in the `.android` directory. +- To debug on a BOOX device, enable developer mode. You can follow [this guide](https://imgur.com/a/i1kb2UQ). + +Feel free to open issues or submit pull requests. I appreciate your help! + +--- + + +[logo]: https://github.com/Ethran/notable/blob/main/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png?raw=true "Notable Logo" +[contributors-shield]: https://img.shields.io/github/contributors/Ethran/notable.svg?style=for-the-badge +[contributors-url]: https://github.com/Ethran/notable/graphs/contributors +[forks-shield]: https://img.shields.io/github/forks/Ethran/notable.svg?style=for-the-badge +[forks-url]: https://github.com/Ethran/notable/network/members +[stars-shield]: https://img.shields.io/github/stars/Ethran/notable.svg?style=for-the-badge +[stars-url]: https://github.com/Ethran/notable/stargazers +[issues-shield]: https://img.shields.io/github/issues/Ethran/notable.svg?style=for-the-badge +[issues-url]: https://github.com/Ethran/notable/issues +[license-shield]: https://img.shields.io/github/license/Ethran/notable.svg?style=for-the-badge + +[license-url]: https://github.com/Ethran/notable/blob/master/LICENSE.txt +[download-shield]: https://img.shields.io/github/v/release/Ethran/notable?style=for-the-badge&label=β¬οΈ%20Download +[download-url]: https://github.com/Ethran/notable/releases/latest +[downloads-shield]: https://img.shields.io/github/downloads/Ethran/notable/total?style=for-the-badge&color=47c219&logo=cloud-download +[downloads-url]: https://github.com/Ethran/notable/releases/latest + +[discord-shield]: https://img.shields.io/badge/Discord-Join%20Chat-7289DA?style=for-the-badge&logo=discord +[discord-url]: https://discord.gg/rvNHgaDmN2 +[kofi-shield]: https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ko--fi-ff5f5f?style=for-the-badge&logo=ko-fi&logoColor=white +[kofi-url]: https://ko-fi.com/rethran + +[sponsor-shield]: https://img.shields.io/badge/Sponsor-GitHub-%23ea4aaa?style=for-the-badge&logo=githubsponsors&logoColor=white +[sponsor-url]: https://github.com/sponsors/rethran -***Important:*** To use your BOOX device for debugging, an application will be required to enable developer mode on your BOOX device. [See a short guide here.](https://imgur.com/a/i1kb2UQ) +[docs-url]: https://github.com/Ethran/notable +[bug-url]: https://github.com/Ethran/notable/issues/new?labels=bug&template=bug-report---.md +[feature-url]: https://github.com/Ethran/notable/issues/new?labels=enhancement&template=feature-request---.md +[bug-shield]: https://img.shields.io/badge/π%20Report%20Bug-red?style=for-the-badge +[feature-shield]: https://img.shields.io/badge/π‘%20Request%20Feature-blueviolet?style=for-the-badge