diff --git a/.github/workflows/release-main.yml b/.github/workflows/release-main.yml new file mode 100644 index 0000000..7657c3d --- /dev/null +++ b/.github/workflows/release-main.yml @@ -0,0 +1,207 @@ +name: Build and Release (main) + +on: + push: + branches: + - main + - feature/sync-conflict-log + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: true + +jobs: + build_flutter: + name: Flutter ${{ matrix.target }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - target: android + os: ubuntu-latest + artifact_name: flutter-android-apk + - target: linux + os: ubuntu-latest + artifact_name: flutter-linux + - target: macos + os: macos-latest + artifact_name: flutter-macos + - target: ios + os: macos-latest + artifact_name: flutter-ios + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java (Android) + if: matrix.target == 'android' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: gradle + + - name: Setup Gradle cache (Android) + if: matrix.target == 'android' + uses: gradle/actions/setup-gradle@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Cache pub dependencies + uses: actions/cache@v4 + with: + path: | + ~/.pub-cache + .dart_tool + key: ${{ runner.os }}-${{ matrix.target }}-pub-${{ hashFiles('pubspec.lock') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.target }}-pub- + ${{ runner.os }}-pub- + + - name: Flutter pub get + run: flutter pub get + + - name: Install Linux desktop build deps + if: matrix.target == 'linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + clang \ + cmake \ + ninja-build \ + pkg-config \ + libgtk-3-dev \ + liblzma-dev \ + libgstreamer1.0-dev \ + libgstreamer-plugins-base1.0-dev + + - name: Enable Linux platform + if: matrix.target == 'linux' + run: | + flutter config --enable-linux-desktop + flutter create --platforms=linux . + + - name: Build Android APK + if: matrix.target == 'android' + run: flutter build apk --release --flavor prod --no-pub + + - name: Build Linux + if: matrix.target == 'linux' + run: flutter build linux --release + + - name: Build macOS + if: matrix.target == 'macos' + run: flutter build macos --release + + - name: Build iOS (no code signing) + if: matrix.target == 'ios' + run: flutter build ios --release --no-codesign + + - name: Prepare artifact bundle + shell: bash + run: | + set -euo pipefail + mkdir -p out + if [ "${{ matrix.target }}" = "android" ]; then + APK_PATH="$(find build/app/outputs/flutter-apk -maxdepth 1 -type f -name '*prod-release.apk' | head -n 1)" + if [ -z "${APK_PATH}" ]; then + echo "Could not find prod release APK in build/app/outputs/flutter-apk" >&2 + ls -la build/app/outputs/flutter-apk || true + exit 1 + fi + cp "${APK_PATH}" out/sketchord-android-prod-release.apk + elif [ "${{ matrix.target }}" = "linux" ]; then + tar -czf out/sketchord-linux-release.tar.gz -C build/linux/x64/release bundle + elif [ "${{ matrix.target }}" = "macos" ]; then + ditto -c -k --sequesterRsrc --keepParent build/macos/Build/Products/Release/sound.app out/sketchord-macos-release.zip + elif [ "${{ matrix.target }}" = "ios" ]; then + ditto -c -k --sequesterRsrc --keepParent build/ios/iphoneos/Runner.app out/sketchord-ios-runner-app-nosign.zip + fi + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact_name }} + path: out/* + if-no-files-found: error + + build_backend_pyinstaller: + name: Backend PyInstaller ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: | + backend/requirements.txt + + - name: Build backend executable + shell: bash + run: | + set -euo pipefail + chmod +x backend/scripts/build_pyinstaller.sh + backend/scripts/build_pyinstaller.sh + + - name: Package backend executable + shell: bash + run: | + set -euo pipefail + mkdir -p out + BIN_NAME="sketchord-sync-backend" + if [ "${{ runner.os }}" = "Windows" ]; then + BIN_NAME="sketchord-sync-backend.exe" + fi + cp "backend/dist/${BIN_NAME}" "out/${BIN_NAME}-${{ runner.os }}" + + - name: Upload backend artifact + uses: actions/upload-artifact@v4 + with: + name: backend-pyinstaller-${{ runner.os }} + path: out/* + if-no-files-found: error + + release: + name: Create Release + runs-on: ubuntu-latest + needs: + - build_flutter + - build_backend_pyinstaller + + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: List artifacts + run: ls -R artifacts + + - name: Publish GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: main-build-${{ github.run_number }} + name: Main build #${{ github.run_number }} + generate_release_notes: true + files: artifacts/**/* diff --git a/.gitignore b/.gitignore index 98e71d9..9cf5c93 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,5 @@ settings.json backend/data/ backend/data.json -android/app/google-services.json \ No newline at end of file +android/app/google-services.json +.gradle/ \ No newline at end of file diff --git a/.metadata b/.metadata index bcef94e..34b9d76 100644 --- a/.metadata +++ b/.metadata @@ -4,7 +4,27 @@ # This file should be version controlled and should not be manually edited. version: - revision: bbfbf1770cca2da7c82e887e4e4af910034800b6 - channel: stable + revision: "9f455d2486bcb28cad87b062475f42edc959f636" + channel: "stable" project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + - platform: macos + create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..c24767c --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "sketchord dev", + "request": "launch", + "type": "dart", + "args": [ + "--flavor", + "dev" + ] + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index caf3b8c..58f90fb 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,18 @@ flutter run --flavor prod flutter run --flavor dev # flutter run --flavor dev -d chrome --web-renderer html -# flutter packages pub run flutter_launcher_icons:main -f pubspec.yaml \ No newline at end of file +# flutter packages pub run flutter_launcher_icons:main -f pubspec.yaml + +# Create Release +1. Change Version +2. Build bundle `flutter build appbundle --flavor prod` + +# CI/CD Release + +A GitHub Actions workflow is defined at `.github/workflows/release-main.yml`. + +On every push to `main`, it: + +1. Builds Flutter artifacts for Android, Linux, macOS, and iOS (`--no-codesign` for iOS). +2. Builds standalone backend binaries with PyInstaller for Linux and macOS. +3. Publishes a GitHub Release named `Main build #` and uploads all artifacts. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..08df032 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,13 @@ +analyzer: + exclude: + - build/** + - lib/audio_list.dart + - lib/audio_list_store.dart + - lib/collection_editor_store.dart + - lib/collections.dart + - lib/collections_store.dart + - lib/menu_store.dart + - lib/note_search_view.dart + - lib/dialogs/add_to_collection_dialog.dart + - lib/dialogs/change_number_dialog.dart + - lib/dialogs/choose_note_dialog.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index 0849074..6d0c393 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,3 +1,9 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'dev.flutter.flutter-gradle-plugin' +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -6,48 +12,33 @@ if (localPropertiesFile.exists()) { } } -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} - -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') ?: '1' +def flutterVersionName = localProperties.getProperty('flutter.versionName') ?: '1.0' android { - compileSdkVersion 28 + namespace 'com.example.sound' + compileSdk flutter.compileSdkVersion + ndkVersion flutter.ndkVersion - sourceSets { - main.java.srcDirs += 'src/main/kotlin' + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 } - lintOptions { - disable 'InvalidPackage' + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "de.onenightproductions.sketchord" - minSdkVersion 16 - targetSdkVersion 28 + minSdk flutter.minSdkVersion + targetSdk flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName } flavorDimensions "app" productFlavors { - dev { dimension "app" resValue "string", "app_name", "SketChord Dev" @@ -64,8 +55,6 @@ android { buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. signingConfig signingConfigs.debug } } @@ -74,7 +63,3 @@ android { flutter { source '../..' } - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..7f16557 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,10 @@ +## Flutter wrapper +-keep class io.flutter.app.** { *; } +-keep class io.flutter.plugin.** { *; } +-keep class io.flutter.util.** { *; } +-keep class io.flutter.view.** { *; } +-keep class io.flutter.** { *; } +-keep class io.flutter.plugins.** { *; } +# -keep class com.google.firebase.** { *; } // uncomment this if you are using firebase in the project +-dontwarn io.flutter.embedding.** +-ignorewarnings \ No newline at end of file diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 693742c..b628ae4 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -1,13 +1,8 @@ - - + - + - - + - - - + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" /> - - + + - - - - - - + + + + - - - - + + + - - - - + + + + - - - - + + + + + + + + diff --git a/android/app/src/main/res/xml/provider_paths.xml b/android/app/src/main/res/xml/provider_paths.xml index 1835a37..42fb503 100644 --- a/android/app/src/main/res/xml/provider_paths.xml +++ b/android/app/src/main/res/xml/provider_paths.xml @@ -1,4 +1,5 @@ + \ No newline at end of file diff --git a/android/app/src/prod/res/mipmap-hdpi/ic_launcher.png b/android/app/src/prod/res/mipmap-hdpi/ic_launcher.png index 6891f38..4425457 100755 Binary files a/android/app/src/prod/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/prod/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/prod/res/mipmap-mdpi/ic_launcher.png b/android/app/src/prod/res/mipmap-mdpi/ic_launcher.png index 65def0e..fbf59d3 100755 Binary files a/android/app/src/prod/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/prod/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/prod/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/prod/res/mipmap-xhdpi/ic_launcher.png index ea1ffe7..753cd06 100755 Binary files a/android/app/src/prod/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/prod/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/prod/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/prod/res/mipmap-xxhdpi/ic_launcher.png index 09f058d..98571ee 100755 Binary files a/android/app/src/prod/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/prod/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/prod/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/prod/res/mipmap-xxxhdpi/ic_launcher.png index d509ed4..5f77e94 100755 Binary files a/android/app/src/prod/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/prod/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml index 0df249b..bbd7ee7 100644 --- a/android/app/src/profile/AndroidManifest.xml +++ b/android/app/src/profile/AndroidManifest.xml @@ -1,7 +1,3 @@ - - + diff --git a/android/build.gradle b/android/build.gradle index 3100ad2..bc157bd 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,20 +1,7 @@ -buildscript { - ext.kotlin_version = '1.3.50' - repositories { - google() - jcenter() - } - - dependencies { - classpath 'com.android.tools.build:gradle:3.5.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - allprojects { repositories { google() - jcenter() + mavenCentral() } } @@ -26,6 +13,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { +tasks.register("clean", Delete) { delete rootProject.buildDir } diff --git a/android/gradle.properties b/android/gradle.properties index 38c8d45..1d21b8c 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,4 @@ -org.gradle.jvmargs=-Xmx1536M -android.enableR8=true +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true +kotlin.jvm.target.validation.mode=warning diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 296b146..ac3b479 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Fri Jun 23 08:50:38 CEST 2017 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 44e62bc..b507b94 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,11 +1,25 @@ -include ':app' +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.9.1" apply false + id "org.jetbrains.kotlin.android" version "2.1.0" apply false +} + +include ":app" diff --git a/assets/fonts/Hind-Bold.ttf b/assets/fonts/Hind-Bold.ttf new file mode 100644 index 0000000..018e32d Binary files /dev/null and b/assets/fonts/Hind-Bold.ttf differ diff --git a/assets/fonts/Hind-Light.ttf b/assets/fonts/Hind-Light.ttf new file mode 100644 index 0000000..20c3281 Binary files /dev/null and b/assets/fonts/Hind-Light.ttf differ diff --git a/assets/fonts/Hind-Medium.ttf b/assets/fonts/Hind-Medium.ttf new file mode 100644 index 0000000..8841a1f Binary files /dev/null and b/assets/fonts/Hind-Medium.ttf differ diff --git a/assets/fonts/Hind-Regular.ttf b/assets/fonts/Hind-Regular.ttf new file mode 100644 index 0000000..410e156 Binary files /dev/null and b/assets/fonts/Hind-Regular.ttf differ diff --git a/assets/fonts/Hind-SemiBold.ttf b/assets/fonts/Hind-SemiBold.ttf new file mode 100644 index 0000000..306f399 Binary files /dev/null and b/assets/fonts/Hind-SemiBold.ttf differ diff --git a/assets/fonts/OpenSans-Bold.ttf b/assets/fonts/OpenSans-Bold.ttf new file mode 100644 index 0000000..efdd5e8 Binary files /dev/null and b/assets/fonts/OpenSans-Bold.ttf differ diff --git a/assets/fonts/OpenSans-Regular.ttf b/assets/fonts/OpenSans-Regular.ttf new file mode 100644 index 0000000..29bfd35 Binary files /dev/null and b/assets/fonts/OpenSans-Regular.ttf differ diff --git a/assets/fonts/RobotoMono.ttf b/assets/fonts/RobotoMono.ttf new file mode 100644 index 0000000..7caacd8 Binary files /dev/null and b/assets/fonts/RobotoMono.ttf differ diff --git a/assets/fonts/arial.ttf b/assets/fonts/arial.ttf new file mode 100644 index 0000000..ad7d8ea Binary files /dev/null and b/assets/fonts/arial.ttf differ diff --git a/assets/initial_data.json b/assets/initial_data.json index 3411bf2..092440f 100644 --- a/assets/initial_data.json +++ b/assets/initial_data.json @@ -1 +1,552 @@ -[{"id": "2621235", "title": "New Until Its Old", "key": null, "tuning": null, "label": "ultimate guitar", "instrument": null, "starred": false, "capo": "3", "sections": [{"id": "34384476-1fe6-4cbc-b79e-4e6ae3cbda88", "title": "Chords", "content": "Notes in parentheses are walking notes.\nhttps://youtu.be/QULo86jLFkA\nDm7 x57x68 (if you are strumming instead of picking as he does you will need to barre this chord x57568)"}, {"id": "cbc82fc0-2def-492e-9242-9c2de8413ff8", "title": "Intro", "content": "C F Dm G Em Am7 F G"}, {"id": "2a931a75-3751-400c-bd88-8e8cad3c3901", "title": "Verse", "content": "C F\nWell the winter brings the snow\n Dm G\nIt's spring before you know\nEm Am\nSummer comes and goes\n F G\nLike a dream that you can't hold\n C F\nAs you gaze upon the sky\n Dm G\nSee the clouds passing by\n Em Am\nThey know as well as you and I\n F Gsus4 G\nEverything\u2019s new until it's old"}, {"id": "cb79f0cd-6f95-4d98-b437-d84ace0895d5", "title": "Chorus", "content": "Am F G\n Perhaps I'd be happy if time stood still\n Em Am\nI know I won't for it never will\n F G (G G#)\nSure as the evening geese take flight\nAm F G\n Silver coins in a Wishing Well\n Em Am\nFinal chimes of a mission bell\n F Gsus4 G\nAnd it is ringing into the night"}, {"id": "cbd2f798-192d-46be-a254-589b9bfbc0f1", "title": "Instrumental Break", "content": "C F Dm G Em Am7 F G"}, {"id": "858a0094-5144-46b0-8f0c-48cf39fe96cf", "title": "Verse", "content": "C F\nWell the morning brings the sun\n Dm G\nBut the rain will surely come\n Em Am\nAnd the afternoon will run\n F Gsus4 G\nInto setting suns of pink and blue\n C F\nAs you gaze upon the moon\n Dm G\nBe it crescent, full, or new\n Em Am\nIt knows as well as me and you\n F G Gsus4 G\nEverything\u2019s new until it\u2019s old"}, {"id": "3e28f80a-e691-4613-bf24-27ba8f76bcbc", "title": "Chorus", "content": "Am F G\n Perhaps I'd be happy if time stood still\n Em Am\nI know I won't for it never will\n F G G G7\nSure as the evening geese take flight\nAm F G\n Silver coins in a wishing well\n Em Am\nFinal chimes of a mission bell\n F G Gsus4 G\nAnd it is ringing into the night"}, {"id": "7cb7e596-689b-498b-878d-73d2205c8e55", "title": "Instrumental Break", "content": "C F Dm7 G (G F E D C B) Am7 F G (G G#)"}, {"id": "8bd9eb54-a83d-46fa-84f8-ddbb6bc9c4dc", "title": "Chorus", "content": "Am C G\n Well I know I'd be happy if time stood still\n Em Am\nBut I won't for it never will\n F G (G F E D C)\nSure as the evening geese take flight\nAm F G\n Silver coins in a wishing well\n Em Am\nFinal chimes of a mission bell\n F G (G G#) Am\nAnd it is ringing into the night"}], "artist": "Passenger"}, {"id": "1180866", "title": "Keep Your Head Up", "key": null, "tuning": null, "label": "ultimate guitar", "instrument": null, "starred": false, "capo": null, "sections": [{"id": "d1ea80b5-b15e-4bb9-a808-a3a4b2996e7c", "title": "Intro", "content": "Either strum or play in the same pattern as the intro throughout, obviously\nwithout the strum at the end.\nAlso keep your 3rd and fourth finger on like this:\ne|----3---|\nB|----3---|\nG|--------|\nD|--------|\nA|--------|\nE|--------|\nfor everything except the chorus, but still keep it on the Em7 chord for the\nchorus, hope that made sense :)\ne|-----------------------------------------------|\nB|-----------3-----------------3-----------3-----|\nG|--------0-----0-----------0-----0--------0-----|\nD|-----2-----------2-----2-----------2-----2-----|\nA|--3-----------------3-----------------3--3--3--|\nE|-----------------------------------------------|"}, {"id": "8b8d29e3-9378-4ba0-8e02-e14a9e86a750", "title": "Verse 1", "content": "C Em7\nI spend my time, watching\n G C\nThe spaces that have grown between us.\n Em7\nAnd I cut my mind on second best,\n G C\nOh the scars that come with the greenness.\n Em7\nAnd I gave my eyes to the boredom,\n G C\nStill the seabed wouldn't let me in.\n Em7\nAnd I try my best to embrace the darkness\nG C Em7\nIn which I swim, oh the darkness... in which I swim.\nC Em7\nNow walking back, down this mountain,\n G C\nThe strength of a turnin' tide.\n Em7\nOh the wind so soft, and my skin,\n G C\nYeah the sun was so hot upon my side.\n Em7\nOh lookin' out at this happiness\n G C\nI searched for between the sheets,\n Em7\nOh feelin' blind, to realize,\nG C\nAll I was searchin' for... was me.\n Em7 G C\nOh oh-oh, all I was searchin' for was me.\nD\nOh yeah"}, {"id": "bd30dce5-6760-4b10-956d-4fd618ebb860", "title": "Chorus", "content": "Em7 D C\nKeep your head up, keep your heart strong no, no, no, no,\nEm7 D C\nKeep your mind set, keep your head whole Oh my, my darlin'.\nEm7 D C\nKeep your head up, keep your heart strong Na, oh, no, no.\nEm7 D C\nKeep your mind set in your ways... And keep your heart strong."}, {"id": "45ab7695-5325-4985-b88d-a64aee8439db", "title": "Verse 2", "content": "C Em7\nI saw a friend of mine, the other day,\n G C\nAnd he told me that my eyes were gleamin'.\n Em7\nI said I'd been away, and he knew\n G C\nOh he knew the depths I was meanin'.\n Em7\nAnd it felt so good to see his face,\n G C\nOh the comfort invested in my soul,\n Em7\nto feel the warmth, of his smile,\n G C\nwhen he said, 'I'm happy to have you home.'\nEm7 G C\nOh oh-oh, I'm happy to have you home.\nD\nOhh"}, {"id": "de7cee9c-7438-4f5f-9e6d-edf32e89f0a2", "title": "Chorus", "content": "Em7 D C\nKeep your head up, keep your heart strong no, no, no, no,\nEm7 D C\nKeep your mind set, keep your hair long Oh my, my darlin'.\nEm7 D C\nKeep your head up, keep your heart strong Na, oh, no, no.\nEm7 D C\nKeep your mind set in your ways... Keep your heart strong."}, {"id": "bfae0577-02cb-45f4-b1d4-00ae24bdf384", "title": "Bridge", "content": "G D C\nAnd I'll always remember you the same.\nG D C\nOh eyes like wildflowers, oh within demons of change.\nD\nOhhh may you find happiness here.\nD\nOhhh may all your hopes all turn out right."}, {"id": "41fc9146-9ca3-446e-9240-35e1326c85af", "title": "Chorus", "content": "Em7 D C\nKeep your head up, keep your heart strong no, no, no, no,\nEm7 D C\nKeep your mind set, keep your hair long Oh my, my darlin'.\nEm7 D C\nKeep your head up, keep your heart strong Na, oh, no, no.\nEm7 D C\nKeep your mind set in your ways... Keep your heart strong."}, {"id": "2842c4f0-fb00-4481-b4a8-1b1621eeca79", "title": "Outro", "content": "G D C\n'Cause I'll always remember you the same.\nG D C\nOh eyes like wildflowers, oh within demons of change."}], "artist": "Ben Howard"}, {"id": "706621", "title": "Slow Dancing In A Burning Room", "key": null, "tuning": null, "label": "ultimate guitar", "instrument": null, "starred": false, "capo": null, "sections": [{"id": "2c28391b-8bb7-487d-81b2-1ae66b214899", "title": "Intro", "content": "John Mayer\n\"Slow Dancing In A Burning Room\"\nTabbed by Dave Ross\n(Chords I use)\n E A D G B E\n - - - - - -\nE- 0 2 2 1 0 0\nC#m7- x 4 6 6 0 0\nAadd9- 5 7 7 6 0 0\nBadd11- 7 9 9 8 0 0\nF#m11- 2 4 4 2 0 0\nC#m7 Aadd9 E \nC#m7 Aadd9 E"}, {"id": "87182e83-7900-4874-a3d7-686c13e5e7de", "title": "Verse", "content": "C#m7\nIt's not a silly little moment\n Aadd9 E\nIt's not the storm before the calm\n C#m7\nThis is the deep and dying breath of\n Aadd9 E\nThis love that we've been working on\n C#m7\nCan't seem to hold you like I want to\n Aadd9 E\nSo I can feel you in my arms\n C#m7\nNobody's gonna come and save you\n Aadd9 E\nWe've pulled too many false alarms"}, {"id": "36e84e71-b282-4c68-b83e-11abafa716e3", "title": "Chorus", "content": "Badd11\nWe're going down\n C#m7 Aadd9\nAnd you can see it too\n Badd11\nWe're going down\n C#m7 F#m11\nAnd you know that we're doomed\n C#m7 Aadd9 E\nMy Dear we're slow dancing in a burning room"}, {"id": "ad9ddf85-b891-4989-ad86-e6b68152472a", "title": "Instrumental", "content": "C#m7 Aadd9 E \nC#m7 Aadd9 E"}, {"id": "8cf1613f-303c-41a8-92e2-c3ea7063469a", "title": "Verse", "content": "C#m7\nI was the one you always dreamed of\n Aadd9 E\nYou were the one I tried to draw\n C#m7\nHow dare you say it's nothing to me\n Aadd9 E\nBaby you're the only light I ever saw\n C#m7\nI'll make the most of all the sadness\n Aadd9 E\nYou'll be a bitch because you can\n C#m7\nYou try to hit me just to hurt me so you leave me feeling dirty\n Aadd9 E\nBecause you can't understand"}, {"id": "2fbd1880-2b8c-4639-a6e0-55414e1649cc", "title": "Chorus", "content": "Badd11\nWe're going down\n C#m7 Aadd9\nAnd you can see it too\n Badd11\nWe're going down\n C#m7 F#m11\nAnd you know that we're doomed\n C#m7 Aadd9 E\nMy Dear we're slow dancing in a burning room"}, {"id": "c4b0a8f9-52d5-4946-a8c7-7b82fff5a1bf", "title": "Instrumental", "content": "C#m7 Aadd9 E \nC#m7 Aadd9 E \nC#m7 Aadd9 E \nC#m7 Aadd9 E"}, {"id": "94f35e39-e264-4945-a94a-9ee9a222906c", "title": "Bridge", "content": "F#m11 C#m7 Badd11 F#m11\nGo Cry about it why don't you (repeat 3 times)\n C#m7 Aadd9 E\nMy Dear we're slow dancing in a burning room"}, {"id": "89d61423-f400-4558-bed7-2fb734cf0828", "title": "Instrumental", "content": "C#m7 Aadd9 E \nC#m7 Aadd9 E \nC#m7 Aadd9 E \nC#m7 Aadd9 E"}, {"id": "f4d12218-e474-4213-b94c-924fb1f97fde", "title": "Outro", "content": "C#m7\nDon't you think we outta know by now\n Aadd9 E\nDon't you think we should've learned somehow\n C#m7\nDon't you think we outta know by now\n Aadd9 E\nDon't you think we should've learned somehow\n C#m7\nDon't you think we outta know by now\n Aadd9 E\nDon't you think we should've learned somehow"}], "artist": "John Mayer"}, {"id": "1729327", "title": "Landslide", "key": null, "tuning": null, "label": "ultimate guitar", "instrument": null, "starred": false, "capo": "3", "sections": [{"id": "ddd7313b-9524-4424-b894-88f0e760f74b", "title": "Verse 1", "content": "*Capo 3*\nBasic picking pattern C G/B Am7 D/F# G G/F# Em7\ne|----------------------| e|-0---3---0---2---3---3----3--|\nB|---------X------------| B|-1---0---1---3---3---3----3--|\nG|-----X-----X---X------| G|-0---0---0---2---0---0----0--|\nD|-------X-----X--------| D|-2---2---2---0---0---0----2--|\nA|-X--------------------| A|-3---2---0---0---2---2----2--|\nE|----------------------| E|-X---X---X---2---3---2----0--|\nI play CM7/B instead of G/B. I play the F# note in the D/F# with my thumb.\nI prefer to play 7th chords in the chorus to add distinction, and I love G's.\nC G/B Am7 G/B\n I took my love and I took it down\nC G/B Am7 G/B\n I climbed a mountain and I turned around\n C G/B Am7 G/B\nAnd I saw my reflection in the snow covered hills\n C G/B Am7 G/B\nUntil the landslide brought me down"}, {"id": "d28d6a81-d8fb-4ef8-bb2c-7105b603d87b", "title": "Verse 2", "content": "C G/B Am7 G/B\nOh, mirror in the sky what is love\n C G/B Am7 G/B\nCan the child within my heart rise above\n C G/B Am7 G/B\nCan I sail through the changing ocean tides\n C G/B Am7 G/B\nCan I handle the seasons of my life\nC G/B Am7 G/B\n mmm... I don't know\nC G/B Am7 D/F#"}, {"id": "06da1398-d40e-4669-ac47-8341a0daf861", "title": "Chorus", "content": "G G/F# Em7\nWell, I've been afraid of changing cause I\nC G/B Am7 D/F#\nbuilt my life around you\n G G/F# Em7\nBut time makes you bolder, children get older\n C G/B Am7 G/B\nand I'm getting older too\nC G/B Am7 G/B (repeat as necessary)\n (insert optional solo here)\nC G/B Am7 D/F#\n (as solo finishes)\n G G/F# Em7\nWell, I've been afraid of changing cause I\nC G/B Am7 D/F#\nbuilt my life around you\n G G/F# Em7\nBut time makes you bolder, children get older\n C G/B Am7 G/B\nand I'm getting older too\n C G/B Am7 G/B\nOh, I'm getting older too Ah"}, {"id": "77d2b266-56cb-4685-946c-c0e66c3c6ddc", "title": "Verse 3", "content": "C G/B Am7 G/B\n Ah Take my love and take it down\nC G/B Am7 G/B\n Ah If you climb a mountain and you turn around\n C G/B Am7 G/B\nAnd if you see my reflection in the snow covered hills\n C G/B Am7 G/B\nthen a landslide will bring you down\n C G/B Am7 G/B\nAnd if you see my relfection in the snow covered hills\nWell...\n C G/B Am7 G/B\nA landslide will bring you down. Ohhhh\n C G/B Am7\nWell well, a landslide bring you down"}], "artist": "Fleetwood Mac"}, {"id": "13586", "title": "Go Your Own Way", "key": null, "tuning": null, "label": "ultimate guitar", "instrument": null, "starred": false, "capo": null, "sections": [{"id": "1c727883-07af-479d-9e11-87b1bce6092d", "title": "Intro", "content": "Go Your Own Way chords \nFleetwood Mac\nF"}, {"id": "9488faa7-ef3d-4efb-bb6a-33a93132a9ec", "title": "Verse", "content": "F C Bb\nLoving you, isn t the right thing to do?\nBb F \nHow can I, ever change things that I feel\nF C Bb\nIf I could, maybe I d give you my world\nBb F\nHow can I, when you wont take it from me?"}, {"id": "ee72e80e-ca99-44c5-879f-6bf272f9d9f7", "title": "Chorus", "content": "Dm Bb C \nYou can go your own way, Go your own way\nDm Bb C \nYou can call it Another lonely day\nDm Bb C \nYou can go your own way, Go your own way"}, {"id": "4b157fa7-c7fa-47bb-a8a0-b0ecaac29bec", "title": "Verse", "content": "F C Bb\nTell me why, everything turned around?\nBb F\nPacking up, shacking up is all you wanna do\nF C Bb\nIf I could, baby Id give you my world\nBb F\nOpen up, everything s waiting for you"}, {"id": "15c35788-e574-4414-8b0d-d795ab13983e", "title": "Chorus", "content": "Dm Bb C \nYou can go your own way, Go your own way\nDm Bb C \nYou can call it Another lonely day\nDm Bb C \nYou can go your own way, Go your own way"}, {"id": "6ecd832e-1bf3-4b23-a20e-fbc5d69c835b", "title": "Break 1", "content": "F C Bb F C Bb F C Bb F C Bb F"}, {"id": "dbae4324-ea23-4f54-883e-7ab65dc84c74", "title": "Chorus", "content": "Dm Bb C \nYou can go your own way, Go your own way\nDm Bb C \nYou can call it Another lonely day\nDm Bb C \nYou can go your own way, Go your own way"}, {"id": "581dbc5d-1a15-49e5-bb3c-b7947d707f55", "title": "Solo", "content": "(over and over during guitar solo)\nDm Bb C"}, {"id": "874c824e-d5af-4968-8bf3-7d790239533f", "title": "Chorus", "content": "Dm Bb C \nYou can go your own way, Go your own way\nDm Bb C \nYou can call it Another lonely day\nDm Bb C \nYou can go your own way, Go your own way\n(Fade out on chorus)"}], "artist": "Fleetwood Mac"}, {"id": "146744", "title": "Boulevard Of Broken Dreams", "key": null, "tuning": null, "label": "ultimate guitar", "instrument": null, "starred": false, "capo": "1", "sections": [{"id": "d7e99808-eb26-4723-af95-1ca5130e583a", "title": "Verse 1", "content": "Em G D A Em\n I walk a lonely road, the only one that I have ever known\n G D A Em\nDon't know where it goes, but it's home to me and I walk alone"}, {"id": "5b4a81b4-1987-464c-94cf-d52969869360", "title": "Interlude", "content": "(Em) G D A"}, {"id": "2a30ebcd-47ff-48d6-98d0-a27599c4776a", "title": "Verse 2", "content": "Em G D A Em\n I walk this empty street, on the boulevard of broken dreams\n G D A Em\nWhere the city sleeps, and I'm the only one and I walk alone"}, {"id": "7fbb6feb-7877-4e9c-ad92-f59aa1387f88", "title": "Interlude", "content": "(Em) G D A Em\n I walk alone, I walk alone\n(Em) G D A\n I walk alone, I walk a...."}, {"id": "71a8a6ea-99ac-43c4-9989-0736d2952ef5", "title": "Chorus", "content": "C G D Em\n My shadow's the only one that walks beside me\nC G D Em\n My shallow heart's the only thing that's beating\nC G D Em\n Sometimes I wish someone out there will find me\nC G B7\n Till then I walk alone"}, {"id": "1aa72ddd-c6e9-48b0-85d0-bc219afad596", "title": "Interlude", "content": "Em G D A\nAh-Ah Ah-Ah Ah-Ah Ahhh-Ah\n Em G D A\nhaaa-ah Ah-Ah Ah-Ah Ah-Ah"}, {"id": "493ea979-b4b2-440c-8bec-7a110fe20e09", "title": "Verse 3", "content": "Em G\n I'm walking down the line\nD A Em\nThat divides me somewhere in my mind\n G D\nOn the border line of the edge\n A Em\nAnd where I walk alone"}, {"id": "fe5a5f90-a7df-4055-ad68-528646f89786", "title": "Interlude", "content": "(Em) G D A"}, {"id": "fae6276f-a646-487d-8d6b-a0621651c1e3", "title": "Verse 4", "content": "Em G\n Read between the lines\nD A Em\nWhat's fucked up and everything's alright\n G D A\nCheck my vital signs, to know I'm still alive\n Em\nAnd I walk alone"}, {"id": "32de7ebb-5253-4ee8-b895-64101195e817", "title": "Interlude", "content": "(Em) G D A Em\n I walk alone, I walk alone\n(Em) G D A\n I walk alone, I walk a...."}, {"id": "438ec43f-cf25-44d8-945d-e32f29f70291", "title": "Chorus", "content": "C G D Em\n My shadow's the only one that walks beside me\nC G D Em\n My shallow heart's the only thing that's beating\nC G D Em\n Sometimes I wish someone out there will find me\nC G B7\n Till then I walk alone"}, {"id": "442decb3-6ad0-41f6-87fb-ca1f1d17effb", "title": "Interlude", "content": "Em G D A\nAh-Ah Ah-Ah Ah-Ah Ahhh-Ah\n Em G D A\nhaaa-ah Ah-Ah Ah-Ah I walk alone, I walk a..."}, {"id": "16adf87d-8f92-4774-97d7-87ddb673c212", "title": "Solo", "content": "C G D Em\nC G D Em\nC G D Em\nC G B B7"}, {"id": "b8b77deb-4381-449f-8eac-6d03a1b97ac1", "title": "Verse 5", "content": "Em G D A Em\nI walk this empty street, on the boulevard of broken dreams\n G D A\nWhere the city sleeps, and I'm the only one and I walk a..."}, {"id": "7445f734-7393-444c-9fbe-3660a7d46d83", "title": "Chorus", "content": "C G D Em\n My shadow's the only one that walks beside me\nC G D Em\n My shallow heart's the only thing that's beating\nC G D Em\n Sometimes I wish someone out there will find me\nC G B\n Till then I walk alone"}, {"id": "fa06adc5-271f-4770-9d1f-833b2d848bf0", "title": "Outro", "content": "Em C D A/C# G D#5\nEm C D A/C# G D#5\nEm C D A/C# G D#5\nEm C D A/C# G D#5"}], "artist": "Green Day"}, {"id": "2475920", "title": "Guiding Light", "key": null, "tuning": null, "label": "ultimate guitar", "instrument": null, "starred": false, "capo": null, "sections": [{"id": "72d28039-9356-4c97-9ff8-76af6ddf5fae", "title": "Intro", "content": "G / / / / / /"}, {"id": "203a9f05-20e6-48a9-ba41-e78573928922", "title": "Verse", "content": "G D/G\nAll day permanent red,\n C/G G\nthe glaze on my eyes.\n D/G\nWhen I heard your voice,\n C/G G\nthe distance caught me by surprise again.\n D/G C/G G\nAnd I know you claim that you're alright;\n C G\nbut fix your eyes on me,\n C\nI guess I'm all you have\n G D\nand I swear you'll see the dawn again."}, {"id": "d295d238-efad-42f7-bc11-256e454ff3c6", "title": "Chorus", "content": "C G\nWell I know I had it all on the line,\n Em D\nbut don't just sit with folded hands and become blind.\n Am G\n'Cause even when there is no star in sight,\nEm D\nyou'll always be my only guiding light."}, {"id": "8d611dda-b47a-45a1-afd5-96f6efb437ee", "title": "Verse", "content": "G D/G\nRelate to my youth,\n C/G G\nwell I'm still in awe of you.\n D/G\nDiscover some new truth,\n C/G G\nand that was always wrapped around you.\nG D/G C/G G\nDon't just slip away in the night,\nG D/G C/G G\ndont just hurl your words from on high."}, {"id": "3cc292bc-8371-41d1-8def-c723335d0889", "title": "Chorus", "content": "C G\nWell I know I had it all on the line,\n Em D\nbut don't just sit with folded hands and become blind.\n Am G\n'Cause even when there is no star in sight,\n Em D\nyou'll always be my only guiding light."}, {"id": "5f244384-1077-4e99-a5fd-bc55cba8f03b", "title": "Bridge", "content": "Am G\n If we come back and were broken,\nAm Em\n unworthy and ashamed,\nAm G\n give us something to believe in,\nAm D / / / Am / / / G / / / Em / / / D / / /\n and you know well go your way"}, {"id": "5c91b417-e8d6-48f6-9f80-97e20206f2f9", "title": "Chorus", "content": "C G\nWell I know I had it all on the line,\n Em D\nbut don't just sit with folded hands and become blind.\n Am G\n'Cause even when there is no star in sight,\n Em D\nyou'll always be my only guiding light. [Repeat Chorus - resolve to G]"}], "artist": "Mumford & Sons"}, {"id": "709013", "title": "Mamma Mia", "key": null, "tuning": null, "label": "ultimate guitar", "instrument": null, "starred": false, "capo": null, "sections": [{"id": "e48f1d87-6c81-4792-a472-3373e0182bf1", "title": "Verse 1", "content": "D G\nI've been cheated by you since I don't know when\nD G\nSo I made up my mind, it must come to an end\nD5 D+5\nLook at me now, will I ever learn?\nD6 D7 G\nI don't know how but I suddenly lose control\nG A\nThere's a fire within my soul"}, {"id": "df43a50b-c991-4a9a-8883-fca00c4b9f61", "title": "Pre-Chorus", "content": "G D A\nJust one look and I can hear a bell ring\nG D A\nOne more look and I forget everything, o-o-o-oh"}, {"id": "43c59456-2235-4289-8e9f-78575e17b113", "title": "Chorus", "content": "D\nMamma mia, here I go again\nG\nMy my, how can I resist you?\nD\nMamma mia, does it show again?\nG\nMy my, just how much I've missed you\nD A\nYes, I've been brokenhearted\nBm F#m\nBlue since the day we parted\nG A\nWhy, why did I ever let you go?\nD Bm\nMamma mia, now I really know,\nG A\nMy my, I could never let you go."}, {"id": "b8d99912-e4c2-482f-92e5-06242c352dbf", "title": "Verse 2", "content": "D G\nI've been angry and sad about things that you do\nD G\nI can't count all the times that I've told you \"we're through\"\nD5 D+5\nAnd when you go, when you slam the door\nD6 D7 G\nI think you know that you won't be away too long\nG A\nYou know that I'm not that strong"}, {"id": "6bf2e85c-bbb0-42b7-8a9b-362c4490a84e", "title": "Pre-Chorus", "content": "G D A\nJust one look and I can hear a bell ring\nG D A\nOne more look and I forget everything"}, {"id": "d12a28c9-bbea-4001-9306-79bfbc71e2c9", "title": "Chorus", "content": "D\nMamma mia, here I go again\nG\nMy my, how can I resist you?\nD\nMamma mia, does it show again\nG\nMy my, just how much I've missed you?\nD A\nYes, I've been brokenhearted\nBm F#m\nBlue since the day we parted\nG A\nWhy, why did I ever let you go?\nD Bm\nMamma mia, even if I say\nG A\n\"Bye bye, leave me now or never\"\nD\nMamma mia, it's a game we play\nG\n\"Bye bye\" doesn't mean forever\nD\nMamma mia, here I go again\nG\nMy my, how can I resist you?\nD\nMamma mia, does it show again\nG\nMy my, just how much I've missed you?\nD A\nYes, I've been brokenhearted\nBm F#m\nBlue since the day we parted\nG A\nWhy, why did I ever let you go?\nD Bm\nMamma mia, now I really know\nG A\nMy my, I could never let you go"}, {"id": "4d4e827e-2360-4c23-a7ed-fbb56aee1d37", "title": "Outro", "content": "D G D G"}], "artist": "ABBA"}, {"id": "1994861", "title": "Into The Sun", "key": null, "tuning": null, "label": "ultimate guitar", "instrument": null, "starred": false, "capo": null, "sections": [{"id": "2fe6bd9e-aa7b-4d7a-b5fd-6560a5215104", "title": "Intro", "content": "E F# E F#"}, {"id": "bc5c2b08-6188-4a95-b197-73219dad799b", "title": "Verse", "content": "C#m E \nStealing Glances at the pavement\nB F#\nThe weight it comes too soon\nC#m E\nSupposed to keep on rolling\nB F#\nBut the race is nothing new\nC#m \nAs the train, it starts to go,\nE\nAnd it takes our bodies slow\nB F#\nAnd I know you wanted to for some time now\nC#m\nAll this time you're gone\nE\nIn your wake I stumble on\nB F#\nBut the smoke is nothing that I haven't seen\nE G#m F#\nSo I walk into the sun\nB E\nI thought you'd be there\nE F#\nBut you could fool anyone\nE G#m F#\nIn the red water dust\n B E\nWill I see you soon,\n G#m F#\nOr did we move on?\nC#m E B F#\nOooooohh\nC#m E\nThe crowd begins to break up\nB F#\nThey're calling their goodbyes\nC#m E\nMy head's above the water\nB F#\nBut I'm drowning in your eyes\nE G#m F#\nSo I walk into the sun\nB E\nI thought you'd be there\nE F#\nBut you could fool anyone\nE G#m F#\nGot a head full of dust\n B E\nWill I see you soon,\n G#m F#\nOr did we move on?"}, {"id": "e3832b3a-4db9-4d4c-bf7d-cc71f6a7e508", "title": "Outro", "content": "E\nWell the race is long, you can't relax\nF#\nAnd I don't belong so I'm headed back\nE\nIt's getting hard, you feel the fear\nF#\nI'm seeing red, wish you were here\nE G#m F#\nAnd I walk into the sun\nB E\nI thought you'd be there\nE F#\nBut you could fool anyone\nE G#m F#\nGot a head full of dust\n B E\nWill I see you soon,\n G#m F#\nOr did we move on?\nF# E\nWill I see you soon,\n G#m F#\nOr did we move on?\nB E\nWill I see you soon,\nG#m\nOr do we move on?"}], "artist": "Sons Of The East"}, {"id": "1196622", "title": "Lifes For The Living", "key": null, "tuning": null, "label": "ultimate guitar", "instrument": null, "starred": false, "capo": "6", "sections": [{"id": "2c5227ed-9937-4d2d-a464-58f6b20d5b41", "title": "Intro", "content": "Chords:\nEm7 020030\nGadd4 320013\nFmaj7 103210\nAm Dm C G C Em7 Am Dm C G Gadd4 G"}, {"id": "955ea37d-8da7-4c47-8cf5-f779585b55fc", "title": "Verse", "content": "Am Fmaj7\nWell grey clouds wraped round the town like elastic\n G C Em7\nCars stood like toys made of Taiwanese plastic\n Am Fmaj7 G Gadd4 G\nThe boy laughed at the spastic dancing around in the rain\n Am Fmaj7\nWhile laundrettes cleaned clothes, high heals rub toes\n G C Em7\nPuddles splashed huddles of bus stop crows\n Am Fmaj7 G Gadd4 G\nDressed in their suits and their boots well they all look the same"}, {"id": "cc959f85-8fcd-4bf8-8a40-742eb5cad20f", "title": "Bridge", "content": "F G C Em7 Am G\nI took myself down to the cafe to find all the boys lost in books and crackling vinyl\n F G C C7\nAnd carved out a poem above the urinal that read"}, {"id": "97f44565-f6d6-4244-99d2-fe142f3312dc", "title": "Chorus", "content": "F \nDon't you cry for the lost\n G \nSmile for the living\n C Em7 Am G\nGet what you need and give what you're given\n F G\nLife's for the living so live it\n Am\nOr you're better off dead\nDm C G C Em7 Am Dm C G Gadd4 G"}, {"id": "8560ae5e-bee8-446b-bcb3-1f38ca349f56", "title": "Verse", "content": "Am Fmaj7\nWhile the evening pulled the moon out of it's packet\n G C Em7\nStars shone like buttons on an old man's jacket\n Am Fmaj7 G Gadd4 G\nWe needed a nail but we tacked it 'til it fell of the wall\n Am Fmaj7\nWhile pigeon's pecked trains, sparks flew like planes\n G C Em7\nThe rain showed the rainbows in the oil stains\n Am Fmaj7 G Gadd4 G\nAnd we all had new iPhones but no one had no one to call"}, {"id": "803f72e9-5353-41fc-baec-b14a09d128be", "title": "Bridge", "content": "F G \nAnd I stumbled down to the stomach of the town\n C Em7 Am G\nWhere the widow takes memories to slowly drown\n F G C C7\nWith a hand to the sky and a mist in her eye she said"}, {"id": "f5113fca-b634-4bf8-bed4-45ae917b2b5e", "title": "Chorus", "content": "F \nDon't you cry for the lost\n G \nSmile for the living\n C Em7 Am G\nGet what you need and give what you're given\n F G\nLife's for the living so live it\n Am\nOr you're better off dead\n \nEm F C Em7 Am Em G Gadd4 G"}, {"id": "e7b094b5-476b-4f35-8cbe-471bd7f6a421", "title": "Verse", "content": "Am Fmaj7\nWell I'm sick of this town, this blind man's forage\n G C Em7\nThey take your dreams down and stick them in storage\n Am Fmaj7 G Gadd4 G\nYou can have them back son when you've paid off your mortgage and loans\n Am Fmaj7\nOh hell with this place, I'll go it my own way\n G C Em7\nI'll stick out my thumb and I trudge down the highway\n Am Fmaj7 G Gadd4 G\nSomeday someone must be going my way home"}, {"id": "f6089c87-2b83-4b80-8323-8def96841aba", "title": "Bridge", "content": "F G\nTill then I'll make my bed from a disused car\n C Em7 Am G\nWith a mattress of leaves and a blanket of stars\n F G C C7\nAnd I'll stitch the words into my heart with a needle and thread"}, {"id": "7efd10ec-4e2c-4219-870f-22c62247701d", "title": "Chorus", "content": "F \nDon't you cry for the lost\n G \nSmile for the living\n C Em7 Am G\nGet what you need and give what you're given\n F G\nYou know life's for the living so live it\n C C7\nOr you're better off dead"}, {"id": "bcae63d9-97b7-4e05-9c15-f20d95c4bee2", "title": "Outro", "content": "F \nDon't you cry for the lost\n G \nSmile for the living\n C Em7 Am G\nGet what you need and give what you're given\n F G\nLife's for the living so live it \n \nOr you're better off dead"}], "artist": "Passenger"}] \ No newline at end of file +[ + { + "id": "2621235", + "title": "New Until Its Old", + "key": null, + "tuning": null, + "label": "ultimate guitar", + "instrument": null, + "starred": false, + "capo": "3", + "sections": [ + { + "id": "34384476-1fe6-4cbc-b79e-4e6ae3cbda88", + "title": "Chords", + "content": "Notes in parentheses are walking notes.\nhttps://youtu.be/QULo86jLFkA\nDm7 x57x68 (if you are strumming instead of picking as he does you will need to barre this chord x57568)" + }, + { + "id": "cbc82fc0-2def-492e-9242-9c2de8413ff8", + "title": "Intro", + "content": "C F Dm G Em Am7 F G" + }, + { + "id": "2a931a75-3751-400c-bd88-8e8cad3c3901", + "title": "Verse", + "content": "C F\nWell the winter brings the snow\n Dm G\nIt's spring before you know\nEm Am\nSummer comes and goes\n F G\nLike a dream that you can't hold\n C F\nAs you gaze upon the sky\n Dm G\nSee the clouds passing by\n Em Am\nThey know as well as you and I\n F Gsus4 G\nEverything\u2019s new until it's old" + }, + { + "id": "cb79f0cd-6f95-4d98-b437-d84ace0895d5", + "title": "Chorus", + "content": "Am F G\n Perhaps I'd be happy if time stood still\n Em Am\nI know I won't for it never will\n F G (G G#)\nSure as the evening geese take flight\nAm F G\n Silver coins in a Wishing Well\n Em Am\nFinal chimes of a mission bell\n F Gsus4 G\nAnd it is ringing into the night" + }, + { + "id": "cbd2f798-192d-46be-a254-589b9bfbc0f1", + "title": "Instrumental Break", + "content": "C F Dm G Em Am7 F G" + }, + { + "id": "858a0094-5144-46b0-8f0c-48cf39fe96cf", + "title": "Verse", + "content": "C F\nWell the morning brings the sun\n Dm G\nBut the rain will surely come\n Em Am\nAnd the afternoon will run\n F Gsus4 G\nInto setting suns of pink and blue\n C F\nAs you gaze upon the moon\n Dm G\nBe it crescent, full, or new\n Em Am\nIt knows as well as me and you\n F G Gsus4 G\nEverything\u2019s new until it\u2019s old" + }, + { + "id": "3e28f80a-e691-4613-bf24-27ba8f76bcbc", + "title": "Chorus", + "content": "Am F G\n Perhaps I'd be happy if time stood still\n Em Am\nI know I won't for it never will\n F G G G7\nSure as the evening geese take flight\nAm F G\n Silver coins in a wishing well\n Em Am\nFinal chimes of a mission bell\n F G Gsus4 G\nAnd it is ringing into the night" + }, + { + "id": "7cb7e596-689b-498b-878d-73d2205c8e55", + "title": "Instrumental Break", + "content": "C F Dm7 G (G F E D C B) Am7 F G (G G#)" + }, + { + "id": "8bd9eb54-a83d-46fa-84f8-ddbb6bc9c4dc", + "title": "Chorus", + "content": "Am C G\n Well I know I'd be happy if time stood still\n Em Am\nBut I won't for it never will\n F G (G F E D C)\nSure as the evening geese take flight\nAm F G\n Silver coins in a wishing well\n Em Am\nFinal chimes of a mission bell\n F G (G G#) Am\nAnd it is ringing into the night" + } + ], + "artist": "Passenger" + }, + { + "id": "1180866", + "title": "Keep Your Head Up", + "key": null, + "tuning": null, + "label": "ultimate guitar", + "instrument": null, + "starred": false, + "capo": null, + "sections": [ + { + "id": "d1ea80b5-b15e-4bb9-a808-a3a4b2996e7c", + "title": "Intro", + "content": "Either strum or play in the same pattern as the intro throughout, obviously\nwithout the strum at the end.\nAlso keep your 3rd and fourth finger on like this:\ne|----3---|\nB|----3---|\nG|--------|\nD|--------|\nA|--------|\nE|--------|\nfor everything except the chorus, but still keep it on the Em7 chord for the\nchorus, hope that made sense :)\ne|-----------------------------------------------|\nB|-----------3-----------------3-----------3-----|\nG|--------0-----0-----------0-----0--------0-----|\nD|-----2-----------2-----2-----------2-----2-----|\nA|--3-----------------3-----------------3--3--3--|\nE|-----------------------------------------------|" + }, + { + "id": "8b8d29e3-9378-4ba0-8e02-e14a9e86a750", + "title": "Verse 1", + "content": "C Em7\nI spend my time, watching\n G C\nThe spaces that have grown between us.\n Em7\nAnd I cut my mind on second best,\n G C\nOh the scars that come with the greenness.\n Em7\nAnd I gave my eyes to the boredom,\n G C\nStill the seabed wouldn't let me in.\n Em7\nAnd I try my best to embrace the darkness\nG C Em7\nIn which I swim, oh the darkness... in which I swim.\nC Em7\nNow walking back, down this mountain,\n G C\nThe strength of a turnin' tide.\n Em7\nOh the wind so soft, and my skin,\n G C\nYeah the sun was so hot upon my side.\n Em7\nOh lookin' out at this happiness\n G C\nI searched for between the sheets,\n Em7\nOh feelin' blind, to realize,\nG C\nAll I was searchin' for... was me.\n Em7 G C\nOh oh-oh, all I was searchin' for was me.\nD\nOh yeah" + }, + { + "id": "bd30dce5-6760-4b10-956d-4fd618ebb860", + "title": "Chorus", + "content": "Em7 D C\nKeep your head up, keep your heart strong no, no, no, no,\nEm7 D C\nKeep your mind set, keep your head whole Oh my, my darlin'.\nEm7 D C\nKeep your head up, keep your heart strong Na, oh, no, no.\nEm7 D C\nKeep your mind set in your ways... And keep your heart strong." + }, + { + "id": "45ab7695-5325-4985-b88d-a64aee8439db", + "title": "Verse 2", + "content": "C Em7\nI saw a friend of mine, the other day,\n G C\nAnd he told me that my eyes were gleamin'.\n Em7\nI said I'd been away, and he knew\n G C\nOh he knew the depths I was meanin'.\n Em7\nAnd it felt so good to see his face,\n G C\nOh the comfort invested in my soul,\n Em7\nto feel the warmth, of his smile,\n G C\nwhen he said, 'I'm happy to have you home.'\nEm7 G C\nOh oh-oh, I'm happy to have you home.\nD\nOhh" + }, + { + "id": "de7cee9c-7438-4f5f-9e6d-edf32e89f0a2", + "title": "Chorus", + "content": "Em7 D C\nKeep your head up, keep your heart strong no, no, no, no,\nEm7 D C\nKeep your mind set, keep your hair long Oh my, my darlin'.\nEm7 D C\nKeep your head up, keep your heart strong Na, oh, no, no.\nEm7 D C\nKeep your mind set in your ways... Keep your heart strong." + }, + { + "id": "bfae0577-02cb-45f4-b1d4-00ae24bdf384", + "title": "Bridge", + "content": "G D C\nAnd I'll always remember you the same.\nG D C\nOh eyes like wildflowers, oh within demons of change.\nD\nOhhh may you find happiness here.\nD\nOhhh may all your hopes all turn out right." + }, + { + "id": "41fc9146-9ca3-446e-9240-35e1326c85af", + "title": "Chorus", + "content": "Em7 D C\nKeep your head up, keep your heart strong no, no, no, no,\nEm7 D C\nKeep your mind set, keep your hair long Oh my, my darlin'.\nEm7 D C\nKeep your head up, keep your heart strong Na, oh, no, no.\nEm7 D C\nKeep your mind set in your ways... Keep your heart strong." + }, + { + "id": "2842c4f0-fb00-4481-b4a8-1b1621eeca79", + "title": "Outro", + "content": "G D C\n'Cause I'll always remember you the same.\nG D C\nOh eyes like wildflowers, oh within demons of change." + } + ], + "artist": "Ben Howard" + }, + { + "id": "706621", + "title": "Slow Dancing In A Burning Room", + "key": null, + "tuning": null, + "label": "ultimate guitar", + "instrument": null, + "starred": false, + "capo": null, + "sections": [ + { + "id": "2c28391b-8bb7-487d-81b2-1ae66b214899", + "title": "Intro", + "content": "John Mayer\n\"Slow Dancing In A Burning Room\"\nTabbed by Dave Ross\n(Chords I use)\n E A D G B E\n - - - - - -\nE- 0 2 2 1 0 0\nC#m7- x 4 6 6 0 0\nAadd9- 5 7 7 6 0 0\nBadd11- 7 9 9 8 0 0\nF#m11- 2 4 4 2 0 0\nC#m7 Aadd9 E \nC#m7 Aadd9 E" + }, + { + "id": "87182e83-7900-4874-a3d7-686c13e5e7de", + "title": "Verse", + "content": "C#m7\nIt's not a silly little moment\n Aadd9 E\nIt's not the storm before the calm\n C#m7\nThis is the deep and dying breath of\n Aadd9 E\nThis love that we've been working on\n C#m7\nCan't seem to hold you like I want to\n Aadd9 E\nSo I can feel you in my arms\n C#m7\nNobody's gonna come and save you\n Aadd9 E\nWe've pulled too many false alarms" + }, + { + "id": "36e84e71-b282-4c68-b83e-11abafa716e3", + "title": "Chorus", + "content": "Badd11\nWe're going down\n C#m7 Aadd9\nAnd you can see it too\n Badd11\nWe're going down\n C#m7 F#m11\nAnd you know that we're doomed\n C#m7 Aadd9 E\nMy Dear we're slow dancing in a burning room" + }, + { + "id": "ad9ddf85-b891-4989-ad86-e6b68152472a", + "title": "Instrumental", + "content": "C#m7 Aadd9 E \nC#m7 Aadd9 E" + }, + { + "id": "8cf1613f-303c-41a8-92e2-c3ea7063469a", + "title": "Verse", + "content": "C#m7\nI was the one you always dreamed of\n Aadd9 E\nYou were the one I tried to draw\n C#m7\nHow dare you say it's nothing to me\n Aadd9 E\nBaby you're the only light I ever saw\n C#m7\nI'll make the most of all the sadness\n Aadd9 E\nYou'll be a bitch because you can\n C#m7\nYou try to hit me just to hurt me so you leave me feeling dirty\n Aadd9 E\nBecause you can't understand" + }, + { + "id": "2fbd1880-2b8c-4639-a6e0-55414e1649cc", + "title": "Chorus", + "content": "Badd11\nWe're going down\n C#m7 Aadd9\nAnd you can see it too\n Badd11\nWe're going down\n C#m7 F#m11\nAnd you know that we're doomed\n C#m7 Aadd9 E\nMy Dear we're slow dancing in a burning room" + }, + { + "id": "c4b0a8f9-52d5-4946-a8c7-7b82fff5a1bf", + "title": "Instrumental", + "content": "C#m7 Aadd9 E \nC#m7 Aadd9 E \nC#m7 Aadd9 E \nC#m7 Aadd9 E" + }, + { + "id": "94f35e39-e264-4945-a94a-9ee9a222906c", + "title": "Bridge", + "content": "F#m11 C#m7 Badd11 F#m11\nGo Cry about it why don't you (repeat 3 times)\n C#m7 Aadd9 E\nMy Dear we're slow dancing in a burning room" + }, + { + "id": "89d61423-f400-4558-bed7-2fb734cf0828", + "title": "Instrumental", + "content": "C#m7 Aadd9 E \nC#m7 Aadd9 E \nC#m7 Aadd9 E \nC#m7 Aadd9 E" + }, + { + "id": "f4d12218-e474-4213-b94c-924fb1f97fde", + "title": "Outro", + "content": "C#m7\nDon't you think we outta know by now\n Aadd9 E\nDon't you think we should've learned somehow\n C#m7\nDon't you think we outta know by now\n Aadd9 E\nDon't you think we should've learned somehow\n C#m7\nDon't you think we outta know by now\n Aadd9 E\nDon't you think we should've learned somehow" + } + ], + "artist": "John Mayer" + }, + { + "id": "1729327", + "title": "Landslide", + "key": null, + "tuning": null, + "label": "ultimate guitar", + "instrument": null, + "starred": false, + "capo": "3", + "sections": [ + { + "id": "ddd7313b-9524-4424-b894-88f0e760f74b", + "title": "Verse 1", + "content": "*Capo 3*\nBasic picking pattern C G/B Am7 D/F# G G/F# Em7\ne|----------------------| e|-0---3---0---2---3---3----3--|\nB|---------X------------| B|-1---0---1---3---3---3----3--|\nG|-----X-----X---X------| G|-0---0---0---2---0---0----0--|\nD|-------X-----X--------| D|-2---2---2---0---0---0----2--|\nA|-X--------------------| A|-3---2---0---0---2---2----2--|\nE|----------------------| E|-X---X---X---2---3---2----0--|\nI play CM7/B instead of G/B. I play the F# note in the D/F# with my thumb.\nI prefer to play 7th chords in the chorus to add distinction, and I love G's.\nC G/B Am7 G/B\n I took my love and I took it down\nC G/B Am7 G/B\n I climbed a mountain and I turned around\n C G/B Am7 G/B\nAnd I saw my reflection in the snow covered hills\n C G/B Am7 G/B\nUntil the landslide brought me down" + }, + { + "id": "d28d6a81-d8fb-4ef8-bb2c-7105b603d87b", + "title": "Verse 2", + "content": "C G/B Am7 G/B\nOh, mirror in the sky what is love\n C G/B Am7 G/B\nCan the child within my heart rise above\n C G/B Am7 G/B\nCan I sail through the changing ocean tides\n C G/B Am7 G/B\nCan I handle the seasons of my life\nC G/B Am7 G/B\n mmm... I don't know\nC G/B Am7 D/F#" + }, + { + "id": "06da1398-d40e-4669-ac47-8341a0daf861", + "title": "Chorus", + "content": "G G/F# Em7\nWell, I've been afraid of changing cause I\nC G/B Am7 D/F#\nbuilt my life around you\n G G/F# Em7\nBut time makes you bolder, children get older\n C G/B Am7 G/B\nand I'm getting older too\nC G/B Am7 G/B (repeat as necessary)\n (insert optional solo here)\nC G/B Am7 D/F#\n (as solo finishes)\n G G/F# Em7\nWell, I've been afraid of changing cause I\nC G/B Am7 D/F#\nbuilt my life around you\n G G/F# Em7\nBut time makes you bolder, children get older\n C G/B Am7 G/B\nand I'm getting older too\n C G/B Am7 G/B\nOh, I'm getting older too Ah" + }, + { + "id": "77d2b266-56cb-4685-946c-c0e66c3c6ddc", + "title": "Verse 3", + "content": "C G/B Am7 G/B\n Ah Take my love and take it down\nC G/B Am7 G/B\n Ah If you climb a mountain and you turn around\n C G/B Am7 G/B\nAnd if you see my reflection in the snow covered hills\n C G/B Am7 G/B\nthen a landslide will bring you down\n C G/B Am7 G/B\nAnd if you see my relfection in the snow covered hills\nWell...\n C G/B Am7 G/B\nA landslide will bring you down. Ohhhh\n C G/B Am7\nWell well, a landslide bring you down" + } + ], + "artist": "Fleetwood Mac" + }, + { + "id": "13586", + "title": "Go Your Own Way", + "key": null, + "tuning": null, + "label": "ultimate guitar", + "instrument": null, + "starred": false, + "capo": null, + "sections": [ + { + "id": "1c727883-07af-479d-9e11-87b1bce6092d", + "title": "Intro", + "content": "Go Your Own Way chords \nFleetwood Mac\nF" + }, + { + "id": "9488faa7-ef3d-4efb-bb6a-33a93132a9ec", + "title": "Verse", + "content": "F C Bb\nLoving you, isn t the right thing to do?\nBb F \nHow can I, ever change things that I feel\nF C Bb\nIf I could, maybe I d give you my world\nBb F\nHow can I, when you wont take it from me?" + }, + { + "id": "ee72e80e-ca99-44c5-879f-6bf272f9d9f7", + "title": "Chorus", + "content": "Dm Bb C \nYou can go your own way, Go your own way\nDm Bb C \nYou can call it Another lonely day\nDm Bb C \nYou can go your own way, Go your own way" + }, + { + "id": "4b157fa7-c7fa-47bb-a8a0-b0ecaac29bec", + "title": "Verse", + "content": "F C Bb\nTell me why, everything turned around?\nBb F\nPacking up, shacking up is all you wanna do\nF C Bb\nIf I could, baby Id give you my world\nBb F\nOpen up, everything s waiting for you" + }, + { + "id": "15c35788-e574-4414-8b0d-d795ab13983e", + "title": "Chorus", + "content": "Dm Bb C \nYou can go your own way, Go your own way\nDm Bb C \nYou can call it Another lonely day\nDm Bb C \nYou can go your own way, Go your own way" + }, + { + "id": "6ecd832e-1bf3-4b23-a20e-fbc5d69c835b", + "title": "Break 1", + "content": "F C Bb F C Bb F C Bb F C Bb F" + }, + { + "id": "dbae4324-ea23-4f54-883e-7ab65dc84c74", + "title": "Chorus", + "content": "Dm Bb C \nYou can go your own way, Go your own way\nDm Bb C \nYou can call it Another lonely day\nDm Bb C \nYou can go your own way, Go your own way" + }, + { + "id": "581dbc5d-1a15-49e5-bb3c-b7947d707f55", + "title": "Solo", + "content": "(over and over during guitar solo)\nDm Bb C" + }, + { + "id": "874c824e-d5af-4968-8bf3-7d790239533f", + "title": "Chorus", + "content": "Dm Bb C \nYou can go your own way, Go your own way\nDm Bb C \nYou can call it Another lonely day\nDm Bb C \nYou can go your own way, Go your own way\n(Fade out on chorus)" + } + ], + "artist": "Fleetwood Mac" + }, + { + "id": "146744", + "title": "Boulevard Of Broken Dreams", + "key": null, + "tuning": null, + "label": "ultimate guitar", + "instrument": null, + "starred": false, + "capo": "1", + "sections": [ + { + "id": "d7e99808-eb26-4723-af95-1ca5130e583a", + "title": "Verse 1", + "content": "Em G D A Em\n I walk a lonely road, the only one that I have ever known\n G D A Em\nDon't know where it goes, but it's home to me and I walk alone" + }, + { + "id": "5b4a81b4-1987-464c-94cf-d52969869360", + "title": "Interlude", + "content": "(Em) G D A" + }, + { + "id": "2a30ebcd-47ff-48d6-98d0-a27599c4776a", + "title": "Verse 2", + "content": "Em G D A Em\n I walk this empty street, on the boulevard of broken dreams\n G D A Em\nWhere the city sleeps, and I'm the only one and I walk alone" + }, + { + "id": "7fbb6feb-7877-4e9c-ad92-f59aa1387f88", + "title": "Interlude", + "content": "(Em) G D A Em\n I walk alone, I walk alone\n(Em) G D A\n I walk alone, I walk a...." + }, + { + "id": "71a8a6ea-99ac-43c4-9989-0736d2952ef5", + "title": "Chorus", + "content": "C G D Em\n My shadow's the only one that walks beside me\nC G D Em\n My shallow heart's the only thing that's beating\nC G D Em\n Sometimes I wish someone out there will find me\nC G B7\n Till then I walk alone" + }, + { + "id": "1aa72ddd-c6e9-48b0-85d0-bc219afad596", + "title": "Interlude", + "content": "Em G D A\nAh-Ah Ah-Ah Ah-Ah Ahhh-Ah\n Em G D A\nhaaa-ah Ah-Ah Ah-Ah Ah-Ah" + }, + { + "id": "493ea979-b4b2-440c-8bec-7a110fe20e09", + "title": "Verse 3", + "content": "Em G\n I'm walking down the line\nD A Em\nThat divides me somewhere in my mind\n G D\nOn the border line of the edge\n A Em\nAnd where I walk alone" + }, + { + "id": "fe5a5f90-a7df-4055-ad68-528646f89786", + "title": "Interlude", + "content": "(Em) G D A" + }, + { + "id": "fae6276f-a646-487d-8d6b-a0621651c1e3", + "title": "Verse 4", + "content": "Em G\n Read between the lines\nD A Em\nWhat's fucked up and everything's alright\n G D A\nCheck my vital signs, to know I'm still alive\n Em\nAnd I walk alone" + }, + { + "id": "32de7ebb-5253-4ee8-b895-64101195e817", + "title": "Interlude", + "content": "(Em) G D A Em\n I walk alone, I walk alone\n(Em) G D A\n I walk alone, I walk a...." + }, + { + "id": "438ec43f-cf25-44d8-945d-e32f29f70291", + "title": "Chorus", + "content": "C G D Em\n My shadow's the only one that walks beside me\nC G D Em\n My shallow heart's the only thing that's beating\nC G D Em\n Sometimes I wish someone out there will find me\nC G B7\n Till then I walk alone" + }, + { + "id": "442decb3-6ad0-41f6-87fb-ca1f1d17effb", + "title": "Interlude", + "content": "Em G D A\nAh-Ah Ah-Ah Ah-Ah Ahhh-Ah\n Em G D A\nhaaa-ah Ah-Ah Ah-Ah I walk alone, I walk a..." + }, + { + "id": "16adf87d-8f92-4774-97d7-87ddb673c212", + "title": "Solo", + "content": "C G D Em\nC G D Em\nC G D Em\nC G B B7" + }, + { + "id": "b8b77deb-4381-449f-8eac-6d03a1b97ac1", + "title": "Verse 5", + "content": "Em G D A Em\nI walk this empty street, on the boulevard of broken dreams\n G D A\nWhere the city sleeps, and I'm the only one and I walk a..." + }, + { + "id": "7445f734-7393-444c-9fbe-3660a7d46d83", + "title": "Chorus", + "content": "C G D Em\n My shadow's the only one that walks beside me\nC G D Em\n My shallow heart's the only thing that's beating\nC G D Em\n Sometimes I wish someone out there will find me\nC G B\n Till then I walk alone" + }, + { + "id": "fa06adc5-271f-4770-9d1f-833b2d848bf0", + "title": "Outro", + "content": "Em C D A/C# G D#5\nEm C D A/C# G D#5\nEm C D A/C# G D#5\nEm C D A/C# G D#5" + } + ], + "artist": "Green Day" + }, + { + "id": "2475920", + "title": "Guiding Light", + "key": null, + "tuning": null, + "label": "ultimate guitar", + "instrument": null, + "starred": false, + "capo": null, + "sections": [ + { + "id": "72d28039-9356-4c97-9ff8-76af6ddf5fae", + "title": "Intro", + "content": "G / / / / / /" + }, + { + "id": "203a9f05-20e6-48a9-ba41-e78573928922", + "title": "Verse", + "content": "G D/G\nAll day permanent red,\n C/G G\nthe glaze on my eyes.\n D/G\nWhen I heard your voice,\n C/G G\nthe distance caught me by surprise again.\n D/G C/G G\nAnd I know you claim that you're alright;\n C G\nbut fix your eyes on me,\n C\nI guess I'm all you have\n G D\nand I swear you'll see the dawn again." + }, + { + "id": "d295d238-efad-42f7-bc11-256e454ff3c6", + "title": "Chorus", + "content": "C G\nWell I know I had it all on the line,\n Em D\nbut don't just sit with folded hands and become blind.\n Am G\n'Cause even when there is no star in sight,\nEm D\nyou'll always be my only guiding light." + }, + { + "id": "8d611dda-b47a-45a1-afd5-96f6efb437ee", + "title": "Verse", + "content": "G D/G\nRelate to my youth,\n C/G G\nwell I'm still in awe of you.\n D/G\nDiscover some new truth,\n C/G G\nand that was always wrapped around you.\nG D/G C/G G\nDon't just slip away in the night,\nG D/G C/G G\ndont just hurl your words from on high." + }, + { + "id": "3cc292bc-8371-41d1-8def-c723335d0889", + "title": "Chorus", + "content": "C G\nWell I know I had it all on the line,\n Em D\nbut don't just sit with folded hands and become blind.\n Am G\n'Cause even when there is no star in sight,\n Em D\nyou'll always be my only guiding light." + }, + { + "id": "5f244384-1077-4e99-a5fd-bc55cba8f03b", + "title": "Bridge", + "content": "Am G\n If we come back and were broken,\nAm Em\n unworthy and ashamed,\nAm G\n give us something to believe in,\nAm D / / / Am / / / G / / / Em / / / D / / /\n and you know well go your way" + }, + { + "id": "5c91b417-e8d6-48f6-9f80-97e20206f2f9", + "title": "Chorus", + "content": "C G\nWell I know I had it all on the line,\n Em D\nbut don't just sit with folded hands and become blind.\n Am G\n'Cause even when there is no star in sight,\n Em D\nyou'll always be my only guiding light. [Repeat Chorus - resolve to G]" + } + ], + "artist": "Mumford & Sons" + }, + { + "id": "709013", + "title": "Mamma Mia", + "key": null, + "tuning": null, + "label": "ultimate guitar", + "instrument": null, + "starred": false, + "capo": null, + "sections": [ + { + "id": "e48f1d87-6c81-4792-a472-3373e0182bf1", + "title": "Verse 1", + "content": "D G\nI've been cheated by you since I don't know when\nD G\nSo I made up my mind, it must come to an end\nD5 D+5\nLook at me now, will I ever learn?\nD6 D7 G\nI don't know how but I suddenly lose control\nG A\nThere's a fire within my soul" + }, + { + "id": "df43a50b-c991-4a9a-8883-fca00c4b9f61", + "title": "Pre-Chorus", + "content": "G D A\nJust one look and I can hear a bell ring\nG D A\nOne more look and I forget everything, o-o-o-oh" + }, + { + "id": "43c59456-2235-4289-8e9f-78575e17b113", + "title": "Chorus", + "content": "D\nMamma mia, here I go again\nG\nMy my, how can I resist you?\nD\nMamma mia, does it show again?\nG\nMy my, just how much I've missed you\nD A\nYes, I've been brokenhearted\nBm F#m\nBlue since the day we parted\nG A\nWhy, why did I ever let you go?\nD Bm\nMamma mia, now I really know,\nG A\nMy my, I could never let you go." + }, + { + "id": "b8d99912-e4c2-482f-92e5-06242c352dbf", + "title": "Verse 2", + "content": "D G\nI've been angry and sad about things that you do\nD G\nI can't count all the times that I've told you \"we're through\"\nD5 D+5\nAnd when you go, when you slam the door\nD6 D7 G\nI think you know that you won't be away too long\nG A\nYou know that I'm not that strong" + }, + { + "id": "6bf2e85c-bbb0-42b7-8a9b-362c4490a84e", + "title": "Pre-Chorus", + "content": "G D A\nJust one look and I can hear a bell ring\nG D A\nOne more look and I forget everything" + }, + { + "id": "d12a28c9-bbea-4001-9306-79bfbc71e2c9", + "title": "Chorus", + "content": "D\nMamma mia, here I go again\nG\nMy my, how can I resist you?\nD\nMamma mia, does it show again\nG\nMy my, just how much I've missed you?\nD A\nYes, I've been brokenhearted\nBm F#m\nBlue since the day we parted\nG A\nWhy, why did I ever let you go?\nD Bm\nMamma mia, even if I say\nG A\n\"Bye bye, leave me now or never\"\nD\nMamma mia, it's a game we play\nG\n\"Bye bye\" doesn't mean forever\nD\nMamma mia, here I go again\nG\nMy my, how can I resist you?\nD\nMamma mia, does it show again\nG\nMy my, just how much I've missed you?\nD A\nYes, I've been brokenhearted\nBm F#m\nBlue since the day we parted\nG A\nWhy, why did I ever let you go?\nD Bm\nMamma mia, now I really know\nG A\nMy my, I could never let you go" + }, + { + "id": "4d4e827e-2360-4c23-a7ed-fbb56aee1d37", + "title": "Outro", + "content": "D G D G" + } + ], + "artist": "ABBA" + }, + { + "id": "1994861", + "title": "Into The Sun", + "key": null, + "tuning": null, + "label": "ultimate guitar", + "instrument": null, + "starred": false, + "capo": null, + "sections": [ + { + "id": "2fe6bd9e-aa7b-4d7a-b5fd-6560a5215104", + "title": "Intro", + "content": "E F# E F#" + }, + { + "id": "bc5c2b08-6188-4a95-b197-73219dad799b", + "title": "Verse", + "content": "C#m E \nStealing Glances at the pavement\nB F#\nThe weight it comes too soon\nC#m E\nSupposed to keep on rolling\nB F#\nBut the race is nothing new\nC#m \nAs the train, it starts to go,\nE\nAnd it takes our bodies slow\nB F#\nAnd I know you wanted to for some time now\nC#m\nAll this time you're gone\nE\nIn your wake I stumble on\nB F#\nBut the smoke is nothing that I haven't seen\nE G#m F#\nSo I walk into the sun\nB E\nI thought you'd be there\nE F#\nBut you could fool anyone\nE G#m F#\nIn the red water dust\n B E\nWill I see you soon,\n G#m F#\nOr did we move on?\nC#m E B F#\nOooooohh\nC#m E\nThe crowd begins to break up\nB F#\nThey're calling their goodbyes\nC#m E\nMy head's above the water\nB F#\nBut I'm drowning in your eyes\nE G#m F#\nSo I walk into the sun\nB E\nI thought you'd be there\nE F#\nBut you could fool anyone\nE G#m F#\nGot a head full of dust\n B E\nWill I see you soon,\n G#m F#\nOr did we move on?" + }, + { + "id": "e3832b3a-4db9-4d4c-bf7d-cc71f6a7e508", + "title": "Outro", + "content": "E\nWell the race is long, you can't relax\nF#\nAnd I don't belong so I'm headed back\nE\nIt's getting hard, you feel the fear\nF#\nI'm seeing red, wish you were here\nE G#m F#\nAnd I walk into the sun\nB E\nI thought you'd be there\nE F#\nBut you could fool anyone\nE G#m F#\nGot a head full of dust\n B E\nWill I see you soon,\n G#m F#\nOr did we move on?\nF# E\nWill I see you soon,\n G#m F#\nOr did we move on?\nB E\nWill I see you soon,\nG#m\nOr do we move on?" + } + ], + "artist": "Sons Of The East" + }, + { + "id": "1196622", + "title": "Lifes For The Living", + "key": null, + "tuning": null, + "label": "ultimate guitar", + "instrument": null, + "starred": false, + "capo": "6", + "sections": [ + { + "id": "2c5227ed-9937-4d2d-a464-58f6b20d5b41", + "title": "Intro", + "content": "Chords:\nEm7 020030\nGadd4 320013\nFmaj7 103210\nAm Dm C G C Em7 Am Dm C G Gadd4 G" + }, + { + "id": "955ea37d-8da7-4c47-8cf5-f779585b55fc", + "title": "Verse", + "content": "Am Fmaj7\nWell grey clouds wraped round the town like elastic\n G C Em7\nCars stood like toys made of Taiwanese plastic\n Am Fmaj7 G Gadd4 G\nThe boy laughed at the spastic dancing around in the rain\n Am Fmaj7\nWhile laundrettes cleaned clothes, high heals rub toes\n G C Em7\nPuddles splashed huddles of bus stop crows\n Am Fmaj7 G Gadd4 G\nDressed in their suits and their boots well they all look the same" + }, + { + "id": "cc959f85-8fcd-4bf8-8a40-742eb5cad20f", + "title": "Bridge", + "content": "F G C Em7 Am G\nI took myself down to the cafe to find all the boys lost in books and crackling vinyl\n F G C C7\nAnd carved out a poem above the urinal that read" + }, + { + "id": "97f44565-f6d6-4244-99d2-fe142f3312dc", + "title": "Chorus", + "content": "F \nDon't you cry for the lost\n G \nSmile for the living\n C Em7 Am G\nGet what you need and give what you're given\n F G\nLife's for the living so live it\n Am\nOr you're better off dead\nDm C G C Em7 Am Dm C G Gadd4 G" + }, + { + "id": "8560ae5e-bee8-446b-bcb3-1f38ca349f56", + "title": "Verse", + "content": "Am Fmaj7\nWhile the evening pulled the moon out of it's packet\n G C Em7\nStars shone like buttons on an old man's jacket\n Am Fmaj7 G Gadd4 G\nWe needed a nail but we tacked it 'til it fell of the wall\n Am Fmaj7\nWhile pigeon's pecked trains, sparks flew like planes\n G C Em7\nThe rain showed the rainbows in the oil stains\n Am Fmaj7 G Gadd4 G\nAnd we all had new iPhones but no one had no one to call" + }, + { + "id": "803f72e9-5353-41fc-baec-b14a09d128be", + "title": "Bridge", + "content": "F G \nAnd I stumbled down to the stomach of the town\n C Em7 Am G\nWhere the widow takes memories to slowly drown\n F G C C7\nWith a hand to the sky and a mist in her eye she said" + }, + { + "id": "f5113fca-b634-4bf8-bed4-45ae917b2b5e", + "title": "Chorus", + "content": "F \nDon't you cry for the lost\n G \nSmile for the living\n C Em7 Am G\nGet what you need and give what you're given\n F G\nLife's for the living so live it\n Am\nOr you're better off dead\n \nEm F C Em7 Am Em G Gadd4 G" + }, + { + "id": "e7b094b5-476b-4f35-8cbe-471bd7f6a421", + "title": "Verse", + "content": "Am Fmaj7\nWell I'm sick of this town, this blind man's forage\n G C Em7\nThey take your dreams down and stick them in storage\n Am Fmaj7 G Gadd4 G\nYou can have them back son when you've paid off your mortgage and loans\n Am Fmaj7\nOh hell with this place, I'll go it my own way\n G C Em7\nI'll stick out my thumb and I trudge down the highway\n Am Fmaj7 G Gadd4 G\nSomeday someone must be going my way home" + }, + { + "id": "f6089c87-2b83-4b80-8323-8def96841aba", + "title": "Bridge", + "content": "F G\nTill then I'll make my bed from a disused car\n C Em7 Am G\nWith a mattress of leaves and a blanket of stars\n F G C C7\nAnd I'll stitch the words into my heart with a needle and thread" + }, + { + "id": "7efd10ec-4e2c-4219-870f-22c62247701d", + "title": "Chorus", + "content": "F \nDon't you cry for the lost\n G \nSmile for the living\n C Em7 Am G\nGet what you need and give what you're given\n F G\nYou know life's for the living so live it\n C C7\nOr you're better off dead" + }, + { + "id": "bcae63d9-97b7-4e05-9c15-f20d95c4bee2", + "title": "Outro", + "content": "F \nDon't you cry for the lost\n G \nSmile for the living\n C Em7 Am G\nGet what you need and give what you're given\n F G\nLife's for the living so live it \n \nOr you're better off dead" + } + ], + "artist": "Passenger" + } +] \ No newline at end of file diff --git a/assets/initial_notes.json b/assets/initial_notes.json new file mode 100644 index 0000000..d2d3c36 --- /dev/null +++ b/assets/initial_notes.json @@ -0,0 +1,183 @@ +{ + "notes": [ + { + "id": "8aefd5f1-2a88-4b9f-a719-67160fb9840d", + "title": "The Parting Glass", + "createdAt": "2021-9-19-20-0-1-204-292", + "lastModified": "2021-9-19-21-25-38-160-353", + "key": "A", + "tuning": "EADGBE", + "capo": null, + "instrument": null, + "length": 130, + "label": "Irish", + "artist": "Traditional", + "starred": 0, + "scrollOffset": 1.0, + "zoom": 1.0, + "bpm": 92, + "color": null, + "sections": [ + { + "title": "Verse 1", + "content": "F#m D A E\nOf all the money that e'er I had\n F#m D A E\nI've spent it in good company\n F#m D A E\nAnd all the harm that e'er I've done\n F#m D Bm F#m\nAlas it was to none but me\n A D A E\nAnd all I've done for want of wit\n Bm A E F#m E\nTo memory now I can't recall\n F#m D A E\nSo fill to me the parting glass\n F#m D Bm F#m\nGood night and joy be with you all", + "id": "5089ec8a-09a9-49ab-bdfa-ecec503bf814", + "lastModified": "2021-9-19-20-0-1-204-378", + "createdAt": "2021-9-19-20-0-1-204-378" + }, + { + "title": "Verse 2", + "content": "F#m D A E\nOf all the comrades that e'er I had\n F#m D A E\nThey are sorry for my going away\n F#m D A E\nAnd all the sweethearts that e'er I had\n F#m D Bm F#m\nThey would wish me one more day to stay\n A D A E\nBut since it falls unto my lot\n Bm A E F#m E\nThat I should rise and you should not\n F#m D A E\nI'll gently rise and I'll softly call\n F#m D Bm F#m\nGood night and joy be with you all", + "id": "06a9636e-9907-43ac-9dc9-a7489f2cb97c", + "lastModified": "2021-9-19-20-0-1-204-379", + "createdAt": "2021-9-19-20-0-1-204-379" + }, + { + "title": "Verse 3", + "content": "F#m D A E\nA man may drink and not be drunk\n F#m D A E\nA man may fight and not be slain\n F#m D A E\nA man may court a pretty girl\n F#m D Bm F#m\nAnd perhaps be welcomed back again\n A D A E\nBut since it has so ought to be\n Bm A E F#m E\nBy a time to rise and a time to fall\n F#m D A E\nCome fill to me the parting glass\n F#m D A E\nGoodnight and joy be with you all\n F#m D Bm F#m\nGoodnight and joy be with you all", + "id": "05a29601-b485-4189-81cc-e03654d8400c", + "lastModified": "2021-9-19-20-0-1-204-381", + "createdAt": "2021-9-19-20-0-1-204-380" + } + ], + "audioFiles": [], + "discarded": 0 + }, + { + "id": "1ce6c58c-9874-4e11-ae22-5a038d261fe4", + "title": "Happy Birthday", + "createdAt": "2021-9-19-21-24-10-207-227", + "lastModified": "2021-9-19-21-24-57-852-220", + "key": "C", + "tuning": "EADGBE", + "capo": "2", + "instrument": "Guitar", + "label": "happy", + "artist": "Traditional", + "starred": 0, + "scrollOffset": 1.0, + "zoom": 1.0, + "bpm": 125, + "length": 12, + "color": null, + "sections": [ + { + "title": "Chorus", + "content": "\n C G7\nHappy Birthday to You\n\n G7 C\nHappy Birthday to You\n\n C F\nHappy Birthday dear -NAME-\n\n C G7 C\nHappy Birthday to You", + "id": "1101766f-79e7-4c6d-885f-95ac51864a04", + "lastModified": "2021-9-19-21-24-10-208-283", + "createdAt": "2021-9-19-21-24-10-208-284" + } + ], + "audioFiles": [], + "discarded": 0 + }, + { + "id": "e2a2e5fb-0679-4aad-9336-ebbdbffe1d66", + "title": "Crossroad Blues", + "createdAt": "2021-9-19-21-52-7-833-889", + "lastModified": "2021-9-19-22-0-49-925-180", + "key": "A", + "tuning": "EADGBE", + "capo": "1", + "instrument": "Guitar", + "label": "Blues", + "artist": "Robert Johnson", + "starred": 0, + "scrollOffset": 1.0, + "zoom": 0.8573749999999999, + "bpm": 97, + "length": 150, + "color": null, + "sections": [ + { + "title": "Intro", + "content": "A E", + "id": "0c817242-0c8c-4eca-aafe-0b9e9870ad16", + "lastModified": "2021-9-19-21-52-5-279-193", + "createdAt": "2021-9-19-21-52-5-279-193" + }, + { + "title": "Verse 1", + "content": "A D A\nI went to the crossroad, fell down on my knees\n D A\nI went to the crossroad, fell down on m kne[A]es\nE\nAsked the Lord above, \"Have mercy now\nD A\nSave poor Bob if you please\"", + "id": "8edfd033-020e-4209-878e-481239c027df", + "lastModified": "2021-9-19-21-52-5-381-193", + "createdAt": "2021-9-19-21-52-5-381-193" + }, + { + "title": "Verse 2", + "content": "A D A\nYeoo, standin' at the crossroad, tried to flag a ride\nD A\nStanding at the crossroads, I tried to flag a ride\nE D A\nDidn't nobody seem to know me, babe, everybody pass me by", + "id": "814a7755-4709-4cb2-8cd9-c7eb4ab62814", + "lastModified": "2021-9-19-21-52-5-501-193", + "createdAt": "2021-9-19-21-52-5-502-193" + }, + { + "title": "Verse 3", + "content": "A D A\nAnd if I’m going downwards, God’s gonna catch me in\nD A\nHe, eee, eee, what’s God gonna get me in\nE D A\nI haven’t got no lovin’ sweet woman, Bob, Bob is seeing my girl", + "id": "334c45af-f67a-4fa0-9c5c-c7258337db2f", + "lastModified": "2021-9-19-21-53-43-315-930", + "createdAt": "2021-9-19-21-53-43-315-930" + }, + { + "title": "Verse 4", + "content": "A\nYou can run, you can run, tell my friend poor Willie Brown\nD7 A7\nYou can run, tell my friend Willie Brown\nE7\nOh, that I stand at the crossroad, babe\nD7 A\nI believe I’m sinkin' down", + "id": "b605e87e-502e-4a5a-9b19-aabc0a7e131d", + "lastModified": "2021-9-19-21-53-43-316-34", + "createdAt": "2021-9-19-21-53-43-316-34" + } + ], + "audioFiles": [], + "discarded": 0 + }, + { + "id": "676b0ac5-44b1-4133-9fb3-51dd919f57ee", + "title": "House Of The Rising Sun", + "createdAt": "2021-9-19-21-27-17-613-299", + "lastModified": "2021-9-19-21-45-37-44-106", + "key": "Am", + "tuning": "GCEA", + "capo": null, + "instrument": null, + "label": "Ukulele", + "artist": "", + "starred": 0, + "scrollOffset": 1.0, + "zoom": 1.0, + "bpm": 117, + "length": 271, + "color": null, + "sections": [ + { + "title": "Intro", + "content": " Am C D F\nThere is a house in New Orleans\n Am C E E\nThey call the Risin' Sun\n Am C D F\nAnd it's been the ruin of many a poor boy.\n Am E Am\nAnd God, I know I'm one.\n\nC, D, F, Am, E, Am, E", + "id": "c7bb5035-bb7e-4a30-a55e-e6e47b5eb7f6", + "lastModified": "2021-9-19-21-31-58-781-403", + "createdAt": "2021-9-19-21-31-58-781-404" + }, + { + "title": "Verse 1", + "content": " Am C D F\nMy mother was a tailor.\n Am C E E\nShe sewed my new blue jeans.\n Am C D F\nMy father was a gamblin' man\nAm E Am\nDown in New Or-leans.\n\nC, D, F, Am, E, Am, E\n\n Am C D F\nNow, the only thing a gambler needs\n Am C E E\nIs a suitcase and a trunk\n Am C D F\nAnd the only time that he's satis-fied\n Am E Am\nIs when he's on a drunk\n\nC, D, F, Am, E, Am, E", + "id": "a39be2d8-c34c-46b3-870d-bbf2e4512eb1", + "lastModified": "2021-9-19-21-27-17-613-399", + "createdAt": "2021-9-19-21-27-17-613-399" + }, + { + "title": "Verse 2", + "content": " Am C D F\nOh, Mother, tell your children\n Am C E E\nNot to do what I have done.\nAm C D F\nSpend your lives in sin and misery\n Am E Am\nIn the house of the risin' sun.\n\nC, D, F, Am, E, Am, E\n\n Am C D F\nWell, I've got one foot on the platform.\n Am C E E\nthe other foot on the train.\n Am C D F\nI'm goin' back to New Orleans\n Am E Am\nTo wear that ball and chain.\n\nC, D, F, Am, E, Am, E", + "id": "1aec077e-e4ab-4ed7-887c-b421a2de29cb", + "lastModified": "2021-9-19-21-27-17-613-400", + "createdAt": "2021-9-19-21-27-17-613-400" + }, + { + "title": "Chorus", + "content": " Am C D F\nThere is a house in New Orleans\n Am C E E\nThey call the Risin' Sun\n Am C D F\nAnd it's been the ruin of many a poor boy.\n Am E Am\nAnd God, I know I'm one.\n\nC, D, F, Am, E, \nAm, D, Am, D, Am, D", + "id": "ecfaa248-7d88-4d64-9a2f-85997a9963a9", + "lastModified": "2021-9-19-21-30-51-684-104", + "createdAt": "2021-9-19-21-30-51-684-106" + } + ], + "audioFiles": [], + "discarded": 0 + } + ] +} \ No newline at end of file diff --git a/assets/launcher_icon/dev-icon.png b/assets/launcher_icon/dev-icon.png index 0ec3686..4c6a02c 100644 Binary files a/assets/launcher_icon/dev-icon.png and b/assets/launcher_icon/dev-icon.png differ diff --git a/assets/launcher_icon/icon.png b/assets/launcher_icon/icon.png index f63e1da..223c835 100644 Binary files a/assets/launcher_icon/icon.png and b/assets/launcher_icon/icon.png differ diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index c5855d7..e91d0b4 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -1,11 +1,11 @@ -FROM python:3.7-buster +FROM python:3.11-slim WORKDIR /app -# install requirements -ADD requirements.txt requirements.txt -RUN pip3 install -r requirements.txt +COPY pyproject.toml README.md ./ +COPY src ./src +RUN pip install --no-cache-dir -e . EXPOSE 8000 -CMD ["uvicorn", "src.app:app", "--reload"] \ No newline at end of file +CMD ["uvicorn", "src.app:app", "--reload", "--host=0.0.0.0", "--port=8000"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..a3fa5b7 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,66 @@ +# Sketchord Sync Backend + +FastAPI backend for deterministic mutation sync. + +## Install + +```bash +cd backend +uv sync +``` + +Optional RethinkDB support: + +```bash +cd backend +uv sync --extra rethinkdb +``` + +## Run + +```bash +cd backend +uv run sketchord-sync-backend +``` + +## Build Standalone Binary (PyInstaller) + +Build a single-file executable for macOS/Linux: + +```bash +cd backend +./scripts/build_pyinstaller.sh +``` + +Output binary: + +- `backend/dist/sketchord-sync-backend` + +Run it directly: + +```bash +SYNC_PORT=8009 ./dist/sketchord-sync-backend +``` + +## Configuration + +- `SYNC_DB_BACKEND`: `sqlite` (default) or `rethinkdb` +- `SYNC_SQLITE_PATH`: SQLite file path (default: `./data/sync.db`) +- `SYNC_HOST`: bind host (default: `0.0.0.0`) +- `SYNC_PORT`: bind port (default: `8009`) +- `SYNC_LOG_LEVEL`: Uvicorn log level (default: `info`) + +RethinkDB settings (only when `SYNC_DB_BACKEND=rethinkdb`): + +- `SYNC_RETHINK_HOST` (default: `localhost`) +- `SYNC_RETHINK_PORT` (default: `28015`) +- `SYNC_RETHINK_DB` (default: `sketchord`) +- `SYNC_RETHINK_TABLE_PREFIX` (default: `sync`) + +## API + +- `GET /healthz` +- `POST /v1/sync/upload` +- `GET /v1/sync/pull?since_seq=` +- `GET /v1/sync/conflicts?unresolved_only=true` +- `POST /v1/sync/conflicts/{conflict_id}/resolve` diff --git a/backend/docker-compose.yaml b/backend/docker-compose.yaml index e50da70..27f3f96 100644 --- a/backend/docker-compose.yaml +++ b/backend/docker-compose.yaml @@ -1,14 +1,5 @@ -version: '2' +version: '3.9' services: - rethinkdb: - image: rethinkdb:latest - ports: - - "8084:8080" - - "29015:29015" - - "28015:28015" - volumes: - - ./data:/data - command: rethinkdb --bind all # --data /data backend: build: context: ./ @@ -20,6 +11,15 @@ services: ports: - "8009:8000" environment: - - DATABASE_URI=user:1234@rethinkdb:28015/iot - depends_on: - - "rethinkdb" + - SYNC_DB_BACKEND=sqlite + - SYNC_SQLITE_PATH=/app/data/sync.db + rethinkdb: + image: rethinkdb:latest + profiles: ["rethinkdb"] + ports: + - "8084:8080" + - "29015:29015" + - "28015:28015" + volumes: + - ./data:/data + command: rethinkdb --bind all diff --git a/backend/pyinstaller/sketchord_sync_backend.spec b/backend/pyinstaller/sketchord_sync_backend.spec new file mode 100644 index 0000000..f0b302b --- /dev/null +++ b/backend/pyinstaller/sketchord_sync_backend.spec @@ -0,0 +1,48 @@ +# -*- mode: python ; coding: utf-8 -*- + +from pathlib import Path + +from PyInstaller.utils.hooks import collect_submodules + +hiddenimports = [] +hiddenimports += collect_submodules("uvicorn") + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = PROJECT_ROOT / "src" +ENTRYPOINT = SRC_ROOT / "sketchord_sync_backend" / "__main__.py" + +a = Analysis( + [str(ENTRYPOINT)], + pathex=[str(SRC_ROOT)], + binaries=[], + datas=[], + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name="sketchord-sync-backend", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..6f1262a --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["hatchling>=1.25.0"] +build-backend = "hatchling.build" + +[project] +name = "sketchord-sync-backend" +version = "0.1.0" +description = "Sketchord sync backend with deterministic merge and conflict logging." +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.116.0", + "uvicorn>=0.35.0", + "pydantic>=2.11.0", +] + +[project.optional-dependencies] +rethinkdb = [ + "rethinkdb>=2.4.10", +] + +[project.scripts] +sketchord-sync-backend = "sketchord_sync_backend.__main__:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/sketchord_sync_backend"] + diff --git a/backend/requirements.txt b/backend/requirements.txt index 898a597..889c6e2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,4 @@ -fastapi -rethinkdb -uvicorn -bs4 -requests \ No newline at end of file +fastapi>=0.116.0 +uvicorn>=0.35.0 +pydantic>=2.11.0 +rethinkdb>=2.4.10 diff --git a/backend/scripts/build_pyinstaller.sh b/backend/scripts/build_pyinstaller.sh new file mode 100755 index 0000000..78c127e --- /dev/null +++ b/backend/scripts/build_pyinstaller.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BACKEND_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${BACKEND_DIR}" + +python -m pip install --upgrade pip +python -m pip install -r requirements.txt pyinstaller + +ENTRYPOINT="${BACKEND_DIR}/src/sketchord_sync_backend/__main__.py" +if [ ! -f "${ENTRYPOINT}" ]; then + echo "Entrypoint not found: ${ENTRYPOINT}" >&2 + exit 1 +fi + +pyinstaller \ + --clean \ + --noconfirm \ + --onefile \ + --name sketchord-sync-backend \ + --paths "${BACKEND_DIR}/src" \ + --collect-submodules uvicorn \ + "${ENTRYPOINT}" + +echo "Built backend executable at: ${BACKEND_DIR}/dist/sketchord-sync-backend" diff --git a/backend/src/app.py b/backend/src/app.py index 7ad22a0..ee4738a 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -1,59 +1,4 @@ -from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse -from starlette.exceptions import HTTPException as StarletteHTTPException +from sketchord_sync_backend.app import create_app -import time -import random -import string -from . import requestvars -import types +app = create_app() -from . import config -from .utils import logger -from . import v1 -from .db import RethinkClient - -app = FastAPI(title="Sound App Server", - openapi_url="/rest/v1/openapi.json", - redoc_url='/redoc', - debug=config.is_debug(), - docs_url='/docs', # '/rest/v1/docs', - version="0.0.1") - - -@app.exception_handler(StarletteHTTPException) -async def validation_exception_handler(request, exc): - logger.error(str(exc.__dict__)) - logger.error(str(request.__dict__)) - return JSONResponse(dict(detail=str(exc.detail), status_code=exc.status_code, method=request.scope['method'], path=request.scope["path"]), status_code=exc.status_code) - - -@app.middleware("http") -async def log_requests(request: Request, call_next): - idem = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6)) - logger.info( - f"rid={idem} start request path={request.url.path}, params={request.query_params}") - start_time = time.time() - - """ - # set namespace vars - initial_g = requestvars.Context( - db=RethinkClient(config.get_database_uri())) - requestvars.request_global.set(initial_g) - """ - - response = await call_next(request) - - process_time = (time.time() - start_time) * 1000 - formatted_process_time = '{0:.2f}'.format(process_time) - logger.info( - f"rid={idem} completed_in={formatted_process_time}ms status_code={response.status_code}") - - return response - - -v1.include_routes(app) - -if __name__ == "__main__": - import uvicorn - uvicorn.run(app, host='0.0.0.0', port=8009) diff --git a/backend/src/sketchord_sync_backend/__init__.py b/backend/src/sketchord_sync_backend/__init__.py new file mode 100644 index 0000000..a207644 --- /dev/null +++ b/backend/src/sketchord_sync_backend/__init__.py @@ -0,0 +1,4 @@ +__all__ = ["create_app"] + +from .app import create_app + diff --git a/backend/src/sketchord_sync_backend/__main__.py b/backend/src/sketchord_sync_backend/__main__.py new file mode 100644 index 0000000..6fa5589 --- /dev/null +++ b/backend/src/sketchord_sync_backend/__main__.py @@ -0,0 +1,20 @@ +import uvicorn + +from sketchord_sync_backend.app import create_app +from sketchord_sync_backend.config import Settings + + +def main() -> None: + settings = Settings() + uvicorn.run( + "sketchord_sync_backend.app:create_app", + host=settings.host, + port=settings.port, + log_level=settings.log_level, + factory=True, + ) + + +if __name__ == "__main__": + main() + diff --git a/backend/src/sketchord_sync_backend/app.py b/backend/src/sketchord_sync_backend/app.py new file mode 100644 index 0000000..9c0ce4c --- /dev/null +++ b/backend/src/sketchord_sync_backend/app.py @@ -0,0 +1,479 @@ +import asyncio +import json + +from fastapi import FastAPI, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, StreamingResponse + +from sketchord_sync_backend.config import Settings +from sketchord_sync_backend.models import ConflictListResponse, PullResponse, UploadRequest, UploadResponse +from sketchord_sync_backend.service import SyncService +from sketchord_sync_backend.storage import create_repository + + +def _diff_payloads(before: dict | None, after: dict | None) -> dict: + before = before or {} + after = after or {} + added = {} + removed = {} + changed = {} + + before_keys = set(before.keys()) + after_keys = set(after.keys()) + for k in sorted(after_keys - before_keys): + added[k] = after[k] + for k in sorted(before_keys - after_keys): + removed[k] = before[k] + for k in sorted(before_keys & after_keys): + b = before[k] + a = after[k] + if b == a: + continue + if isinstance(b, dict) and isinstance(a, dict): + nested = _diff_payloads(b, a) + if nested["added"] or nested["removed"] or nested["changed"]: + changed[k] = nested + else: + changed[k] = {"from": b, "to": a} + return {"added": added, "removed": removed, "changed": changed} + + +def create_app() -> FastAPI: + settings = Settings() + repository = create_repository(settings) + service = SyncService(repository) + + app = FastAPI( + title="Sketchord Sync Backend", + version="0.1.0", + ) + + @app.get("/healthz") + def healthz() -> dict[str, str]: + return {"status": "ok", "backend": settings.normalized_backend} + + @app.post("/v1/sync/upload", response_model=UploadResponse) + def upload(request: UploadRequest) -> UploadResponse: + return service.upload(request) + + @app.get("/v1/sync/pull", response_model=PullResponse) + def pull( + since_seq: int = Query(default=0, ge=0), + limit: int = Query(default=500, ge=1, le=5000), + ) -> PullResponse: + return service.pull(since_seq=since_seq, limit=limit) + + @app.get("/v1/sync/conflicts", response_model=ConflictListResponse) + def list_conflicts(unresolved_only: bool = True) -> ConflictListResponse: + return service.list_conflicts(unresolved_only=unresolved_only) + + @app.post("/v1/sync/conflicts/{conflict_id}/resolve") + def resolve_conflict(conflict_id: str) -> dict[str, bool]: + resolved = service.resolve_conflict(conflict_id) + if not resolved: + raise HTTPException(status_code=404, detail="conflict not found") + return {"resolved": True} + + @app.get("/v1/admin/state") + def admin_state( + entity_limit: int = Query(default=200, ge=1, le=5000), + change_limit: int = Query(default=200, ge=1, le=5000), + conflict_limit: int = Query(default=200, ge=1, le=5000), + op_limit: int = Query(default=200, ge=1, le=5000), + ) -> dict: + changes = repository.list_changes(since_seq=0, limit=change_limit) + previous_payloads: dict[tuple[str, str], dict] = {} + changes_with_diff = [] + for change in changes: + key = (change.entity_type, change.entity_id) + before = previous_payloads.get(key, {}) + after = change.payload + diff = _diff_payloads(before, after) + row = change.model_dump() + row["diff"] = diff + changes_with_diff.append(row) + previous_payloads[key] = after + + conflicts = repository.list_conflicts(unresolved_only=False)[:conflict_limit] + entities = repository.list_entities(limit=entity_limit) + processed_ops = repository.list_processed_ops(limit=op_limit) + conflicts_rows = [conflict.model_dump() for conflict in conflicts] + return { + "backend": settings.normalized_backend, + "counts": { + "entities": len(repository.list_entities(limit=5000)), + "changes": repository.max_change_seq(), + "conflicts_unresolved": len(repository.list_conflicts(unresolved_only=True)), + "processed_ops": len(repository.list_processed_ops(limit=5000)), + }, + "entities": entities, + "changes": changes_with_diff, + "conflicts": conflicts_rows, + "processed_ops": processed_ops, + "tables": { + "sync_entities": entities, + "sync_change_log": changes_with_diff, + "sync_conflicts": conflicts_rows, + "sync_processed_ops": processed_ops, + }, + } + + @app.get("/v1/admin/stream") + async def admin_stream( + request: Request, + since_seq: int = Query(default=0, ge=0), + ) -> StreamingResponse: + async def event_generator(): + last_seq = since_seq + # Send initial hello event so the UI can confirm connectivity. + yield "event: hello\ndata: {\"ok\":true}\n\n" + while True: + if await request.is_disconnected(): + break + max_seq = repository.max_change_seq() + unresolved = len(repository.list_conflicts(unresolved_only=True)) + if max_seq > last_seq: + payload = { + "type": "change", + "from_seq": last_seq, + "to_seq": max_seq, + "unresolved_conflicts": unresolved, + } + yield f"event: change\ndata: {json.dumps(payload)}\n\n" + last_seq = max_seq + else: + payload = { + "type": "heartbeat", + "seq": max_seq, + "unresolved_conflicts": unresolved, + } + yield f"event: heartbeat\ndata: {json.dumps(payload)}\n\n" + await asyncio.sleep(1.0) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + @app.get("/dashboard", response_class=HTMLResponse) + def dashboard() -> str: + return """ + + + + + + Sketchord Sync Dashboard + + + +
+ + + + + +""" + + return app diff --git a/backend/src/sketchord_sync_backend/config.py b/backend/src/sketchord_sync_backend/config.py new file mode 100644 index 0000000..58e2885 --- /dev/null +++ b/backend/src/sketchord_sync_backend/config.py @@ -0,0 +1,24 @@ +import os +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Settings: + db_backend: str = os.getenv("SYNC_DB_BACKEND", "sqlite").strip().lower() + sqlite_path: str = os.getenv("SYNC_SQLITE_PATH", "./data/sync.db") + host: str = os.getenv("SYNC_HOST", "0.0.0.0") + port: int = int(os.getenv("SYNC_PORT", "8009")) + log_level: str = os.getenv("SYNC_LOG_LEVEL", "info") + + rethink_host: str = os.getenv("SYNC_RETHINK_HOST", "localhost") + rethink_port: int = int(os.getenv("SYNC_RETHINK_PORT", "28015")) + rethink_db: str = os.getenv("SYNC_RETHINK_DB", "sketchord") + rethink_table_prefix: str = os.getenv("SYNC_RETHINK_TABLE_PREFIX", "sync") + + @property + def normalized_backend(self) -> str: + backend = self.db_backend + if backend not in {"sqlite", "rethinkdb"}: + return "sqlite" + return backend + diff --git a/backend/src/sketchord_sync_backend/models.py b/backend/src/sketchord_sync_backend/models.py new file mode 100644 index 0000000..8b3923e --- /dev/null +++ b/backend/src/sketchord_sync_backend/models.py @@ -0,0 +1,85 @@ +from datetime import datetime, timezone +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field + + +def utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +class OperationStatus(str, Enum): + applied = "applied" + merged = "merged" + rejected = "rejected" + duplicate = "duplicate" + + +class SyncOperationType(str, Enum): + upsert = "upsert" + delete = "delete" + tombstone = "tombstone" + + +class Mutation(BaseModel): + op_id: str = Field(min_length=1, max_length=128) + entity_type: str = Field(min_length=1, max_length=64) + entity_id: str = Field(min_length=1, max_length=256) + operation: SyncOperationType + base_version: int = Field(ge=0) + payload: dict[str, Any] = Field(default_factory=dict) + client_ts: str | None = None + source: str | None = None + + +class UploadRequest(BaseModel): + mutations: list[Mutation] = Field(default_factory=list) + + +class MutationResult(BaseModel): + op_id: str + status: OperationStatus + entity_type: str + entity_id: str + server_version: int | None = None + message: str | None = None + merged_payload: dict[str, Any] | None = None + remote_payload: dict[str, Any] | None = None + conflict_id: str | None = None + + +class UploadResponse(BaseModel): + results: list[MutationResult] + + +class ChangeItem(BaseModel): + seq: int + entity_type: str + entity_id: str + operation: SyncOperationType + version: int + source: str | None = None + payload: dict[str, Any] + ts: str + + +class PullResponse(BaseModel): + changes: list[ChangeItem] + next_seq: int + + +class ConflictItem(BaseModel): + conflict_id: str + op_id: str + entity_type: str + entity_id: str + reason: str + local_payload: dict[str, Any] + remote_payload: dict[str, Any] + created_at: str + resolved_at: str | None = None + + +class ConflictListResponse(BaseModel): + conflicts: list[ConflictItem] diff --git a/backend/src/sketchord_sync_backend/service.py b/backend/src/sketchord_sync_backend/service.py new file mode 100644 index 0000000..88f530e --- /dev/null +++ b/backend/src/sketchord_sync_backend/service.py @@ -0,0 +1,317 @@ +from dataclasses import dataclass +from typing import Any + +from sketchord_sync_backend.models import ( + ConflictListResponse, + Mutation, + MutationResult, + OperationStatus, + PullResponse, + SyncOperationType, + UploadRequest, + UploadResponse, + utc_now_iso, +) +from sketchord_sync_backend.storage.base import EntityState, SyncRepository + + +@dataclass +class MergeOutcome: + status: OperationStatus + merged_payload: dict[str, Any] | None = None + message: str | None = None + + +class SyncService: + def __init__(self, repository: SyncRepository): + self.repository = repository + + def upload(self, request: UploadRequest) -> UploadResponse: + results: list[MutationResult] = [] + for mutation in request.mutations: + results.append(self._apply_mutation(mutation)) + return UploadResponse(results=results) + + def pull(self, since_seq: int, limit: int = 500) -> PullResponse: + changes = self.repository.list_changes(since_seq=since_seq, limit=limit) + next_seq = changes[-1].seq if changes else self.repository.max_change_seq() + return PullResponse(changes=changes, next_seq=next_seq) + + def list_conflicts(self, unresolved_only: bool = True) -> ConflictListResponse: + return ConflictListResponse( + conflicts=self.repository.list_conflicts(unresolved_only=unresolved_only), + ) + + def resolve_conflict(self, conflict_id: str) -> bool: + return self.repository.resolve_conflict(conflict_id, utc_now_iso()) + + def _apply_mutation(self, mutation: Mutation) -> MutationResult: + existing_result = self.repository.get_processed_result(mutation.op_id) + if existing_result is not None: + return existing_result + + now = utc_now_iso() + current = self.repository.get_entity(mutation.entity_type, mutation.entity_id) + + if mutation.operation in {SyncOperationType.delete, SyncOperationType.tombstone}: + result = self._apply_delete(mutation, current, now) + else: + result = self._apply_upsert(mutation, current, now) + + self.repository.save_processed_result( + op_id=mutation.op_id, + result=result, + processed_at=now, + ) + return result + + def _apply_delete( + self, + mutation: Mutation, + current: EntityState | None, + now: str, + ) -> MutationResult: + if current is None: + version = self.repository.upsert_entity( + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + payload={"id": mutation.entity_id}, + deleted=True, + ts=now, + ) + self.repository.append_change( + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + operation=mutation.operation.value, + version=version, + source=mutation.source, + payload={"id": mutation.entity_id}, + ts=now, + ) + return MutationResult( + op_id=mutation.op_id, + status=OperationStatus.applied, + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + server_version=version, + message="Delete tombstone created", + ) + + if current.version != mutation.base_version and not current.deleted: + conflict_id = self.repository.create_conflict( + op_id=mutation.op_id, + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + reason="Delete rejected: version mismatch", + local_payload=mutation.payload, + remote_payload=current.payload, + created_at=now, + ) + return MutationResult( + op_id=mutation.op_id, + status=OperationStatus.rejected, + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + server_version=current.version, + message="Delete rejected: version mismatch", + remote_payload=current.payload, + conflict_id=conflict_id, + ) + + version = self.repository.upsert_entity( + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + payload=current.payload if current else mutation.payload, + deleted=True, + ts=now, + ) + self.repository.append_change( + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + operation=mutation.operation.value, + version=version, + source=mutation.source, + payload={"id": mutation.entity_id}, + ts=now, + ) + return MutationResult( + op_id=mutation.op_id, + status=OperationStatus.applied, + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + server_version=version, + ) + + def _apply_upsert( + self, + mutation: Mutation, + current: EntityState | None, + now: str, + ) -> MutationResult: + if current is None: + if mutation.base_version != 0: + conflict_id = self.repository.create_conflict( + op_id=mutation.op_id, + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + reason="Upsert rejected: entity missing for non-zero base_version", + local_payload=mutation.payload, + remote_payload={}, + created_at=now, + ) + return MutationResult( + op_id=mutation.op_id, + status=OperationStatus.rejected, + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + server_version=0, + message="Entity missing for non-zero base_version", + remote_payload={}, + conflict_id=conflict_id, + ) + version = self.repository.upsert_entity( + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + payload=mutation.payload, + deleted=False, + ts=now, + ) + self.repository.append_change( + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + operation=mutation.operation.value, + version=version, + source=mutation.source, + payload=mutation.payload, + ts=now, + ) + return MutationResult( + op_id=mutation.op_id, + status=OperationStatus.applied, + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + server_version=version, + ) + + if current.deleted: + conflict_id = self.repository.create_conflict( + op_id=mutation.op_id, + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + reason="Upsert rejected: entity is tombstoned", + local_payload=mutation.payload, + remote_payload=current.payload, + created_at=now, + ) + return MutationResult( + op_id=mutation.op_id, + status=OperationStatus.rejected, + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + server_version=current.version, + message="Entity is tombstoned", + remote_payload=current.payload, + conflict_id=conflict_id, + ) + + if current.version == mutation.base_version: + version = self.repository.upsert_entity( + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + payload=mutation.payload, + deleted=False, + ts=now, + ) + self.repository.append_change( + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + operation=mutation.operation.value, + version=version, + source=mutation.source, + payload=mutation.payload, + ts=now, + ) + return MutationResult( + op_id=mutation.op_id, + status=OperationStatus.applied, + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + server_version=version, + ) + + merge = self._deterministic_merge(local=mutation.payload, remote=current.payload) + if merge.status == OperationStatus.rejected: + conflict_id = self.repository.create_conflict( + op_id=mutation.op_id, + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + reason=merge.message or "Upsert rejected: overlapping field conflict", + local_payload=mutation.payload, + remote_payload=current.payload, + created_at=now, + ) + return MutationResult( + op_id=mutation.op_id, + status=OperationStatus.rejected, + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + server_version=current.version, + message=merge.message, + remote_payload=current.payload, + conflict_id=conflict_id, + ) + + merged_payload = merge.merged_payload or mutation.payload + version = self.repository.upsert_entity( + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + payload=merged_payload, + deleted=False, + ts=now, + ) + self.repository.append_change( + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + operation=mutation.operation.value, + version=version, + source=mutation.source, + payload=merged_payload, + ts=now, + ) + return MutationResult( + op_id=mutation.op_id, + status=OperationStatus.merged, + entity_type=mutation.entity_type, + entity_id=mutation.entity_id, + server_version=version, + merged_payload=merged_payload, + ) + + def _deterministic_merge(self, local: dict[str, Any], remote: dict[str, Any]) -> MergeOutcome: + merged = dict(remote) + conflict_keys: list[str] = [] + for key, local_value in local.items(): + if key not in merged: + merged[key] = local_value + continue + remote_value = merged[key] + if remote_value == local_value: + continue + + if isinstance(local_value, dict) and isinstance(remote_value, dict): + nested = self._deterministic_merge(local_value, remote_value) + if nested.status == OperationStatus.rejected: + conflict_keys.append(key) + continue + merged[key] = nested.merged_payload or remote_value + continue + + conflict_keys.append(key) + + if conflict_keys: + return MergeOutcome( + status=OperationStatus.rejected, + message=f"Overlapping field conflict: {', '.join(sorted(conflict_keys))}", + ) + + return MergeOutcome(status=OperationStatus.merged, merged_payload=merged) diff --git a/backend/src/sketchord_sync_backend/storage/__init__.py b/backend/src/sketchord_sync_backend/storage/__init__.py new file mode 100644 index 0000000..9d584d0 --- /dev/null +++ b/backend/src/sketchord_sync_backend/storage/__init__.py @@ -0,0 +1,20 @@ +from sketchord_sync_backend.config import Settings +from sketchord_sync_backend.storage.base import SyncRepository + + +def create_repository(settings: Settings) -> SyncRepository: + if settings.normalized_backend == "rethinkdb": + from sketchord_sync_backend.storage.rethink_repo import RethinkSyncRepository + + repo = RethinkSyncRepository( + host=settings.rethink_host, + port=settings.rethink_port, + db_name=settings.rethink_db, + table_prefix=settings.rethink_table_prefix, + ) + else: + from sketchord_sync_backend.storage.sqlite_repo import SqliteSyncRepository + + repo = SqliteSyncRepository(db_path=settings.sqlite_path) + repo.ensure_schema() + return repo diff --git a/backend/src/sketchord_sync_backend/storage/base.py b/backend/src/sketchord_sync_backend/storage/base.py new file mode 100644 index 0000000..c2643f0 --- /dev/null +++ b/backend/src/sketchord_sync_backend/storage/base.py @@ -0,0 +1,99 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any + +from sketchord_sync_backend.models import ChangeItem, ConflictItem, MutationResult + + +@dataclass +class EntityState: + entity_type: str + entity_id: str + version: int + payload: dict[str, Any] + deleted: bool + updated_at: str + + +class SyncRepository(ABC): + @abstractmethod + def ensure_schema(self) -> None: + raise NotImplementedError + + @abstractmethod + def get_processed_result(self, op_id: str) -> MutationResult | None: + raise NotImplementedError + + @abstractmethod + def save_processed_result( + self, + op_id: str, + result: MutationResult, + processed_at: str, + ) -> None: + raise NotImplementedError + + @abstractmethod + def get_entity(self, entity_type: str, entity_id: str) -> EntityState | None: + raise NotImplementedError + + @abstractmethod + def upsert_entity( + self, + entity_type: str, + entity_id: str, + payload: dict[str, Any], + deleted: bool, + ts: str, + ) -> int: + raise NotImplementedError + + @abstractmethod + def append_change( + self, + entity_type: str, + entity_id: str, + operation: str, + version: int, + source: str | None, + payload: dict[str, Any], + ts: str, + ) -> int: + raise NotImplementedError + + @abstractmethod + def create_conflict( + self, + op_id: str, + entity_type: str, + entity_id: str, + reason: str, + local_payload: dict[str, Any], + remote_payload: dict[str, Any], + created_at: str, + ) -> str: + raise NotImplementedError + + @abstractmethod + def list_changes(self, since_seq: int, limit: int) -> list[ChangeItem]: + raise NotImplementedError + + @abstractmethod + def max_change_seq(self) -> int: + raise NotImplementedError + + @abstractmethod + def list_conflicts(self, unresolved_only: bool = True) -> list[ConflictItem]: + raise NotImplementedError + + @abstractmethod + def resolve_conflict(self, conflict_id: str, resolved_at: str) -> bool: + raise NotImplementedError + + @abstractmethod + def list_entities(self, limit: int) -> list[dict[str, Any]]: + raise NotImplementedError + + @abstractmethod + def list_processed_ops(self, limit: int) -> list[dict[str, Any]]: + raise NotImplementedError diff --git a/backend/src/sketchord_sync_backend/storage/rethink_repo.py b/backend/src/sketchord_sync_backend/storage/rethink_repo.py new file mode 100644 index 0000000..98da919 --- /dev/null +++ b/backend/src/sketchord_sync_backend/storage/rethink_repo.py @@ -0,0 +1,288 @@ +import uuid +from typing import Any + +from sketchord_sync_backend.models import ( + ChangeItem, + ConflictItem, + MutationResult, + SyncOperationType, +) +from sketchord_sync_backend.storage.base import EntityState, SyncRepository + + +class RethinkSyncRepository(SyncRepository): + def __init__( + self, + host: str, + port: int, + db_name: str, + table_prefix: str = "sync", + ): + try: + from rethinkdb import RethinkDB + except ImportError as exc: + raise RuntimeError( + "RethinkDB backend requested but rethinkdb package is not installed. " + "Install with: uv sync --extra rethinkdb", + ) from exc + + self.r = RethinkDB() + self.host = host + self.port = port + self.db_name = db_name + self.entities_table = f"{table_prefix}_entities" + self.processed_ops_table = f"{table_prefix}_processed_ops" + self.change_log_table = f"{table_prefix}_change_log" + self.conflicts_table = f"{table_prefix}_conflicts" + self.conn = self.r.connect(host, port) + + def _db(self): + return self.r.db(self.db_name) + + def _create_index_if_missing(self, table: str, index: str) -> None: + indexes = self._db().table(table).index_list().run(self.conn) + if index not in indexes: + self._db().table(table).index_create(index).run(self.conn) + self._db().table(table).index_wait(index).run(self.conn) + + def ensure_schema(self) -> None: + dbs = self.r.db_list().run(self.conn) + if self.db_name not in dbs: + self.r.db_create(self.db_name).run(self.conn) + + db = self._db() + tables = db.table_list().run(self.conn) + for table in [ + self.entities_table, + self.processed_ops_table, + self.change_log_table, + self.conflicts_table, + ]: + if table not in tables: + db.table_create(table).run(self.conn) + + self._create_index_if_missing(self.entities_table, "entity_key") + self._create_index_if_missing(self.processed_ops_table, "op_id") + self._create_index_if_missing(self.change_log_table, "seq") + self._create_index_if_missing(self.change_log_table, "ts") + self._create_index_if_missing(self.conflicts_table, "resolved_at") + self._create_index_if_missing(self.conflicts_table, "created_at") + + def _entity_key(self, entity_type: str, entity_id: str) -> str: + return f"{entity_type}:{entity_id}" + + def get_processed_result(self, op_id: str) -> MutationResult | None: + row = self._db().table(self.processed_ops_table).get(op_id).run(self.conn) + if row is None: + return None + return MutationResult.model_validate(row["result"]) + + def save_processed_result( + self, + op_id: str, + result: MutationResult, + processed_at: str, + ) -> None: + self._db().table(self.processed_ops_table).insert( + { + "id": op_id, + "op_id": op_id, + "entity_type": result.entity_type, + "entity_id": result.entity_id, + "result": result.model_dump(), + "processed_at": processed_at, + }, + conflict="replace", + ).run(self.conn) + + def get_entity(self, entity_type: str, entity_id: str) -> EntityState | None: + key = self._entity_key(entity_type, entity_id) + row = self._db().table(self.entities_table).get(key).run(self.conn) + if row is None: + return None + return EntityState( + entity_type=row["entity_type"], + entity_id=row["entity_id"], + version=int(row["version"]), + payload=dict(row.get("payload", {})), + deleted=bool(row.get("deleted", False)), + updated_at=row["updated_at"], + ) + + def upsert_entity( + self, + entity_type: str, + entity_id: str, + payload: dict[str, Any], + deleted: bool, + ts: str, + ) -> int: + current = self.get_entity(entity_type, entity_id) + next_version = (current.version if current else 0) + 1 + self._db().table(self.entities_table).insert( + { + "id": self._entity_key(entity_type, entity_id), + "entity_key": self._entity_key(entity_type, entity_id), + "entity_type": entity_type, + "entity_id": entity_id, + "version": next_version, + "payload": payload, + "deleted": deleted, + "updated_at": ts, + }, + conflict="replace", + ).run(self.conn) + return next_version + + def append_change( + self, + entity_type: str, + entity_id: str, + operation: str, + version: int, + source: str | None, + payload: dict[str, Any], + ts: str, + ) -> int: + next_seq = self.max_change_seq() + 1 + result = self._db().table(self.change_log_table).insert( + { + "seq": next_seq, + "entity_type": entity_type, + "entity_id": entity_id, + "operation": operation, + "version": version, + "source": source, + "payload": payload, + "ts": ts, + }, + ).run(self.conn) + _ = result + return next_seq + + def create_conflict( + self, + op_id: str, + entity_type: str, + entity_id: str, + reason: str, + local_payload: dict[str, Any], + remote_payload: dict[str, Any], + created_at: str, + ) -> str: + conflict_id = str(uuid.uuid4()) + self._db().table(self.conflicts_table).insert( + { + "id": conflict_id, + "conflict_id": conflict_id, + "op_id": op_id, + "entity_type": entity_type, + "entity_id": entity_id, + "reason": reason, + "local_payload": local_payload, + "remote_payload": remote_payload, + "created_at": created_at, + "resolved_at": None, + }, + ).run(self.conn) + return conflict_id + + def list_changes(self, since_seq: int, limit: int) -> list[ChangeItem]: + rows = ( + self._db() + .table(self.change_log_table) + .filter(self.r.row["seq"] > since_seq) + .order_by("seq") + .limit(limit) + .run(self.conn) + ) + return [ + ChangeItem( + seq=int(row["seq"]), + entity_type=row["entity_type"], + entity_id=row["entity_id"], + operation=SyncOperationType(row["operation"]), + version=int(row["version"]), + source=row.get("source"), + payload=dict(row.get("payload", {})), + ts=row["ts"], + ) + for row in rows + ] + + def max_change_seq(self) -> int: + rows = ( + self._db() + .table(self.change_log_table) + .order_by(self.r.desc("seq")) + .limit(1) + .run(self.conn) + ) + first = next(iter(rows), None) + if first is None: + return 0 + return int(first.get("seq", 0)) + + def list_conflicts(self, unresolved_only: bool = True) -> list[ConflictItem]: + query = self._db().table(self.conflicts_table) + if unresolved_only: + query = query.filter(self.r.row["resolved_at"] == None) # noqa: E711 + rows = query.order_by(self.r.desc("created_at")).run(self.conn) + return [ + ConflictItem( + conflict_id=row["conflict_id"], + op_id=row["op_id"], + entity_type=row["entity_type"], + entity_id=row["entity_id"], + reason=row["reason"], + local_payload=dict(row.get("local_payload", {})), + remote_payload=dict(row.get("remote_payload", {})), + created_at=row["created_at"], + resolved_at=row.get("resolved_at"), + ) + for row in rows + ] + + def resolve_conflict(self, conflict_id: str, resolved_at: str) -> bool: + result = self._db().table(self.conflicts_table).get(conflict_id).update( + {"resolved_at": resolved_at}, + ).run(self.conn) + return result.get("replaced", 0) > 0 or result.get("unchanged", 0) > 0 + + def list_entities(self, limit: int) -> list[dict]: + rows = ( + self._db() + .table(self.entities_table) + .order_by(self.r.desc("updated_at")) + .limit(limit) + .run(self.conn) + ) + return [ + { + "entity_type": row["entity_type"], + "entity_id": row["entity_id"], + "version": int(row["version"]), + "payload": dict(row.get("payload", {})), + "deleted": bool(row.get("deleted", False)), + "updated_at": row["updated_at"], + } + for row in rows + ] + + def list_processed_ops(self, limit: int) -> list[dict]: + rows = ( + self._db() + .table(self.processed_ops_table) + .order_by(self.r.desc("processed_at")) + .limit(limit) + .run(self.conn) + ) + return [ + { + "op_id": row["op_id"], + "entity_type": row["entity_type"], + "entity_id": row["entity_id"], + "processed_at": row.get("processed_at", ""), + } + for row in rows + ] diff --git a/backend/src/sketchord_sync_backend/storage/sqlite_repo.py b/backend/src/sketchord_sync_backend/storage/sqlite_repo.py new file mode 100644 index 0000000..92ef0e3 --- /dev/null +++ b/backend/src/sketchord_sync_backend/storage/sqlite_repo.py @@ -0,0 +1,342 @@ +import json +import sqlite3 +import threading +import uuid +from pathlib import Path + +from sketchord_sync_backend.models import ChangeItem, ConflictItem, MutationResult, SyncOperationType +from sketchord_sync_backend.storage.base import EntityState, SyncRepository + + +class SqliteSyncRepository(SyncRepository): + def __init__(self, db_path: str): + self.db_path = db_path + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + self.conn = sqlite3.connect(db_path, check_same_thread=False) + self.conn.row_factory = sqlite3.Row + self._lock = threading.RLock() + + def ensure_schema(self) -> None: + with self._lock: + self.conn.execute( + """ + CREATE TABLE IF NOT EXISTS sync_entities( + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + version INTEGER NOT NULL, + payload TEXT NOT NULL, + deleted INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + PRIMARY KEY(entity_type, entity_id) + ); + """, + ) + self.conn.execute( + """ + CREATE TABLE IF NOT EXISTS sync_processed_ops( + op_id TEXT PRIMARY KEY, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + result_json TEXT NOT NULL, + processed_at TEXT NOT NULL + ); + """, + ) + self.conn.execute( + """ + CREATE TABLE IF NOT EXISTS sync_change_log( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + operation TEXT NOT NULL, + version INTEGER NOT NULL, + source TEXT, + payload TEXT NOT NULL, + ts TEXT NOT NULL + ); + """, + ) + cols = self.conn.execute("PRAGMA table_info(sync_change_log)").fetchall() + col_names = {str(c["name"]) for c in cols} + if "source" not in col_names: + self.conn.execute("ALTER TABLE sync_change_log ADD source TEXT;") + self.conn.execute( + """ + CREATE TABLE IF NOT EXISTS sync_conflicts( + conflict_id TEXT PRIMARY KEY, + op_id TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + reason TEXT NOT NULL, + local_payload TEXT NOT NULL, + remote_payload TEXT NOT NULL, + created_at TEXT NOT NULL, + resolved_at TEXT + ); + """, + ) + self.conn.commit() + + def get_processed_result(self, op_id: str) -> MutationResult | None: + with self._lock: + row = self.conn.execute( + "SELECT result_json FROM sync_processed_ops WHERE op_id = ?", + (op_id,), + ).fetchone() + if row is None: + return None + return MutationResult.model_validate_json(row["result_json"]) + + def save_processed_result(self, op_id: str, result: MutationResult, processed_at: str) -> None: + with self._lock: + self.conn.execute( + """ + INSERT OR REPLACE INTO sync_processed_ops(op_id, entity_type, entity_id, result_json, processed_at) + VALUES(?, ?, ?, ?, ?) + """, + ( + op_id, + result.entity_type, + result.entity_id, + result.model_dump_json(), + processed_at, + ), + ) + self.conn.commit() + + def get_entity(self, entity_type: str, entity_id: str) -> EntityState | None: + with self._lock: + row = self.conn.execute( + """ + SELECT entity_type, entity_id, version, payload, deleted, updated_at + FROM sync_entities + WHERE entity_type = ? AND entity_id = ? + """, + (entity_type, entity_id), + ).fetchone() + if row is None: + return None + return EntityState( + entity_type=row["entity_type"], + entity_id=row["entity_id"], + version=int(row["version"]), + payload=json.loads(row["payload"]), + deleted=bool(row["deleted"]), + updated_at=row["updated_at"], + ) + + def upsert_entity( + self, + entity_type: str, + entity_id: str, + payload: dict, + deleted: bool, + ts: str, + ) -> int: + with self._lock: + row = self.conn.execute( + """ + SELECT version FROM sync_entities + WHERE entity_type = ? AND entity_id = ? + """, + (entity_type, entity_id), + ).fetchone() + next_version = (int(row["version"]) if row else 0) + 1 + self.conn.execute( + """ + INSERT OR REPLACE INTO sync_entities(entity_type, entity_id, version, payload, deleted, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + entity_type, + entity_id, + next_version, + json.dumps(payload), + 1 if deleted else 0, + ts, + ), + ) + self.conn.commit() + return next_version + + def append_change( + self, + entity_type: str, + entity_id: str, + operation: str, + version: int, + source: str | None, + payload: dict, + ts: str, + ) -> int: + with self._lock: + cur = self.conn.execute( + """ + INSERT INTO sync_change_log(entity_type, entity_id, operation, version, source, payload, ts) + VALUES(?, ?, ?, ?, ?, ?, ?) + """, + ( + entity_type, + entity_id, + operation, + version, + source, + json.dumps(payload), + ts, + ), + ) + self.conn.commit() + return int(cur.lastrowid) + + def create_conflict( + self, + op_id: str, + entity_type: str, + entity_id: str, + reason: str, + local_payload: dict, + remote_payload: dict, + created_at: str, + ) -> str: + with self._lock: + conflict_id = str(uuid.uuid4()) + self.conn.execute( + """ + INSERT INTO sync_conflicts(conflict_id, op_id, entity_type, entity_id, reason, local_payload, remote_payload, created_at, resolved_at) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, NULL) + """, + ( + conflict_id, + op_id, + entity_type, + entity_id, + reason, + json.dumps(local_payload), + json.dumps(remote_payload), + created_at, + ), + ) + self.conn.commit() + return conflict_id + + def list_changes(self, since_seq: int, limit: int) -> list[ChangeItem]: + with self._lock: + rows = self.conn.execute( + """ + SELECT seq, entity_type, entity_id, operation, version, source, payload, ts + FROM sync_change_log + WHERE seq > ? + ORDER BY seq ASC + LIMIT ? + """, + (since_seq, limit), + ).fetchall() + return [ + ChangeItem( + seq=int(row["seq"]), + entity_type=row["entity_type"], + entity_id=row["entity_id"], + operation=SyncOperationType(row["operation"]), + version=int(row["version"]), + source=row["source"], + payload=json.loads(row["payload"]), + ts=row["ts"], + ) + for row in rows + ] + + def max_change_seq(self) -> int: + with self._lock: + row = self.conn.execute("SELECT COALESCE(MAX(seq), 0) AS v FROM sync_change_log").fetchone() + return int(row["v"]) if row is not None else 0 + + def list_conflicts(self, unresolved_only: bool = True) -> list[ConflictItem]: + with self._lock: + if unresolved_only: + rows = self.conn.execute( + """ + SELECT conflict_id, op_id, entity_type, entity_id, reason, local_payload, remote_payload, created_at, resolved_at + FROM sync_conflicts + WHERE resolved_at IS NULL + ORDER BY created_at DESC + """, + ).fetchall() + else: + rows = self.conn.execute( + """ + SELECT conflict_id, op_id, entity_type, entity_id, reason, local_payload, remote_payload, created_at, resolved_at + FROM sync_conflicts + ORDER BY created_at DESC + """, + ).fetchall() + return [ + ConflictItem( + conflict_id=row["conflict_id"], + op_id=row["op_id"], + entity_type=row["entity_type"], + entity_id=row["entity_id"], + reason=row["reason"], + local_payload=json.loads(row["local_payload"]), + remote_payload=json.loads(row["remote_payload"]), + created_at=row["created_at"], + resolved_at=row["resolved_at"], + ) + for row in rows + ] + + def resolve_conflict(self, conflict_id: str, resolved_at: str) -> bool: + with self._lock: + cur = self.conn.execute( + """ + UPDATE sync_conflicts + SET resolved_at = ? + WHERE conflict_id = ? + """, + (resolved_at, conflict_id), + ) + self.conn.commit() + return cur.rowcount > 0 + + def list_entities(self, limit: int) -> list[dict]: + with self._lock: + rows = self.conn.execute( + """ + SELECT entity_type, entity_id, version, payload, deleted, updated_at + FROM sync_entities + ORDER BY updated_at DESC + LIMIT ? + """, + (limit,), + ).fetchall() + return [ + { + "entity_type": row["entity_type"], + "entity_id": row["entity_id"], + "version": int(row["version"]), + "payload": json.loads(row["payload"]), + "deleted": bool(row["deleted"]), + "updated_at": row["updated_at"], + } + for row in rows + ] + + def list_processed_ops(self, limit: int) -> list[dict]: + with self._lock: + rows = self.conn.execute( + """ + SELECT op_id, entity_type, entity_id, processed_at + FROM sync_processed_ops + ORDER BY processed_at DESC + LIMIT ? + """, + (limit,), + ).fetchall() + return [ + { + "op_id": row["op_id"], + "entity_type": row["entity_type"], + "entity_id": row["entity_id"], + "processed_at": row["processed_at"], + } + for row in rows + ] diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..2cfbc54 --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,301 @@ +version = 1 +requires-python = ">=3.11" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592 }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "fastapi" +version = "0.135.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/b5/386a9579a299a32365b34097e4eac6a0544ce0d7aa4bb95ce0d71607a999/fastapi-0.135.0.tar.gz", hash = "sha256:bd37903acf014d1284bda027096e460814dca9699f9dacfe11c275749d949f4d", size = 393855 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/38/fa5dd0e677e1e2e38f858933c4a125e80103e551151f1f661dd4f227210d/fastapi-0.135.0-py3-none-any.whl", hash = "sha256:31e2ddc78d6406c6f7d5d7b9996a057985e2600fbe7e9ba6ace8205d48dff688", size = 114496 }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, +] + +[[package]] +name = "looseversion" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/7e/f13dc08e0712cc2eac8e56c7909ce2ac280dbffef2ffd87bd5277ce9d58b/looseversion-1.3.0.tar.gz", hash = "sha256:ebde65f3f6bb9531a81016c6fef3eb95a61181adc47b7f949e9c0ea47911669e", size = 8799 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/74/d5405b9b3b12e9176dff223576d7090bc161092878f533fd0dc23dd6ae1d/looseversion-1.3.0-py2.py3-none-any.whl", hash = "sha256:781ef477b45946fc03dd4c84ea87734b21137ecda0e1e122bcb3c8d16d2a56e0", size = 8237 }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580 }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873 }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826 }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869 }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890 }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740 }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021 }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378 }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761 }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303 }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355 }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875 }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549 }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305 }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902 }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990 }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003 }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200 }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578 }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504 }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816 }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366 }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698 }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603 }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591 }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068 }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908 }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145 }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179 }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403 }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206 }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307 }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258 }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917 }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186 }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164 }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146 }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788 }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133 }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852 }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679 }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766 }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005 }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622 }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725 }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040 }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691 }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897 }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302 }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877 }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680 }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960 }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102 }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039 }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126 }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255 }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760 }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092 }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385 }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832 }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585 }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078 }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914 }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560 }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244 }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955 }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441 }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291 }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632 }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905 }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495 }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388 }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879 }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017 }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980 }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865 }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256 }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762 }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141 }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317 }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992 }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302 }, +] + +[[package]] +name = "rethinkdb" +version = "2.4.10.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "looseversion" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/ba4f965d8063c6e03ecca7237977b452f4d46ec056a8b3a4ba0cc61debc9/rethinkdb-2.4.10.post1.tar.gz", hash = "sha256:3634e03ee13dd637fd7196b80474bf44c64d3eba1dd069ea92b94926702a60bd", size = 159014 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/a9/8101ffc5f005aa9d735f213185e87209e30981db149506c18a47e9367602/rethinkdb-2.4.10.post1-py2.py3-none-any.whl", hash = "sha256:a8c3644a35beb7bc857887808d267e6124623b32dc1f54608e7729a14617a431", size = 160870 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "sketchord-sync-backend" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "pydantic" }, + { name = "uvicorn" }, +] + +[package.optional-dependencies] +rethinkdb = [ + { name = "rethinkdb" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.116.0" }, + { name = "pydantic", specifier = ">=2.11.0" }, + { name = "rethinkdb", marker = "extra == 'rethinkdb'", specifier = ">=2.4.10" }, + { name = "uvicorn", specifier = ">=0.35.0" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272 }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, +] + +[[package]] +name = "uvicorn" +version = "0.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783 }, +] diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 6b4c0f7..d57061d 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 8.0 + 13.0 diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/ephemeral/flutter_lldb_helper.py b/ios/Flutter/ephemeral/flutter_lldb_helper.py new file mode 100644 index 0000000..a88caf9 --- /dev/null +++ b/ios/Flutter/ephemeral/flutter_lldb_helper.py @@ -0,0 +1,32 @@ +# +# Generated file, do not edit. +# + +import lldb + +def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict): + """Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages.""" + base = frame.register["x0"].GetValueAsAddress() + page_len = frame.register["x1"].GetValueAsUnsigned() + + # Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the + # first page to see if handled it correctly. This makes diagnosing + # misconfiguration (e.g. missing breakpoint) easier. + data = bytearray(page_len) + data[0:8] = b'IHELPED!' + + error = lldb.SBError() + frame.GetThread().GetProcess().WriteMemory(base, data, error) + if not error.Success(): + print(f'Failed to write into {base}[+{page_len}]', error) + return + +def __lldb_init_module(debugger: lldb.SBDebugger, _): + target = debugger.GetDummyTarget() + # Caveat: must use BreakpointCreateByRegEx here and not + # BreakpointCreateByName. For some reasons callback function does not + # get carried over from dummy target for the later. + bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$") + bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__)) + bp.SetAutoContinue(True) + print("-- LLDB integration loaded --") diff --git a/ios/Flutter/ephemeral/flutter_lldbinit b/ios/Flutter/ephemeral/flutter_lldbinit new file mode 100644 index 0000000..e3ba6fb --- /dev/null +++ b/ios/Flutter/ephemeral/flutter_lldbinit @@ -0,0 +1,5 @@ +# +# Generated file, do not edit. +# + +command script import --relative-to-command-file flutter_lldb_helper.py diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..c9c27c7 --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,41 @@ +platform :ios, '13.0' + +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0' + end + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..a0e34e2 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,121 @@ +PODS: + - audioplayers_darwin (0.0.1): + - Flutter + - FlutterMacOS + - DKImagePickerController/Core (4.3.9): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.9) + - DKImagePickerController/PhotoGallery (4.3.9): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.9) + - DKPhotoGallery (0.0.19): + - DKPhotoGallery/Core (= 0.0.19) + - DKPhotoGallery/Model (= 0.0.19) + - DKPhotoGallery/Preview (= 0.0.19) + - DKPhotoGallery/Resource (= 0.0.19) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.19): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.19): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter + - Flutter (1.0.0) + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - permission_handler_apple (9.3.0): + - Flutter + - receive_sharing_intent (1.8.1): + - Flutter + - record_ios (1.2.0): + - Flutter + - SDWebImage (5.21.7): + - SDWebImage/Core (= 5.21.7) + - SDWebImage/Core (5.21.7) + - share_plus (0.0.1): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - SwiftyGif (5.4.5) + +DEPENDENCIES: + - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) + - file_picker (from `.symlinks/plugins/file_picker/ios`) + - Flutter (from `Flutter`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) + - record_ios (from `.symlinks/plugins/record_ios/ios`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + +SPEC REPOS: + trunk: + - DKImagePickerController + - DKPhotoGallery + - SDWebImage + - SwiftyGif + +EXTERNAL SOURCES: + audioplayers_darwin: + :path: ".symlinks/plugins/audioplayers_darwin/darwin" + file_picker: + :path: ".symlinks/plugins/file_picker/ios" + Flutter: + :path: Flutter + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" + receive_sharing_intent: + :path: ".symlinks/plugins/receive_sharing_intent/ios" + record_ios: + :path: ".symlinks/plugins/record_ios/ios" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + +SPEC CHECKSUMS: + audioplayers_darwin: 4027b33a8f471d996c13f71cb77f0b1583b5d923 + DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c + DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 + file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 + receive_sharing_intent: 79c848f5b045674ad60b9fea3bafea59962ad2c1 + record_ios: 26294aaa39e4bb7665b0fef78bdc23d723b432f2 + SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf + share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f + shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 + +PODFILE CHECKSUM: 9685c422f978e625b905c0c983d663c2de289dfc + +COCOAPODS: 1.15.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 9aa00ae..0356d75 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 46; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -13,6 +13,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + C88FDDAD4C348B31BB3F3D2C /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F15C175C2430CD5EC14B4CE7 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -31,7 +32,9 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 2FAC781980CA86D8ED6838D8 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 67DE8992BF8EA33C49EB5557 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -42,6 +45,8 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D4B3D6E841C0FF6D5E3FD916 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + F15C175C2430CD5EC14B4CE7 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -49,12 +54,21 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C88FDDAD4C348B31BB3F3D2C /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 09F35E9A7360AAE72678E134 /* Frameworks */ = { + isa = PBXGroup; + children = ( + F15C175C2430CD5EC14B4CE7 /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -72,6 +86,8 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, + F4EBE7CC58343850C592F1C8 /* Pods */, + 09F35E9A7360AAE72678E134 /* Frameworks */, ); sourceTree = ""; }; @@ -98,6 +114,17 @@ path = Runner; sourceTree = ""; }; + F4EBE7CC58343850C592F1C8 /* Pods */ = { + isa = PBXGroup; + children = ( + D4B3D6E841C0FF6D5E3FD916 /* Pods-Runner.debug.xcconfig */, + 2FAC781980CA86D8ED6838D8 /* Pods-Runner.release.xcconfig */, + 67DE8992BF8EA33C49EB5557 /* Pods-Runner.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -105,12 +132,15 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 4EDCB63E7AD716ECEC3A4226 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + D6DA809C184AE964E62E477E /* [CP] Embed Pods Frameworks */, + 12DFB11D7EFEBB27E3239BA2 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -127,7 +157,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1020; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -169,12 +199,31 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 12DFB11D7EFEBB27E3239BA2 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( @@ -183,8 +232,31 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 4EDCB63E7AD716ECEC3A4226 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -197,6 +269,23 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + D6DA809C184AE964E62E477E /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -272,7 +361,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -294,7 +383,10 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", @@ -354,7 +446,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -403,7 +495,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -426,7 +518,10 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", @@ -453,7 +548,10 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", @@ -492,4 +590,4 @@ /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; -} \ No newline at end of file +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 1d526a1..919434a 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index a28140c..fc5ae03 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ @@ -45,11 +46,13 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 70693e4..b636303 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,7 +1,7 @@ import UIKit import Flutter -@UIApplicationMain +@main @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, diff --git a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-1024x1024@1x.png index 9f14f8c..d21396b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@1x.png index b24e626..17ab1df 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@2x.png index 09cb633..459a7a6 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@3x.png index 49fd424..a21b393 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@1x.png index cd6404b..94830c9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@2x.png index ecdbf44..3c45345 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@3x.png index c91c453..588077c 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@1x.png index 09cb633..459a7a6 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@2x.png index e8710da..815c318 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@3x.png index df8f1f1..483442e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-60x60@2x.png index df8f1f1..483442e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-60x60@3x.png index 97ed73f..9567d4a 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-76x76@1x.png index b4d734b..fc8eefe 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-76x76@2x.png index 2063f8e..399ff90 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-83.5x83.5@2x.png index fc40604..e8e1f4c 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-1024x1024@1x.png index 74c50c2..6424f58 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-20x20@1x.png index 52a4372..4c20e83 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-20x20@2x.png index 30e3c06..4ab02e8 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-20x20@3x.png index 2693f9a..8b2a6a4 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-29x29@1x.png index cdebe62..a4a8981 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-29x29@2x.png index b0b9c17..97439d4 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-29x29@3x.png index dc5ad22..7db3ad4 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-40x40@1x.png index 30e3c06..4ab02e8 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-40x40@2x.png index dcd398f..4905fad 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-40x40@3x.png index fa92ac4..4a5d3ff 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-60x60@2x.png index fa92ac4..4a5d3ff 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-60x60@3x.png index cb3533f..decd30a 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-76x76@1x.png index 1ef7f3a..ea52eb8 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-76x76@2x.png index b017dd9..54d64bb 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-83.5x83.5@2x.png index 44036dc..e285f53 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/AppIcon-prod-83.5x83.5@2x.png differ diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 2f74184..00d96c2 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -41,5 +41,11 @@ UIViewControllerBasedStatusBarAppearance + NSMicrophoneUsageDescription + SketChord needs microphone access to record audio clips for your notes. + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + diff --git a/lib/audio_ideas.dart b/lib/audio_ideas.dart new file mode 100644 index 0000000..5b76255 --- /dev/null +++ b/lib/audio_ideas.dart @@ -0,0 +1,294 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:sound/dialogs/audio_action_dialog.dart'; +import 'package:sound/dialogs/audio_import_dialog.dart'; +import 'package:sound/editor_views/audio.dart'; +import 'package:sound/file_manager.dart'; +import 'package:sound/local_storage.dart'; +import 'package:sound/model.dart'; +import 'package:sound/recorder_bottom_sheet.dart'; +import 'package:sound/recorder_store.dart'; +import 'package:sound/share.dart'; + +class AudioIdeasPage extends StatefulWidget { + final VoidCallback onMenuPressed; + + const AudioIdeasPage({required this.onMenuPressed, super.key}); + + @override + State createState() => _AudioIdeasPageState(); +} + +class _AudioIdeasPageState extends State { + List _ideas = []; + final TextEditingController _searchController = TextEditingController(); + StreamSubscription? _recordingSub; + bool _loading = true; + bool _searching = false; + String _search = ''; + + @override + void initState() { + super.initState(); + _recordingSub = recorderBottomSheetStore.onRecordingFinished.listen((f) async { + await LocalStorage().addAudioIdea(f); + await _loadIdeas(); + }); + _loadIdeas(); + } + + @override + void dispose() { + _recordingSub?.cancel(); + _searchController.dispose(); + super.dispose(); + } + + Future _loadIdeas() async { + final ideas = await LocalStorage().getAudioIdeas(); + if (!mounted) return; + setState(() { + _ideas = ideas; + _loading = false; + }); + } + + Future _deleteIdea(AudioFile file) async { + await LocalStorage().deleteAudioIdea(file); + await _loadIdeas(); + } + + Future _moveIdea(AudioFile file) async { + await showMoveToNoteDialog(context, () async { + await LocalStorage().deleteAudioIdea(file); + await _loadIdeas(); + }, file); + } + + Future _toggleStar(AudioFile file) async { + file.starred = !file.starred; + await LocalStorage().syncAudioFile(file); + await _loadIdeas(); + } + + Future _renameIdea(AudioFile file) async { + final controller = TextEditingController(text: file.name); + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Rename Idea'), + content: TextField( + controller: controller, + autofocus: true, + maxLines: 1, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () async { + file.name = controller.text.trim().isEmpty + ? 'Untitled idea' + : controller.text.trim(); + await LocalStorage().syncAudioFile(file); + if (mounted) Navigator.pop(context); + await _loadIdeas(); + }, + child: const Text('Apply'), + ) + ], + ), + ); + } + + Future _duplicateIdea(AudioFile file) async { + final copy = await FileManager().copyToNew(file); + copy.starred = file.starred; + copy.text = file.text; + await LocalStorage().addAudioIdea(copy); + await _loadIdeas(); + } + + Future _pickAndImportAudio() async { + final result = await FilePicker.platform.pickFiles( + allowMultiple: true, + type: FileType.custom, + allowedExtensions: ['m4a', 'wav', 'mp3', 'aac'], + ); + if (result == null) return; + final files = result.paths.whereType().map(File.new).toList(); + if (files.isEmpty || !mounted) return; + showAudioImportDialog(context, files); + await Future.delayed(const Duration(milliseconds: 250)); + await _loadIdeas(); + } + + Future _toggleRecording() async { + if (recorderBottomSheetStore.state == RecorderState.recording) { + await stopAction(); + } else { + await startRecordingAction(); + } + } + + List _filteredIdeas() { + final q = _search.trim().toLowerCase(); + if (q.isEmpty) return _ideas; + return _ideas.where((f) { + return f.name.toLowerCase().contains(q) || + f.text.toLowerCase().contains(q) || + f.durationString.toLowerCase().contains(q); + }).toList(); + } + + List _buildIdeaTiles(List files) { + final list = []; + final starred = files.where((f) => f.starred).toList(); + final rest = files.where((f) => !f.starred).toList(); + + void addGroup(String title, List entries) { + if (entries.isEmpty) return; + list.add(Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 4), + child: Row( + children: [ + Text(title, style: Theme.of(context).textTheme.bodySmall), + if (title == 'Starred') + const Padding( + padding: EdgeInsets.only(left: 6), + child: Icon(Icons.star, size: 14), + ), + ], + ), + )); + + for (final file in entries) { + list.add(Dismissible( + key: ValueKey(file.id), + direction: DismissDirection.endToStart, + onDismissed: (_) => _deleteIdea(file), + background: Container( + alignment: Alignment.centerRight, + color: Colors.redAccent, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: const Icon(Icons.delete), + ), + child: ListTile( + leading: IconButton( + icon: const Icon(Icons.play_arrow), + onPressed: () => playInDialog(context, file), + ), + title: Text(file.name), + subtitle: Text(file.durationString), + trailing: PopupMenuButton( + onSelected: (action) async { + if (action == 'share') { + await shareFile(file.path); + } else if (action == 'move') { + await _moveIdea(file); + } else if (action == 'delete') { + await _deleteIdea(file); + } else if (action == 'star') { + await _toggleStar(file); + } else if (action == 'rename') { + await _renameIdea(file); + } else if (action == 'duplicate') { + await _duplicateIdea(file); + } + }, + itemBuilder: (context) => [ + const PopupMenuItem(value: 'share', child: Text('Share')), + const PopupMenuItem(value: 'move', child: Text('Move to Note')), + PopupMenuItem( + value: 'star', + child: Text(file.starred ? 'Unstar' : 'Star'), + ), + const PopupMenuItem(value: 'rename', child: Text('Rename')), + const PopupMenuItem(value: 'duplicate', child: Text('Duplicate')), + const PopupMenuItem(value: 'delete', child: Text('Delete')), + ], + ), + ), + )); + } + } + + if (starred.isNotEmpty) { + addGroup('Starred', starred); + addGroup('Other', rest); + } else { + addGroup('Ideas', files); + } + return list; + } + + @override + Widget build(BuildContext context) { + final recorderStore = context.watch(); + final showSheet = recorderStore.state == RecorderState.recording || + recorderStore.state == RecorderState.playing || + recorderStore.state == RecorderState.pausing; + final ideas = _filteredIdeas(); + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.menu), + onPressed: widget.onMenuPressed, + ), + title: _searching + ? TextField( + controller: _searchController, + autofocus: true, + decoration: const InputDecoration( + border: InputBorder.none, + hintText: 'Search ideas...', + ), + onChanged: (value) => setState(() => _search = value), + ) + : const Text('Ideas'), + actions: [ + IconButton( + icon: Icon(_searching ? Icons.close : Icons.search), + onPressed: () { + setState(() { + if (_searching) { + _searchController.clear(); + _search = ''; + } + _searching = !_searching; + }); + }, + ), + IconButton( + icon: const Icon(Icons.upload_file), + onPressed: _pickAndImportAudio, + ), + ], + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : ideas.isEmpty + ? const Center(child: Text('No audio ideas yet')) + : RefreshIndicator( + onRefresh: _loadIdeas, + child: ListView( + children: _buildIdeaTiles(ideas), + ), + ), + bottomSheet: showSheet ? const RecorderBottomSheet() : null, + floatingActionButton: FloatingActionButton( + onPressed: _toggleRecording, + child: Icon( + recorderStore.state == RecorderState.recording ? Icons.stop : Icons.mic, + ), + ), + ); + } +} diff --git a/lib/audio_list.dart b/lib/audio_list.dart new file mode 100644 index 0000000..0ddbd81 --- /dev/null +++ b/lib/audio_list.dart @@ -0,0 +1,357 @@ +import 'dart:convert'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_flux/flutter_flux.dart'; +import 'package:sound/audio_list_store.dart'; +import 'package:sound/dialogs/audio_action_dialog.dart'; +import 'package:sound/dialogs/import_dialog.dart'; +import 'package:sound/dialogs/permissions_dialog.dart'; +import 'package:sound/editor_views/audio.dart'; +import 'package:sound/file_manager.dart'; +import 'package:sound/local_storage.dart'; +import 'package:sound/main.dart'; +import 'package:sound/menu_store.dart'; +import 'package:sound/model.dart'; +import 'package:sound/note_views/seach.dart'; +import 'package:sound/recorder_bottom_sheet.dart'; +import 'package:sound/recorder_store.dart'; +import 'package:sound/share.dart'; +import 'package:sound/utils.dart'; +import 'package:tuple/tuple.dart'; + +class AudioList extends StatefulWidget { + final Function onMenuPressed; + AudioList({this.onMenuPressed}); + + @override + State createState() { + return AudioListState(); + } +} + +class AudioListState extends State + with StoreWatcherMixin { + GlobalKey _globalKey = GlobalKey(); + AudioListStore store; + MenuStore menuStore; + RecorderBottomSheetStore recorderStore; + List subs = []; + TextEditingController _searchController; + FocusNode searchFocusNode; + AudioFile _highlightAudioFile; + + @override + void initState() { + print("INIT STATE"); + + super.initState(); + + menuStore = listenToStore(menuStoreToken); + + searchFocusNode = new FocusNode(); + store = listenToStore(audioListToken, handleStoreChange); + recorderStore = + listenToStore(recorderBottomSheetStoreToken, handleStoreChange); + + _searchController = TextEditingController.fromValue(TextEditingValue.empty); + + initListeners(); + setState(() {}); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + } + + void handleStoreChange(Store store) { + recordingFinished.clearListeners(); + audioRecordingPermissionDenied.clearListeners(); + initListeners(); + setState(() {}); // TO NOT REMOVE!!!! + } + + void initListeners() { + // recordingFinished.clearListeners(); + recordingFinished.listen((f) async { + print("recording finished ${f.path} with duration ${f.duration}"); + + final player = AudioPlayer(); + await player.setUrl(f.path); + + return Future.delayed( + const Duration(milliseconds: 200), + () async { + f.duration = Duration(milliseconds: await player.getDuration()); + addAudioIdea(f); + }, + ); + }); + + // audioRecordingPermissionDenied.clearListeners(); + subs.add(audioRecordingPermissionDenied.listen((_) { + showHasNoPermissionsDialog(context); + })); + + subs.add(startPlaybackAction.listen((event) { + // find the index + + setState(() { + _highlightAudioFile = recorderStore.currentAudioFile; + }); + })); + + subs.add(toggleAudioIdeasSearch.listen((_) { + if (store.isSearching) { + Future.delayed(Duration(milliseconds: 100), () { + print("REQUESTING FOCUS"); + FocusScope.of(context).requestFocus(searchFocusNode); + }); + } + })); + + subs.add(stopAction.listen((event) { + setState(() {}); + })); + } + + @override + void dispose() { + print("DISPOSE"); + for (ActionSubscription sub in subs) { + if (sub != null) { + sub.cancel(); + } + } + // recorderStore.dispose(); + // store.dispose(); + + // for (var sub in subs) { + // sub.cancel(); + // } + //store.dispose(); + //recorderStore.dispose(); + super.dispose(); + } + + _onMenu() { + if (recorderStore.state == RecorderState.RECORDING) { + showSnackByContext(_globalKey.currentContext, + "You cannot open the menu while you are still recording", + backgroundColor: Theme.of(_globalKey.currentContext).errorColor); + return; + } else { + stopAction(); + } + + if (widget.onMenuPressed != null) { + widget.onMenuPressed(); + } else { + toggleMenu(); + } + } + + showRecordingButton() { + return (recorderStore.state == RecorderState.RECORDING) || + (recorderStore.state == RecorderState.STOP); + } + + _onDelete(AudioFile f) { + deleteAudioIdea(f); + } + + _onMove(AudioFile f) { + showMoveToNoteDialog(context, () { + Navigator.of(context).pop(); + }, f); + } + + _onShare(AudioFile f) { + shareFile(f.path); + } + + _onToggleStarred(AudioFile f) { + toggleStarredAudioIdea(f); + } + + _onRename(AudioFile f, String name) { + renameAudioIdea(Tuple2(f, name)); + } + + _makeAudioFile(AudioFile f, Color color) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: AudioFileView( + index: -1, + backgroundColor: color, + globalKey: _globalKey, + file: f, + onDelete: () => _onDelete(f), + onDuplicate: null, + onToggleStarred: () => _onToggleStarred(f), + onRename: (name) => _onRename(f, name), + onMove: () => _onMove(f), + onShare: () => _onShare(f))); + } + + _searchView() { + return SearchTextView( + toggleIsSearching: ({searching}) { + if (!store.isSearching) { + toggleAudioIdeasSearch(); + } + }, + onChanged: (s) { + setSearchAudioIdeas(s); + setState(() {}); + }, + text: (store.isSearching) ? "Search..." : "Ideas", + focusNode: searchFocusNode, + enabled: store.isSearching, + controller: _searchController); + } + + _makeAudioFileViewList(List files) { + return SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + Color color = (_highlightAudioFile != null && + _highlightAudioFile.id == files[index].id) + ? Theme.of(context).accentColor + : null; + + return _makeAudioFile(files[index], color); + }, childCount: files.length)); + } + + _silver(List files) { + bool isAnyAudioFileStarred() { + return files.any((element) => element.starred); + } + + List noteList = []; + + if (isAnyAudioFileStarred()) { + List items = files.where((n) => !n.starred).toList(); + List starrtedItems = files.where((n) => n.starred).toList(); + + noteList = [ + SliverList( + delegate: SliverChildListDelegate([ + Padding( + padding: EdgeInsets.only(left: 16, top: 16), + child: Row(children: [ + Text("Starred", style: Theme.of(context).textTheme.caption), + Padding( + padding: EdgeInsets.only(left: 8, bottom: 0), + child: Icon(Icons.star, size: 16)) + ])) + ])), + _makeAudioFileViewList(starrtedItems), + SliverList( + delegate: SliverChildListDelegate([ + Padding( + padding: EdgeInsets.only(left: 16), + child: Text("Other", style: Theme.of(context).textTheme.caption)) + ])), + _makeAudioFileViewList(items), + ]; + } else { + noteList = [ + _makeAudioFileViewList(files), + ]; + } + + SliverAppBar appBar = _sliverAppBar(); + + return CustomScrollView( + slivers: [appBar]..addAll(noteList), + ); + } + + _sliverAppBar() { + return SliverAppBar( + titleSpacing: 5.0, + title: Padding( + child: Center(child: _searchView()), + padding: EdgeInsets.only(left: 5)), + leading: IconButton( + icon: store.isSearching ? Icon(Icons.clear) : Icon(Icons.menu), + onPressed: store.isSearching + ? () { + setSearchAudioIdeas(""); + toggleAudioIdeasSearch(); + } + : _onMenu), + actions: store.isSearching + ? [] + : [ + IconButton( + icon: Icon(Icons.search), + onPressed: () { + toggleAudioIdeasSearch(); + }, + ) + ], + floating: false, + pinned: true, + ); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + print("WILL POP"); + return true; + }, + child: ScaffoldMessenger( + child: Scaffold( + key: _globalKey, + bottomSheet: showRecordingButton() + ? null + : RecorderBottomSheet( + key: Key("idea-bottom-sheet"), + showTitle: true, + showRepeat: true, + ), + floatingActionButton: showRecordingButton() + ? FloatingActionButton( + child: (recorderStore.state == RecorderState.RECORDING) + ? Icon(Icons.stop) + : Icon(Icons.mic), + backgroundColor: + (recorderStore.state == RecorderState.RECORDING) + ? Theme.of(context).accentColor + : null, + onPressed: () { + if (recorderStore.state == RecorderState.STOP) { + startRecordingAction(); + } else if (recorderStore.state == + RecorderState.RECORDING) { + stopAction(); + } + }) + : null, + body: FutureBuilder>( + initialData: [], + future: LocalStorage().getAudioIdeas(), + builder: (context, AsyncSnapshot> snap) { + List files = snap.data; + + if (store.isSearching && store.search.trim() != "") { + var search = store.search.toLowerCase(); + files = files + .where((element) => jsonEncode(element.toJson()) + .toLowerCase() + .contains(search)) + .toList(); + } + setQueue(files); + + print("RERENDER"); + return _silver(files); + }))), + ); + } +} diff --git a/lib/audio_list_store.dart b/lib/audio_list_store.dart new file mode 100644 index 0000000..d4555d4 --- /dev/null +++ b/lib/audio_list_store.dart @@ -0,0 +1,62 @@ +import 'package:flutter_flux/flutter_flux.dart' show Action, Store, StoreToken; +import 'package:sound/local_storage.dart'; +import 'package:sound/model.dart'; +import 'package:tuple/tuple.dart'; + +class AudioListStore extends Store { + // default values + + String _search; + bool _searching; + + bool get isSearching => _searching; + String get search => _search; + + AudioListStore() { + // init listener + _searching = false; + + addAudioIdea.listen((AudioFile f) async { + int row = await LocalStorage().addAudioIdea(f); + print("Added audio file row $row"); + trigger(); + }); + + deleteAudioIdea.listen((AudioFile f) async { + await LocalStorage().deleteAudioIdea(f); + trigger(); + }); + + toggleStarredAudioIdea.listen((AudioFile f) async { + f.starred = !f.starred; + await LocalStorage().syncAudioFile(f); + trigger(); + }); + + renameAudioIdea.listen((Tuple2 r) async { + r.item1.name = r.item2; + await LocalStorage().syncAudioFile(r.item1); + trigger(); + }); + + setSearchAudioIdeas.listen((s) { + _search = s; + trigger(); + }); + toggleAudioIdeasSearch.listen((s) { + _searching = !_searching; + _search = ""; + trigger(); + }); + } +} + +Action addAudioIdea = Action(); +Action deleteAudioIdea = Action(); +Action setSearchAudioIdeas = Action(); +Action toggleAudioIdeasSearch = Action(); +Action toggleStarredAudioIdea = Action(); +Action> renameAudioIdea = + Action>(); + +StoreToken audioListToken = StoreToken(AudioListStore()); diff --git a/lib/backup.dart b/lib/backup.dart index 50d9763..986e26f 100644 --- a/lib/backup.dart +++ b/lib/backup.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; -import 'package:archive/archive.dart'; import 'package:archive/archive_io.dart'; import 'package:file_picker/file_picker.dart'; import 'package:path_provider/path_provider.dart'; @@ -16,6 +15,15 @@ class ImportException implements Exception { } class Backup { + Future _pickFilePath( + {required FileType type, List? allowedExtensions}) async { + final result = await FilePicker.platform.pickFiles( + type: type, + allowedExtensions: allowedExtensions, + ); + return result?.files.single.path; + } + Future getPermissions() async { return await Permission.storage.request().isGranted; } @@ -33,23 +41,21 @@ class Backup { } Future> importZip() async { - File f = await FilePicker.getFile( - type: FileType.custom, - allowedExtensions: ['zip'], - ); - return await readZip(f.path); + final path = + await _pickFilePath(type: FileType.custom, allowedExtensions: ['zip']); + if (path == null) return []; + return readZip(path); } Future> import() async { - File f = await FilePicker.getFile( - type: FileType.any, - // allowedExtensions: ['zip', 'json'], - ); - if (f.path.endsWith(".json")) { - Note note = readNote(f.path); + final path = await _pickFilePath(type: FileType.any); + if (path == null) return []; + if (path.endsWith(".json")) { + Note? note = readNote(path); + if (note == null) return []; return [note]; - } else if (f.path.endsWith(".zip")) { - return await readZip(f.path); + } else if (path.endsWith(".zip")) { + return await readZip(path); } return []; @@ -71,22 +77,20 @@ class Backup { print(f.name); } final noteListFile = archive.files - .firstWhere((a) => a.name == NOTES_FILENAME, orElse: () => null); - - if (noteListFile == null) { - print("cannot find note list"); - throw new ImportException(); - } + .where((a) => a.name == NOTES_FILENAME) + .cast() + .firstWhere((a) => a != null, orElse: () => null); + if (noteListFile == null) throw ImportException(); final noteIds = jsonDecode(decodeZipContent(noteListFile)); print("zip contains $noteIds"); for (String noteId in noteIds) { - var noteFile = archive.files - .firstWhere((a) => a.name == "$noteId.json", orElse: () => null); - + final noteFile = archive.files + .where((a) => a.name == "$noteId.json") + .cast() + .firstWhere((a) => a != null, orElse: () => null); if (noteFile == null) { - print("cannot find note with id $noteId"); - throw new ImportException(); + continue; } var noteMap = jsonDecode(decodeZipContent(noteFile)); Note note = Note.fromJson(noteMap, noteMap['id']); @@ -100,7 +104,7 @@ class Backup { return notes; } - Note readNote(String path) { + Note? readNote(String path) { String data = File(path).readAsStringSync(); try { var jsonData = jsonDecode(data); @@ -119,12 +123,10 @@ class Backup { } Future importNote() async { - File f = await FilePicker.getFile( - type: FileType.custom, - allowedExtensions: ['json'], - ); - - return readNote(f.path); + final path = + await _pickFilePath(type: FileType.custom, allowedExtensions: ['json']); + if (path == null) return Note.empty(); + return readNote(path) ?? Note.empty(); } Future exportZip(List notes) async { @@ -146,7 +148,7 @@ class Backup { encoder.create(path); } on FileSystemException catch (e) { print("cannot create zip at location $path ${e.message}"); - return null; + return ''; } for (Note note in notes) { diff --git a/lib/collection_editor_store.dart b/lib/collection_editor_store.dart new file mode 100644 index 0000000..37d485f --- /dev/null +++ b/lib/collection_editor_store.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart' show Color; +import 'package:flutter_flux/flutter_flux.dart' show Action, Store, StoreToken; +import '../local_storage.dart'; +import '../file_manager.dart'; +import '../model.dart'; +import 'package:tuple/tuple.dart'; + +class CollectionEditorStore extends Store { + NoteCollection _collection; + NoteCollection get collection => _collection; + + Tuple2 _lastDeletion; + + void setCollection(NoteCollection c) { + _collection = c; + } + + CollectionEditorStore() { + editorSetCollection.listen((c) { + _collection = c; + trigger(); + }); + + removeNoteFromCollection.listen((note) async { + int index = _collection.notes.indexWhere((n) => n.id == note.id); + _collection.notes.removeAt(index); + _lastDeletion = Tuple2(index, note); + await LocalStorage().syncCollection(_collection); + + trigger(); + }); + + undoRemoveNoteFromCollection.listen((_) async { + _collection.notes.insert(_lastDeletion.item1, _lastDeletion.item2); + await LocalStorage().syncCollection(_collection); + trigger(); + }); + + moveNoteUp.listen((Note note) async { + int index = _collection.notes.indexOf(note); + + if (index >= 1) { + print("move up with index $index"); + _collection.notes.removeAt(index); + _collection.notes.insert(index - 1, note); + await LocalStorage().syncCollection(_collection); + trigger(); + } + }); + moveNoteDown.listen((Note note) async { + int index = _collection.notes.indexOf(note); + + if (index != (collection.notes.length - 1) && index >= 0) { + print('move down with index: $index'); + _collection.notes.removeAt(index); + _collection.notes.insert(index + 1, note); + await LocalStorage().syncCollection(_collection); + } + trigger(); + }); + + changeCollectionTitle.listen((t) async { + _collection.title = t; + print('chaning title...'); + await LocalStorage().syncCollection(_collection); + trigger(); + }); + + changeCollectionDescription.listen((t) async { + _collection.description = t; + print('chaning description...'); + await LocalStorage().syncCollection(_collection); + trigger(); + }); + + updateCollectionEditorView.listen((_) { + trigger(); + }); + + toggleCollectionStarred.listen((event) async { + _collection.starred = !_collection.starred; + await LocalStorage().syncCollection(_collection); + trigger(); + }); + + addNoteToCollection.listen((Note note) async { + _collection.notes.add(note); + await LocalStorage().syncCollection(_collection); + trigger(); + }); + + addNotesToCollection.listen((List notes) async { + _collection.notes.addAll(notes); + await LocalStorage().syncCollection(_collection); + trigger(); + }); + + setNotesOfCollection.listen((List notes) async { + _collection.notes = notes; + await LocalStorage().syncCollection(_collection); + trigger(); + }); + } +} + +Action editorSetCollection = Action(); +Action removeNoteFromCollection = Action(); +Action> setNotesOfCollection = Action(); +Action changeCollectionDescription = Action(); +Action changeCollectionTitle = Action(); +Action moveNoteDown = Action(); +Action moveNoteUp = Action(); +Action addNoteToCollection = Action(); +Action> addNotesToCollection = Action(); +Action undoRemoveNoteFromCollection = Action(); +Action toggleCollectionStarred = Action(); +Action updateCollectionEditorView = Action(); + +StoreToken collectionEditorStoreToken = StoreToken(CollectionEditorStore()); diff --git a/lib/collections.dart b/lib/collections.dart new file mode 100644 index 0000000..d3f129e --- /dev/null +++ b/lib/collections.dart @@ -0,0 +1,732 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_flux/flutter_flux.dart'; +import 'package:sound/collection_editor_store.dart'; +import 'package:sound/db.dart'; +import 'package:sound/dialogs/choose_note_dialog.dart'; +import 'package:sound/dialogs/confirmation_dialogs.dart'; +import 'package:sound/dialogs/export_dialog.dart'; +import 'package:sound/export.dart'; +import 'package:sound/local_storage.dart'; +import 'package:sound/model.dart'; +import 'package:sound/collections_store.dart'; +import 'package:sound/note_search_view.dart'; +import 'package:sound/note_viewer.dart'; +import 'package:sound/utils.dart'; + +class NoteCollectionItemModel { + final NoteCollection collection; + final bool isSelected; + + const NoteCollectionItemModel({this.collection, this.isSelected}); +} + +class NoteCollectionList extends StatelessWidget { + final bool singleView; + final ValueChanged onTap; + final ValueChanged onLongPress; + final List items; + + NoteCollectionList(this.items, this.onTap, this.onLongPress, + {this.singleView = true}); + + List processList( + List data, bool even) { + List returns = []; + + for (int i = 0; i < data.length; i++) { + if (even && i % 2 == 0) returns.add(data[i]); + if (!even && i % 2 != 0) returns.add(data[i]); + } + return returns; + } + + _getItem(double width, int index, {double padding = 8}) { + if (!singleView) { + return Padding( + padding: EdgeInsets.all(padding), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + Flexible( + flex: 1, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Wrap( + direction: Axis.vertical, + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.start, + children: processList(items, true) + .map((i) => SmallNoteCollectionItem( + i.collection, + i.isSelected, + () => onTap(i.collection), + () => onLongPress(i.collection), + width / 2 - padding, + EdgeInsets.only(left: 0))) + .toList(), + ) + ])), + Flexible( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Wrap( + direction: Axis.vertical, + alignment: WrapAlignment.spaceEvenly, + children: processList(items, false) + .map((i) => SmallNoteCollectionItem( + i.collection, + i.isSelected, + () => onTap(i.collection), + () => onLongPress(i.collection), + width / 2 - padding, + EdgeInsets.only(left: 0))) + .toList(), + ) + ])) + ])); + } else { + var item = items[index]; + + return Padding( + padding: EdgeInsets.only( + left: 0, + right: 0, + top: index == 0 ? padding : 0, + bottom: index == items.length - 1 ? padding : 0), + child: NoteCollectionItem(item.collection, item.isSelected, + () => onTap(item.collection), () => onLongPress(item.collection), + padding: 0)); + } + } + + _body(BuildContext context) { + double width = MediaQuery.of(context).size.width; + int childCount = (singleView) ? items.length : 1; + + return SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + return _getItem(width, index); + }, childCount: childCount)); + } + + @override + Widget build(BuildContext context) { + return _body(context); + } +} + +class NoteCollectionItem extends StatelessWidget { + final NoteCollection collection; + final bool isSelected; + final Function onTap, onLongPress; + final double padding; + + const NoteCollectionItem( + this.collection, this.isSelected, this.onTap, this.onLongPress, + {this.padding = 0, Key key}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.all(padding), + child: ListTile( + onTap: onTap, + onLongPress: onLongPress, + title: Text( + this.collection.title == "" ? "EMPTY" : this.collection.title), + trailing: Text(this.collection.activeNotes.length.toString()), + tileColor: isSelected ? getSelectedCardColor(context) : null, + subtitle: this.collection.description == null || + this.collection.description == "" + ? Text("-") + : (Text(this.collection.description)), + )); + } +} + +class SmallNoteCollectionItem extends StatelessWidget { + final NoteCollection collection; + final bool isSelected; + final EdgeInsets padding; + final Function onTap, onLongPress; + final double width; + + const SmallNoteCollectionItem(this.collection, this.isSelected, this.onTap, + this.onLongPress, this.width, this.padding, + {Key key}) + : super(key: key); + + bool get empty => ((collection.title == null || + collection.title.trim() == "") && + (collection.description == null || collection.description.trim() == "") && + collection.notes.length == 0); + + @override + Widget build(BuildContext context) { + Widget child = Card( + color: null, + child: Container( + decoration: isSelected + ? getSelectedDecoration(context) + : getNormalDecoration(context), + child: Padding( + padding: EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(bottom: 10), + child: Text(collection.title)), + Padding( + child: Text( + collection.notes.length.toString(), + textScaleFactor: 2.0, + ), + padding: EdgeInsets.only(top: 16)) + ])))); + List stackChildren = []; + stackChildren.add(child); + + return GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + child: Container( + width: this.width, + height: (empty) ? 50 : null, + padding: this.padding, + child: Stack(children: stackChildren))); + } +} + +class CollectionEditor extends StatefulWidget { + final NoteCollection collection; + final bool allowEdit; + + const CollectionEditor(this.collection, {this.allowEdit = true, Key key}) + : super(key: key); + + @override + _CollectionEditorState createState() => _CollectionEditorState(); +} + +class _CollectionEditorState extends State + with StoreWatcherMixin { + CollectionEditorStore store; + Map dismissables = {}; + + @override + void initState() { + super.initState(); + store = listenToStore(collectionEditorStoreToken); + store.setCollection(widget.collection); + } + + _edit({initial, title, hint, onChanged, maxlines = 1}) { + return TextFormField( + initialValue: initial, + decoration: InputDecoration( + labelText: title, border: InputBorder.none, hintText: hint), + enabled: widget.allowEdit, + onChanged: (v) => onChanged(v), + maxLines: maxlines); + } + + floatingActionButtonPressed() async { + // add a note to this collection + Navigator.push( + context, + new MaterialPageRoute( + builder: (context) => NoteSearchViewLoader( + collection: store.collection, + onAddNotes: (List notes) { + addNotesToCollection(notes); + }, + ))); + + // showAddNotesDialog( + // context: context, + // notes: notes, + // preselected: store.collection.notes, + // onImport: (notes) async { + // setNotesOfCollection(notes); + // }); + } + + _onPlay() { + Navigator.of(context).push(MaterialPageRoute(builder: (context) { + return NoteCollectionViewer(store.collection); + })); + } + + _onExport() { + String title = + (store.collection.title.trim() == "" ? null : store.collection.title); + showExportDialog(context, store.collection.notes, + collections: [store.collection], title: title); + } + + _onSharePDF() async { + String title = + (store.collection.title.trim() == "" ? null : store.collection.title); + await Exporter.exportShare(store.collection.notes, ExportType.PDF, + title: title); + } + + _onDelete() { + showNoteCollectionDeleteDialog(context, store.collection, () async { + await LocalStorage().deleteCollection(store.collection); + Navigator.of(context).pop(); + }); + } + + _runPopupAction(String action) { + switch (action) { + case "delete": + _onDelete(); + break; + case "export": + _onExport(); + break; + default: + break; + } + } + + @override + Widget build(BuildContext context) { + var notes = []; + var activeNotes = store.collection.activeNotes; + + for (var i = 0; i < activeNotes.length; i++) { + if (!dismissables.containsKey(activeNotes[i])) + dismissables[activeNotes[i]] = GlobalKey(); + + bool showMoveUp = (i != 0); + bool showMoveDown = (i != (activeNotes.length - 1)); + notes.add(CollecitonNoteListItem( + idx: i + 1, + globalKey: dismissables[activeNotes[i]], + note: activeNotes[i], + moveDown: showMoveDown, + moveUp: showMoveUp)); + } + + var titleEdit = Padding( + padding: EdgeInsets.only(left: 10, top: 10), + child: Wrap(runSpacing: 1, children: [ + _edit( + initial: store.collection.title, + title: "Title", + hint: "Title...", + onChanged: changeCollectionTitle), + _edit( + initial: store.collection.description, + title: "Description", + hint: "Description...", + onChanged: changeCollectionDescription, + maxlines: 1), + (store.collection.lengthStr == "") + ? Container() + : Padding( + padding: EdgeInsets.only(top: 8), + child: Text("Length: " + store.collection.lengthStr)), + Padding( + padding: EdgeInsets.only(top: 16, bottom: 8), + child: Row(children: [ + Text("Notes", style: Theme.of(context).textTheme.caption) + ])), + ...notes + ])); + + List items = [ + titleEdit, + ]; + + List stackChildren = []; + + stackChildren.add(Container( + padding: EdgeInsets.all(16), + child: ListView.builder( + itemBuilder: (context, index) => items[index], + itemCount: items.length, + ))); + + List actions = [ + IconButton(icon: Icon(Icons.share), onPressed: _onSharePDF), + //IconButton(icon: Icon(Icons.share), onPressed: _onExport), + IconButton(icon: Icon(Icons.play_circle), onPressed: _onPlay), + IconButton( + icon: + Icon((store.collection.starred) ? Icons.star : Icons.star_border), + onPressed: toggleCollectionStarred), + ]; + + List popupMenuActions = ["export", "delete"]; + List popupMenuActionsLong = [ + "Export", + "Delete", + ]; + + PopupMenuButton popup = PopupMenuButton( + onSelected: _runPopupAction, + itemBuilder: (context) { + return popupMenuActions.map>((String action) { + int index = popupMenuActions.indexOf(action); + + return PopupMenuItem( + value: action, child: Text(popupMenuActionsLong[index])); + }).toList(); + }, + ); + + return WillPopScope( + onWillPop: () async { + if (store.collection.empty) { + print("delete collection"); + LocalStorage().deleteCollection(store.collection); + } else { + print("sync collection"); + LocalStorage().syncCollection(store.collection); + } + return true; + }, + child: ScaffoldMessenger( + child: Scaffold( + floatingActionButton: FloatingActionButton( + onPressed: floatingActionButtonPressed, + child: IconButton( + icon: Icon(Icons.add), + onPressed: floatingActionButtonPressed, + ), + ), + appBar: AppBar( + //backgroundColor: store.note.color, + actions: actions..add(popup), + title: Text("Edit Set"), + ), + body: Container(child: Stack(children: stackChildren))), + )); + } +} + +class AroundText extends StatelessWidget { + final String text; + final double radius; + final BoxShape shape; + const AroundText( + {this.text, this.radius = 30, this.shape = BoxShape.rectangle, Key key}) + : super(key: key); + + @override + Widget build(BuildContext context) { + Color background = Theme.of(context).appBarTheme.backgroundColor; + background = Theme.of(context).accentColor; + return Container( + width: this.radius, + height: this.radius, + decoration: BoxDecoration( + border: Border.all(width: 1, color: background), + shape: BoxShape.rectangle, + // You can use like this way or like the below line + //borderRadius: new BorderRadius.circular(30.0), + //color: Theme.of(context).appBarTheme.backgroundColor, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(this.text), + ], + ), + ); + } +} + +class CollecitonNoteListItem extends StatelessWidget { + // Section section, bool moveDown, bool moveUp, GlobalKey globalKey) { + final Note note; + final int idx; + final bool moveDown, moveUp; + final GlobalKey globalKey; + + const CollecitonNoteListItem( + {this.note, + this.idx, + this.moveUp, + this.moveDown, + this.globalKey, + Key key}) + : super(key: key); + + @override + Widget build(BuildContext context) { + List trailingWidgets = []; + if (moveDown) + trailingWidgets.add(IconButton( + icon: Icon(Icons.arrow_drop_down), + onPressed: () => moveNoteDown(note))); + + if (moveUp) + trailingWidgets.add(IconButton( + icon: Icon(Icons.arrow_drop_up), + onPressed: () => moveNoteUp(note), + )); + + Widget trailing = Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: trailingWidgets); + + Card card = Card( + child: Container( + padding: EdgeInsets.zero, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.only(left: 10, top: 10), + child: AroundText(text: idx.toString())), + Expanded( + child: Container( + padding: EdgeInsets.all(15), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + note.hasEmptyTitle + ? EMPTY_TEXT.toUpperCase() + : note.title, + style: Theme.of(context).textTheme.subtitle1), + ]))), + trailing + ], + ))); + + return Dismissible( + child: card, + onDismissed: (d) { + removeNoteFromCollection(note); + + showUndoSnackbar( + context: context, + message: + "Removed ${note.hasEmptyTitle ? "Note" : note.title} from collection", + data: note, + onUndo: (_) { + undoRemoveNoteFromCollection(note); + }); + }, + direction: DismissDirection.startToEnd, + key: globalKey, + background: Card( + child: Container( + color: Theme.of(context).accentColor, + child: Row(children: [Icon(Icons.delete)]), + padding: EdgeInsets.all(8))), + ); + } +} + +class Collections extends StatelessWidget { + final Function onMenuPressed; + + Collections(this.onMenuPressed); + + _floatingButtonPress(BuildContext context) { + NoteCollection collection = NoteCollection.empty(); + LocalStorage().syncCollection(collection); + Navigator.push( + context, + new MaterialPageRoute( + builder: (context) => CollectionEditor(collection))); + } + + @override + Widget build(BuildContext context) { + LocalStorage() + .getCollections() + .then((value) => LocalStorage().collectionController.sink.add(value)); + + var builder = StreamBuilder>( + stream: LocalStorage().collectionStream, + initialData: [], + builder: (context, snap) { + if (snap.hasData) { + print("DB Set collections ${snap.data.length}"); + DB().setCollections(snap.data); + return CollectionsContent(onMenuPressed: this.onMenuPressed); + } else { + return CircularProgressIndicator(); + } + }, + ); + return Scaffold( + floatingActionButton: FloatingActionButton( + onPressed: () => _floatingButtonPress(context), + child: IconButton( + onPressed: () => _floatingButtonPress(context), + icon: Icon(Icons.add), + ), + ), + //bottomSheet: RecorderBottomSheet(), + body: builder); + } +} + +class CollectionsContent extends StatefulWidget { + final Function onMenuPressed; + final NoteListType listType; + + CollectionsContent( + {Key key, this.onMenuPressed, this.listType = NoteListType.double}) + : super(key: key); + + @override + _CollectionsContentState createState() => _CollectionsContentState(); +} + +class _CollectionsContentState extends State + with StoreWatcherMixin, SingleTickerProviderStateMixin { + CollectionsStore storage; + + @override + void initState() { + super.initState(); + storage = listenToStore(collectionsStoreToken); + } + + bool get singleView => true; + + _sliverNoteSelectionAppBar() { + return SliverAppBar( + pinned: true, + leading: IconButton( + icon: Icon(Icons.clear), onPressed: () => clearCollectionSelection()), + title: Text(storage.selectedCollections.length.toString()), + actions: [ + IconButton( + icon: Icon(Icons.delete), + onPressed: () => removeAllSelectedCollections()), + IconButton( + icon: Icon((storage.selectedCollections + .where((e) => e.starred) + .toList() + .length + .toDouble() / + storage.selectedCollections.length.toDouble()) < + 0.5 + ? Icons.star + : Icons.star_border), + onPressed: () { + if ((storage.selectedCollections + .where((e) => e.starred) + .toList() + .length + .toDouble() / + storage.selectedCollections.length.toDouble()) < + 0.5) { + starAllSelectedCollections(); + } else { + unstarAllSelectedCollections(); + } + }), + ], + ); + } + + _sliver() { + onTap(NoteCollection collection) { + if (storage.isAnyCollectionSelected()) { + triggerSelectCollection(collection); + } else { + Navigator.push( + context, + new MaterialPageRoute( + builder: (context) => CollectionEditor(collection))); + } + } + + onLongPress(NoteCollection collection) { + triggerSelectCollection(collection); + } + + List noteList = []; + + if (storage.isAnyCollectionStarred()) { + print("notes are starred"); + List items = storage.filteredCollections + .where((n) => !n.starred) + .map((n) => NoteCollectionItemModel( + collection: n, isSelected: storage.isSelected(n))) + .toList(); + + List starrtedItems = storage.filteredCollections + .where((n) => n.starred) + .map((n) => NoteCollectionItemModel( + collection: n, isSelected: storage.isSelected(n))) + .toList(); + + noteList = [ + SliverList( + delegate: SliverChildListDelegate([ + Padding( + padding: EdgeInsets.only(left: 16, top: 16), + child: Row(children: [ + Text("Starred", style: Theme.of(context).textTheme.caption), + Padding( + padding: EdgeInsets.only(left: 8, bottom: 0), + child: Icon(Icons.star, size: 16)) + ])) + ])), + NoteCollectionList(starrtedItems, onTap, onLongPress, + singleView: singleView), + SliverList( + delegate: SliverChildListDelegate([ + Padding( + padding: EdgeInsets.only(left: 16), + child: Text("Other", style: Theme.of(context).textTheme.caption)) + ])), + NoteCollectionList(items, onTap, onLongPress, singleView: singleView) + ]; + } else { + List items = storage.filteredCollections + .map((n) => NoteCollectionItemModel( + collection: n, isSelected: storage.isSelected(n))) + .toList(); + + noteList = [ + NoteCollectionList(items, onTap, onLongPress, singleView: singleView) + ]; + } + + SliverAppBar appBar = storage.isAnyCollectionSelected() + ? _sliverNoteSelectionAppBar() + : _sliverAppBar(); + + return CustomScrollView( + slivers: [appBar]..addAll(noteList), + ); + } + + _sliverAppBar() { + return SliverAppBar( + titleSpacing: 5.0, + leading: + IconButton(icon: Icon(Icons.menu), onPressed: widget.onMenuPressed), + title: Center( + child: Align(child: Text("Sets"), alignment: Alignment.centerLeft)), + floating: false, + pinned: true, + ); + } + + @override + Widget build(BuildContext context) { + return _sliver(); + } +} diff --git a/lib/collections_page.dart b/lib/collections_page.dart new file mode 100644 index 0000000..2ba1793 --- /dev/null +++ b/lib/collections_page.dart @@ -0,0 +1,510 @@ +import 'package:flutter/material.dart'; +import 'package:sound/backup.dart'; +import 'package:sound/local_storage.dart'; +import 'package:sound/model.dart'; +import 'package:sound/note_editor.dart'; +import 'package:sound/note_viewer.dart'; +import 'package:sound/share.dart'; + +Future showAddNoteToSetDialog(BuildContext context, Note note) async { + final collections = await LocalStorage().getCollections(); + if (!context.mounted) return; + + final selected = {}; + for (final c in collections) { + if (c.notes.any((n) => n.id == note.id)) { + selected.add(c.id); + } + } + + await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: const Text('Add to Set'), + content: SizedBox( + width: 460, + height: 420, + child: collections.isEmpty + ? const Center(child: Text('No sets yet')) + : ListView.builder( + itemCount: collections.length, + itemBuilder: (context, index) { + final collection = collections[index]; + return CheckboxListTile( + value: selected.contains(collection.id), + title: Text( + collection.title.isEmpty ? 'Untitled Set' : collection.title, + ), + subtitle: Text('${collection.notes.length} notes'), + onChanged: (value) { + setState(() { + if (value ?? false) { + selected.add(collection.id); + } else { + selected.remove(collection.id); + } + }); + }, + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () async { + for (final c in collections) { + final has = c.notes.any((n) => n.id == note.id); + final selectedNow = selected.contains(c.id); + if (selectedNow && !has) { + c.notes.add(note); + await LocalStorage().syncCollection(c); + } + if (!selectedNow && has) { + c.notes.removeWhere((n) => n.id == note.id); + await LocalStorage().syncCollection(c); + } + } + if (context.mounted) Navigator.pop(context); + }, + child: const Text('Apply'), + ), + ], + ), + ), + ); +} + +class CollectionsPage extends StatefulWidget { + final VoidCallback onMenuPressed; + + const CollectionsPage({required this.onMenuPressed, super.key}); + + @override + State createState() => _CollectionsPageState(); +} + +class _CollectionsPageState extends State { + List _collections = []; + final Set _selectedIds = {}; + bool _loading = true; + bool _searching = false; + String _search = ''; + final TextEditingController _searchController = TextEditingController(); + + bool get _selectionMode => _selectedIds.isNotEmpty; + + @override + void initState() { + super.initState(); + _loadCollections(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _loadCollections() async { + final collections = await LocalStorage().getCollections(); + if (!mounted) return; + setState(() { + _collections = collections; + _loading = false; + }); + } + + Future _createCollection() async { + final collection = NoteCollection.empty(); + await LocalStorage().syncCollection(collection); + await _openCollection(collection); + } + + Future _openCollection(NoteCollection collection) async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CollectionEditorPage(collection: collection), + ), + ); + await _loadCollections(); + } + + void _toggleSelection(NoteCollection c) { + setState(() { + if (_selectedIds.contains(c.id)) { + _selectedIds.remove(c.id); + } else { + _selectedIds.add(c.id); + } + }); + } + + Future _deleteSelected() async { + final selected = + _collections.where((c) => _selectedIds.contains(c.id)).toList(); + for (final c in selected) { + await LocalStorage().deleteCollection(c); + } + _selectedIds.clear(); + await _loadCollections(); + } + + Future _setStarSelected(bool starred) async { + for (final c in _collections.where((c) => _selectedIds.contains(c.id))) { + c.starred = starred; + await LocalStorage().syncCollection(c); + } + _selectedIds.clear(); + await _loadCollections(); + } + + List _filteredCollections() { + final q = _search.trim().toLowerCase(); + if (q.isEmpty) return _collections; + return _collections.where((c) { + return c.title.toLowerCase().contains(q) || + c.description.toLowerCase().contains(q); + }).toList(); + } + + @override + Widget build(BuildContext context) { + final collections = _filteredCollections(); + final starred = collections.where((c) => c.starred).toList(); + final unstarred = collections.where((c) => !c.starred).toList(); + + PreferredSizeWidget appBar; + if (_selectionMode) { + appBar = AppBar( + leading: IconButton( + icon: const Icon(Icons.clear), + onPressed: () => setState(() => _selectedIds.clear()), + ), + title: Text('${_selectedIds.length}'), + actions: [ + IconButton( + icon: const Icon(Icons.star), + onPressed: () => _setStarSelected(true), + ), + IconButton( + icon: const Icon(Icons.star_border), + onPressed: () => _setStarSelected(false), + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: _deleteSelected, + ), + ], + ); + } else { + appBar = AppBar( + leading: IconButton( + icon: const Icon(Icons.menu), + onPressed: widget.onMenuPressed, + ), + title: _searching + ? TextField( + controller: _searchController, + autofocus: true, + decoration: const InputDecoration( + border: InputBorder.none, + hintText: 'Search sets...', + ), + onChanged: (v) => setState(() => _search = v), + ) + : const Text('Sets'), + actions: [ + IconButton( + icon: Icon(_searching ? Icons.close : Icons.search), + onPressed: () { + setState(() { + if (_searching) { + _searchController.clear(); + _search = ''; + } + _searching = !_searching; + }); + }, + ), + IconButton(icon: const Icon(Icons.add), onPressed: _createCollection), + ], + ); + } + + Widget list(List data, {String? title}) { + if (data.isEmpty) return const SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Text(title, style: Theme.of(context).textTheme.bodySmall), + ), + ...data.map((c) { + final selected = _selectedIds.contains(c.id); + return ListTile( + tileColor: selected + ? Theme.of(context).colorScheme.surfaceContainerHighest + : null, + title: Text(c.title.isEmpty ? 'Untitled Set' : c.title), + subtitle: Text( + c.description.isEmpty + ? '${c.activeNotes.length} notes' + : '${c.description} • ${c.activeNotes.length} notes', + ), + leading: c.starred ? const Icon(Icons.star, size: 18) : null, + trailing: const Icon(Icons.chevron_right), + onLongPress: () => _toggleSelection(c), + onTap: () { + if (_selectionMode) { + _toggleSelection(c); + } else { + _openCollection(c); + } + }, + ); + }), + ], + ); + } + + return Scaffold( + appBar: appBar, + body: _loading + ? const Center(child: CircularProgressIndicator()) + : collections.isEmpty + ? const Center(child: Text('No sets yet')) + : RefreshIndicator( + onRefresh: _loadCollections, + child: ListView( + children: [ + if (starred.isNotEmpty) list(starred, title: 'Starred'), + list(unstarred, title: starred.isNotEmpty ? 'Other' : null), + ], + ), + ), + ); + } +} + +class CollectionEditorPage extends StatefulWidget { + final NoteCollection collection; + + const CollectionEditorPage({required this.collection, super.key}); + + @override + State createState() => _CollectionEditorPageState(); +} + +class _CollectionEditorPageState extends State { + late NoteCollection _collection; + late TextEditingController _titleController; + late TextEditingController _descriptionController; + + @override + void initState() { + super.initState(); + _collection = widget.collection; + _titleController = TextEditingController(text: _collection.title); + _descriptionController = TextEditingController(text: _collection.description); + } + + @override + void dispose() { + _titleController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + Future _save() async { + _collection.title = _titleController.text; + _collection.description = _descriptionController.text; + await LocalStorage().syncCollection(_collection); + } + + Future _deleteCollection() async { + await LocalStorage().deleteCollection(_collection); + if (mounted) Navigator.pop(context); + } + + Future _toggleStar() async { + _collection.starred = !_collection.starred; + await _save(); + setState(() {}); + } + + Future _exportCollectionZip() async { + final notes = _collection.activeNotes; + if (notes.isEmpty) return; + final path = await Backup().exportZip(notes); + if (path.isNotEmpty) { + await shareFile(path, + filename: '${_collection.title.isEmpty ? "set" : _collection.title}.zip'); + } + } + + Future _addNotes() async { + final notes = await LocalStorage().getActiveNotes(); + if (!mounted) return; + + final selected = _collection.notes.map((n) => n.id).toSet(); + await showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + title: const Text('Add notes'), + content: SizedBox( + width: 460, + height: 420, + child: ListView.builder( + itemCount: notes.length, + itemBuilder: (context, index) { + final note = notes[index]; + final checked = selected.contains(note.id); + return CheckboxListTile( + value: checked, + title: Text(note.title.isEmpty ? 'Untitled Note' : note.title), + onChanged: (value) { + setState(() { + if (value ?? false) { + selected.add(note.id); + } else { + selected.remove(note.id); + } + }); + }, + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () async { + _collection.notes = + notes.where((n) => selected.contains(n.id)).toList(); + await _save(); + if (mounted) Navigator.pop(context); + setState(() {}); + }, + child: const Text('Apply'), + ), + ], + ); + }, + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Edit Set'), + actions: [ + IconButton( + icon: Icon(_collection.starred ? Icons.star : Icons.star_border), + onPressed: _toggleStar, + ), + IconButton(icon: const Icon(Icons.upload_file), onPressed: _exportCollectionZip), + IconButton(icon: const Icon(Icons.playlist_add), onPressed: _addNotes), + IconButton(icon: const Icon(Icons.delete), onPressed: _deleteCollection), + ], + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: TextField( + controller: _titleController, + decoration: const InputDecoration(labelText: 'Title'), + onChanged: (_) => _save(), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: TextField( + controller: _descriptionController, + decoration: const InputDecoration(labelText: 'Description'), + onChanged: (_) => _save(), + ), + ), + if (_collection.lengthStr.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text('Length: ${_collection.lengthStr}'), + ), + const SizedBox(height: 8), + Expanded( + child: ReorderableListView.builder( + itemCount: _collection.notes.length, + onReorder: (oldIndex, newIndex) async { + setState(() { + if (newIndex > oldIndex) newIndex -= 1; + final note = _collection.notes.removeAt(oldIndex); + _collection.notes.insert(newIndex, note); + }); + await _save(); + }, + itemBuilder: (context, index) { + final note = _collection.notes[index]; + return ListTile( + key: ValueKey('${note.id}-$index'), + title: Text(note.title.isEmpty ? 'Untitled Note' : note.title), + subtitle: Text(note.lengthStr.isEmpty ? '' : note.lengthStr), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.remove_circle_outline), + onPressed: () async { + setState(() { + _collection.notes.removeAt(index); + }); + await _save(); + }, + ), + const Icon(Icons.drag_handle), + ], + ), + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => NoteEditor(note)), + ); + }, + onLongPress: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => NoteViewer( + note, + showAdditionalInformation: false, + showTitle: true, + showAudioFiles: true, + ), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/collections_store.dart b/lib/collections_store.dart new file mode 100644 index 0000000..fb0d1e6 --- /dev/null +++ b/lib/collections_store.dart @@ -0,0 +1,85 @@ +import 'package:flutter_flux/flutter_flux.dart'; +import 'package:sound/db.dart'; +import 'package:sound/model.dart'; +import 'package:sound/local_storage.dart'; + +class CollectionsStore extends Store { + List _selectedCollections; + List get selectedCollections => _selectedCollections; + + List get filteredCollections => + DB().collections.where((NoteCollection collection) { + return true; + // if (_filters.length == 0 && (_search == null || _search == "")) + // return true; + + // if (_search != null && search != "") { + // if (_filters.length == 0) { + // return _isSearchValid(note); + // } else { + // return _isSearchValid(note) && _isAnyFilterValid(note); + // } + // } else { + // return _isAnyFilterValid(note); + // } + }).toList(); + + bool isAnyCollectionSelected() => _selectedCollections.length > 0; + bool isAnyCollectionStarred() => filteredCollections.any((n) => n.starred); + + bool isSelected(NoteCollection collection) => + this._selectedCollections.contains(collection); + + CollectionsStore() { + _selectedCollections = []; + + triggerSelectCollection.listen((NoteCollection collection) { + if (!_selectedCollections.contains(collection)) { + _selectedCollections.add(collection); + trigger(); + } else { + _selectedCollections.remove(collection); + trigger(); + } + }); + + clearCollectionSelection.listen((_) { + _selectedCollections.clear(); + trigger(); + }); + + removeAllSelectedCollections.listen((_) { + for (NoteCollection collection in _selectedCollections) { + LocalStorage().deleteCollection(collection); + } + _selectedCollections.clear(); + trigger(); + }); + + starAllSelectedCollections.listen((_) { + for (NoteCollection collection in _selectedCollections) { + collection.starred = true; + LocalStorage().syncCollection(collection); + } + _selectedCollections.clear(); + trigger(); + }); + + unstarAllSelectedCollections.listen((_) { + for (NoteCollection collection in _selectedCollections) { + collection.starred = false; + LocalStorage().syncCollection(collection); + } + _selectedCollections.clear(); + trigger(); + }); + } +} + +Action triggerSelectCollection = Action(); +Action clearCollectionSelection = Action(); +Action removeAllSelectedCollections = Action(); +Action unstarAllSelectedCollections = Action(); +Action starAllSelectedCollections = Action(); + +StoreToken collectionsStoreToken = StoreToken(CollectionsStore()); diff --git a/lib/db.dart b/lib/db.dart index be16b2d..0c2d82f 100644 --- a/lib/db.dart +++ b/lib/db.dart @@ -27,24 +27,28 @@ class DB { DB._internal(); List get uniqueLabels => _notes - .where((n) => n.label != null && n.label != "") - .map((n) => n.label) + .map((n) => n.label) + .whereType() + .where((v) => v.isNotEmpty) .toSet() .toList(); List get uniqueCapos => _notes - .where((n) => n.capo != null && n.capo != "") - .map((n) => n.capo.toString()) + .map((n) => n.capo) + .whereType() + .where((v) => v.isNotEmpty) .toSet() .toList(); List get uniqueKeys => _notes - .where((n) => n.key != null && n.key != "") - .map((n) => n.key) + .map((n) => n.key) + .whereType() + .where((v) => v.isNotEmpty) .toSet() .toList(); List get uniqueTunings => _notes - .where((n) => n.tuning != null && n.tuning != "") - .map((n) => n.tuning) + .map((n) => n.tuning) + .whereType() + .where((v) => v.isNotEmpty) .toSet() .toList(); } diff --git a/lib/dialogs/add_to_collection_dialog.dart b/lib/dialogs/add_to_collection_dialog.dart new file mode 100644 index 0000000..999ac48 --- /dev/null +++ b/lib/dialogs/add_to_collection_dialog.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:sound/collections.dart'; +import 'package:sound/main.dart'; +import 'package:sound/model.dart'; +import 'package:sound/local_storage.dart'; + +typedef FutureNoteCollectionCallback = Future Function(); +typedef FutureAddNoteToCollectionCallback = Future Function( + NoteCollection); + +showAddToCollectionDialog(BuildContext context, Note note) { + Future onNew() async { + NoteCollection collection = NoteCollection.empty(); + collection.notes.add(note); + return collection; + } + + Future onAdd(NoteCollection collection) async { + if (!collection.notes.any((element) => element.id == note.id)) { + collection.notes.add(note); + } + return collection; + } + + _showAddToCollectionDialog(context, "Add to Set", onNew, onAdd, note, + importButtonText: 'Add'); +} + +_showAddToCollectionDialog( + BuildContext context, + String title, + FutureNoteCollectionCallback onNew, + FutureAddNoteToCollectionCallback onAdd, + Note note, + {String newButtonText = 'Create NEW', + String importButtonText = "Import", + bool openCollection = true, + bool syncCollection = true}) async { + List collections = await LocalStorage().getCollections(); + print("found ${collections.length} collections"); + + showDialog( + context: context, + builder: (BuildContext context) { + // if selected is null (use empty new note) + NoteCollection selected; + + _open(NoteCollection col) { + if (openCollection) { + Navigator.push( + context, + new MaterialPageRoute( + builder: (context) => CollectionEditor(col))); + } + } + + _import() async { + // sync and pop current dialog + NoteCollection collection = await onAdd(selected); + if (syncCollection) { + LocalStorage().syncCollection(collection); + } + Navigator.of(context).pop(); + _open(collection); + } + + _onNew() async { + NoteCollection newCollection = await onNew(); + if (syncCollection) { + LocalStorage().syncCollection(newCollection); + } + + Navigator.of(context).pop(); + _open(newCollection); + } + + return StatefulBuilder(builder: (context, setState) { + return AlertDialog( + title: new Text(title), + content: Builder(builder: (context) { + double width = MediaQuery.of(context).size.width; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Flexible( + child: ElevatedButton( + child: Text(newButtonText), onPressed: _onNew)), + SizedBox(height: 10), + Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Container(child: Text("-- or select a set --")) + ]), + SizedBox(height: 15), + Row(mainAxisSize: MainAxisSize.max, children: [ + new DropdownButton( + value: selected, + isDense: true, + items: collections + .where((c) { + try { + c.notes.firstWhere((n) => n.id == note.id); + return false; + } catch (e) { + return true; + } + }) + .map((e) => DropdownMenuItem( + child: SizedBox( + width: width - 152, + child: Text( + "${collections.indexOf(e)}: ${e.title}", + overflow: TextOverflow.ellipsis)), + value: e)) + .toList(), + onChanged: (v) => setState(() => selected = v)), + ]) + ]); + }), + actions: [ + new TextButton( + child: Text("Cancel"), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + // usually buttons at the bottom of the dialog + new ElevatedButton( + child: new Text( + importButtonText, + ), + onPressed: (selected != null) ? _import : null, + ), + ], + ); + }); + }, + ); +} diff --git a/lib/dialogs/audio_action_dialog.dart b/lib/dialogs/audio_action_dialog.dart index c1f2479..f0d0ea5 100644 --- a/lib/dialogs/audio_action_dialog.dart +++ b/lib/dialogs/audio_action_dialog.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:sound/model.dart'; +import 'package:sound/dialogs/import_dialog.dart'; class AudioAction { final IconData icon; @@ -8,10 +10,40 @@ class AudioAction { AudioAction(this.id, this.icon, this.description); } -showAudioActionDialog(BuildContext context, List actions, - ValueChanged onActionPressed) { - // actions are an icon with a descrition unterneath it +enum AudioActionEnum { + share, + move, + duplicate, + copy, + move_to_new, + search, + star, + unstar +} +var enum2Action = { + AudioActionEnum.duplicate: + AudioAction(AudioActionEnum.duplicate.index, Icons.copy, "Duplicate"), + AudioActionEnum.move: + AudioAction(AudioActionEnum.move.index, Icons.move_to_inbox, "Move"), + AudioActionEnum.move_to_new: AudioAction( + AudioActionEnum.move_to_new.index, Icons.new_label, "Move to New"), + AudioActionEnum.search: + AudioAction(AudioActionEnum.search.index, Icons.search, "Search"), + AudioActionEnum.share: + AudioAction(AudioActionEnum.share.index, Icons.share, "Share"), + AudioActionEnum.star: + AudioAction(AudioActionEnum.star.index, Icons.star_border, "Star"), + AudioActionEnum.unstar: + AudioAction(AudioActionEnum.unstar.index, Icons.star, "Unstar"), +}; + +showAudioActionDialog(BuildContext context, List actionEnums, + ValueChanged onActionPressed) { + final actions = actionEnums + .map((x) => enum2Action[x]) + .whereType() + .toList(); showDialog( context: context, builder: (context) { @@ -26,15 +58,41 @@ showAudioActionDialog(BuildContext context, List actions, IconButton( icon: Icon(action.icon, size: 30), onPressed: () => onActionPressed(action)), - Text(action.description, textScaleFactor: 0.7) + Text(action.description, + textScaler: const TextScaler.linear(0.7)) ], ); }).toList()), // actions: [ - // FlatButton( + // TextButton( // child: Text("Close"), // onPressed: () => Navigator.of(context).pop()) // ] ); }); } + +showMoveToNoteDialog( + BuildContext context, Future Function() onDone, AudioFile f) { + Future onMoveToNew() async { + // create a new note + Note note = Note.empty(); + note.audioFiles.add(f); + await onDone(); + return note; + } + + Future onMoveToExisting(Note note) async { + note.audioFiles.add(f); + await onDone(); + return note; + } + + showImportDialog( + context, + "Move audio file to note", + onMoveToNew, + onMoveToExisting, + importButtonText: "Move", + ); +} diff --git a/lib/dialogs/audio_import_dialog.dart b/lib/dialogs/audio_import_dialog.dart index 1fe5a7a..b82ed42 100644 --- a/lib/dialogs/audio_import_dialog.dart +++ b/lib/dialogs/audio_import_dialog.dart @@ -8,21 +8,18 @@ import 'package:sound/file_manager.dart'; import 'package:sound/local_storage.dart'; import 'package:sound/model.dart'; import 'package:path/path.dart' as p; +import 'package:uuid/uuid.dart'; showAudioImportDialog(BuildContext context, List files) { // analyse the playability and duration of audio files Future getDuration(File f) async { - AudioPlayer _player = AudioPlayer(); - - int result = await _player.play(f.path, isLocal: true, volume: 0); - if (result != 1) return null; - - Duration duration = await _player.onDurationChanged.first; - - await _player.stop(); - await _player.dispose(); - + final player = AudioPlayer(); + await player.setSource(DeviceFileSource(f.path)); + await player.play(DeviceFileSource(f.path), volume: 0); + final duration = await player.onDurationChanged.first; + await player.stop(); + await player.dispose(); return duration; } @@ -30,13 +27,7 @@ showAudioImportDialog(BuildContext context, List files) { List audioFiles = []; for (File f in files) { - Duration duration = await getDuration((f)); - if (duration == null) { - final snackBar = SnackBar( - backgroundColor: Theme.of(context).errorColor, - content: Text("cannot load audio ${f.path}")); - Scaffold.of(context).showSnackBar(snackBar); - } + Duration duration = await getDuration(f); print("=> File ${f.path} is ${duration.inSeconds} seconds"); var audioFile = AudioFile( @@ -58,8 +49,12 @@ _showAudioImportDialog(BuildContext context, List files) async { for (AudioFile f in files) { Directory filesDir = await Backup().getFilesDir(); - String newPath = p.join(filesDir.path, p.basename(f.path)); - AudioFile move = await FileManager().move(f, newPath, id: f.id); + String ext = p.extension(f.path); + String newBase = Uuid().v4() + ext; + String newPath = p.join(filesDir.path, newBase); + AudioFile move = await FileManager().copy(f, newPath, id: f.id); + move.createdAt = f.file.lastModifiedSync(); + move.name = p.basename(f.path); copied.add(move); } return copied; @@ -76,6 +71,18 @@ _showAudioImportDialog(BuildContext context, List files) async { return note; } + Future onImportAudioIdeas() async { + final prepared = await _prepareFiles(); + for (final f in prepared) { + await LocalStorage().addAudioIdea(f); + } + } + showImportDialog( - context, "Import ${files.length} Audio Files", onNew, onImport); + context, + "Import ${files.length} Audio Files", + onNew, + onImport, + onImportAudioIdeas: onImportAudioIdeas, + ); } diff --git a/lib/dialogs/change_number_dialog.dart b/lib/dialogs/change_number_dialog.dart new file mode 100644 index 0000000..3b853a8 --- /dev/null +++ b/lib/dialogs/change_number_dialog.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:sound/utils.dart'; + +showChangeNumberDialog(BuildContext context, String title, double _value, + ValueChanged onChange, + {double step = 1.0, + bool asInt = true, + double max, + double min, + bool isTime = false, + double longPressStep = 2}) { + showDialog( + context: context, + builder: (context) { + double value = _value; + bool isLongPressed = false; + + String getValue() { + if (isTime) { + return toTime(value.toInt()); + } else { + return asInt ? value.toInt().toString() : value.toString(); + } + } + + return StatefulBuilder(builder: (context, setState) { + return AlertDialog( + title: Text(title), + content: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + GestureDetector( + onLongPressEnd: (_) => + setState(() => isLongPressed = false), + onLongPressStart: (_) async { + isLongPressed = true; + do { + if (longPressStep != null) { + setState(() { + value = (min == null || + (value - longPressStep) >= min) + ? (value - longPressStep) + : min; + }); + } + await Future.delayed(Duration(seconds: 1)); + } while (isLongPressed); + }, + child: IconButton( + icon: Icon(Icons.remove), + onPressed: () { + setState(() { + value = ((min == null) || (value - step) >= min) + ? (value - step) + : min; + }); + }), + ), + Text(getValue(), + style: Theme.of(context) + .textTheme + .caption + .copyWith(fontSize: 20)), + GestureDetector( + onLongPressStart: (_) async { + isLongPressed = true; + do { + if (longPressStep != null) { + setState(() { + value = (max == null || + (value + longPressStep) <= max) + ? (value + longPressStep) + : max; + }); + } + await Future.delayed(Duration(seconds: 1)); + } while (isLongPressed); + }, + onLongPressEnd: (_) => + setState(() => isLongPressed = false), + onLongPress: () {}, + child: IconButton( + icon: Icon(Icons.add), + onPressed: () { + setState(() { + value = (max == null || (value + step) <= max) + ? (value + step) + : max; + }); + }), + ) + ]), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text("Cancel")), + new ElevatedButton( + child: new Text("Apply"), + onPressed: () { + onChange(value); + Navigator.of(context).pop(); + }, + ), + ]); + }); + }); +} diff --git a/lib/dialogs/choose_note_dialog.dart b/lib/dialogs/choose_note_dialog.dart index e69de29..6778efd 100644 --- a/lib/dialogs/choose_note_dialog.dart +++ b/lib/dialogs/choose_note_dialog.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:sound/model.dart'; +import 'package:sound/utils.dart'; + +typedef FutureNoteImportCallback = Future Function(List); + +showAddNotesDialog( + {@required BuildContext context, + @required List notes, + @required List preselected, + @required FutureNoteImportCallback onImport, + String title = 'Add Notes', + String newButtonText = 'Import as NEW', + String importButtonText = "Import", + String cancelButtonText = "Cancel"}) { + // trigger note selection / deselection via button press + + showDialog( + context: context, + builder: (BuildContext context) { + // if selected is null (use empty new note) + + List selected = []; + + _import() async { + // sync and pop current dialog + await onImport(selected); + Navigator.of(context).pop(); + } + + bool isSelected(int index) { + return selected.contains(notes[index]); + } + + bool emptyTitle(Note note) { + return (note.title == null || note.title.trim() == ""); + } + + return StatefulBuilder(builder: (context, setState) { + var width = MediaQuery.of(context).size.width; + + return AlertDialog( + title: new Text(title), + content: Builder(builder: (context) { + return Container( + width: width, + height: 400, + child: ListView.builder( + shrinkWrap: true, + itemBuilder: (context, index) { + return ListTile( + leading: ConstrainedBox( + constraints: + BoxConstraints(maxHeight: 16, maxWidth: 160), + child: Text( + emptyTitle(notes[index]) + ? "Empty" + : notes[index].title, + overflow: TextOverflow.clip, + )), + onTap: () { + setState(() { + if (isSelected(index)) { + selected.remove(notes[index]); + } else { + selected.add(notes[index]); + } + }); + }, + trailing: isSelected(index) + ? IconButton( + icon: Icon( + Icons.check, + color: getSelectedCardColor(context), + ), + onPressed: () {}) + : null, + ); + }, + itemCount: notes.length)); + }), + actions: [ + new TextButton( + child: Text(cancelButtonText), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + // usually buttons at the bottom of the dialog + new TextButton( + child: new Text(importButtonText), + onPressed: (selected != null) ? _import : null, + ), + ], + ); + }); + }, + ); +} diff --git a/lib/dialogs/color_picker_dialog.dart b/lib/dialogs/color_picker_dialog.dart index 2f61190..7d4d83f 100644 --- a/lib/dialogs/color_picker_dialog.dart +++ b/lib/dialogs/color_picker_dialog.dart @@ -1,12 +1,15 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; class ColorPicker extends StatelessWidget { - final Color pickerColor; + final Color? pickerColor; final List availableColors; final ValueChanged onColorChanged; - ColorPicker({this.availableColors, this.onColorChanged, this.pickerColor}); + const ColorPicker( + {required this.availableColors, + required this.onColorChanged, + this.pickerColor, + super.key}); _getItem(Color color) { return Padding( @@ -80,7 +83,7 @@ List getCardColors(BuildContext context) { return colors; } -showColorPickerDialog(BuildContext context, Color currentColor, +showColorPickerDialog(BuildContext context, Color? currentColor, ValueChanged onColorChanged) { List colors = getCardColors(context); @@ -91,7 +94,7 @@ showColorPickerDialog(BuildContext context, Color currentColor, showDialog( context: context, builder: (context) { - Color selected = currentColor; + Color selected = currentColor ?? colors.first; return StatefulBuilder(builder: (context, setState) { return AlertDialog( @@ -99,8 +102,8 @@ showColorPickerDialog(BuildContext context, Color currentColor, contentPadding: const EdgeInsets.all(8), title: Text("Choose a Color"), actions: [ - FlatButton(child: Text("Cancel"), onPressed: _onCancel), - FlatButton( + TextButton(child: Text("Cancel"), onPressed: _onCancel), + TextButton( child: Text("Apply"), onPressed: () { onColorChanged(selected); @@ -111,7 +114,7 @@ showColorPickerDialog(BuildContext context, Color currentColor, child: ColorPicker( availableColors: colors, pickerColor: - selected == null ? Theme.of(context).cardColor : selected, + selected, onColorChanged: (color) => setState(() => selected = color), ), ), diff --git a/lib/dialogs/confirmation_dialogs.dart b/lib/dialogs/confirmation_dialogs.dart new file mode 100644 index 0000000..b3fc78e --- /dev/null +++ b/lib/dialogs/confirmation_dialogs.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:sound/local_storage.dart'; +import 'package:sound/model.dart'; + +Future showDeleteDialog( + BuildContext context, + Note note, + VoidCallback onDelete, +) async { + const message = 'Are you sure you want to delete this note?'; + _deleteDialog(context, message, onDelete); +} + +Future showNoteCollectionDeleteDialog( + BuildContext context, + Object collection, + VoidCallback onDelete, +) async { + const message = 'Are you sure you want to delete this collection?'; + _deleteDialog(context, message, onDelete); +} + +void _deleteDialog( + BuildContext context, + String message, + VoidCallback onDelete, +) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('No'), + ), + ElevatedButton( + onPressed: () { + onDelete(); + Navigator.of(context).pop(); + }, + child: const Text('Yes'), + ), + ], + ); + }, + ); +} + +void showDeleteForeverDialog({ + required BuildContext context, + required Note note, + required VoidCallback onDelete, +}) { + final message = 'Are you sure you want to delete "${note.title}" irrevocably?'; + showConfirmationDialog( + title: 'Delete Irrevocably', + context: context, + onConfirm: () { + LocalStorage().deleteNote(note); + onDelete(); + }, + onDeny: () {}, + message: message, + ); +} + +void showDeleteNotesForeverDialog({ + required BuildContext context, + required List notes, + required VoidCallback onDelete, +}) { + final message = + 'Are you sure you want to delete ${notes.length} note/s irrevocably?'; + showConfirmationDialog( + title: 'Delete Irrevocably', + context: context, + onConfirm: () { + for (final note in notes) { + LocalStorage().deleteNote(note); + } + onDelete(); + }, + onDeny: () {}, + message: message, + ); +} + +void showConfirmationDialog({ + required BuildContext context, + required String title, + required String message, + required VoidCallback onConfirm, + VoidCallback? onDeny, +}) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(title), + content: Padding( + padding: const EdgeInsets.only(right: 10), + child: Text(message), + ), + actions: [ + TextButton( + child: const Text('No'), + onPressed: () { + onDeny?.call(); + Navigator.of(context).pop(); + }, + ), + ElevatedButton( + child: const Text('Yes'), + onPressed: () { + onConfirm(); + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); +} diff --git a/lib/dialogs/export_dialog.dart b/lib/dialogs/export_dialog.dart index ffedd11..d6f3222 100644 --- a/lib/dialogs/export_dialog.dart +++ b/lib/dialogs/export_dialog.dart @@ -1,6 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_share/flutter_share.dart'; -import 'package:sound/backup.dart'; import 'package:sound/export.dart'; import 'package:sound/model.dart'; @@ -19,30 +17,30 @@ showExportDialog(BuildContext context, Note note) { return StatefulBuilder(builder: (context, setState) { return AlertDialog( - title: new Text("Export Options"), + title: const Text("Export Options"), content: Row(children: [ - Padding( + const Padding( child: Text("Format:"), padding: EdgeInsets.only(right: 10), ), - new DropdownButton( + DropdownButton( value: current, items: ExportType.values - .map((e) => DropdownMenuItem( + .map((e) => DropdownMenuItem( child: Text(getExtension(e)), value: e)) .toList(), - onChanged: (v) => setState(() => current = v)), + onChanged: (v) => setState(() => current = v!)), ]), actions: [ - new FlatButton( + TextButton( child: Text("Cancel"), onPressed: () { Navigator.of(context).pop(); }, ), // usually buttons at the bottom of the dialog - new FlatButton( - child: new Text("Export"), + TextButton( + child: const Text("Export"), onPressed: () { _export(); }, diff --git a/lib/dialogs/import_dialog.dart b/lib/dialogs/import_dialog.dart index b38808b..958a746 100644 --- a/lib/dialogs/import_dialog.dart +++ b/lib/dialogs/import_dialog.dart @@ -5,11 +5,14 @@ import 'package:sound/note_editor.dart'; typedef FutureNoteCallback = Future Function(); typedef FutureNoteImportCallback = Future Function(Note); +typedef FutureAudioIdeaImportCallback = Future Function(); showImportDialog(BuildContext context, String title, FutureNoteCallback onNew, FutureNoteImportCallback onImport, {String newButtonText = 'Import as NEW', String importButtonText = "Import", + String importIdeasButtonText = 'Import as Idea', + FutureAudioIdeaImportCallback? onImportAudioIdeas, bool openNote = true, bool syncNote = true}) async { List notes = await LocalStorage().getActiveNotes(); @@ -18,18 +21,19 @@ showImportDialog(BuildContext context, String title, FutureNoteCallback onNew, context: context, builder: (BuildContext context) { // if selected is null (use empty new note) - Note selected; + Note? selected; _open(Note note) { if (openNote) { Navigator.push(context, - new MaterialPageRoute(builder: (context) => NoteEditor(note))); + MaterialPageRoute(builder: (context) => NoteEditor(note))); } } _import() async { // sync and pop current dialog - Note note = await onImport(selected); + if (selected == null) return; + Note note = await onImport(selected!); if (syncNote) { LocalStorage().syncNote(note); } @@ -47,9 +51,17 @@ showImportDialog(BuildContext context, String title, FutureNoteCallback onNew, _open(newNote); } + _onImportIdeas() async { + if (onImportAudioIdeas == null) return; + await onImportAudioIdeas(); + if (context.mounted) { + Navigator.of(context).pop(); + } + } + return StatefulBuilder(builder: (context, setState) { return AlertDialog( - title: new Text(title), + title: Text(title), content: Builder(builder: (context) { double width = MediaQuery.of(context).size.width; return Column( @@ -57,15 +69,22 @@ showImportDialog(BuildContext context, String title, FutureNoteCallback onNew, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Flexible( - child: RaisedButton( + child: ElevatedButton( child: Text(newButtonText), onPressed: _onNew)), + if (onImportAudioIdeas != null) ...[ + const SizedBox(height: 10), + Flexible( + child: ElevatedButton( + onPressed: _onImportIdeas, + child: Text(importIdeasButtonText))), + ], SizedBox(height: 10), Row(mainAxisAlignment: MainAxisAlignment.center, children: [ Container(child: Text("-- or select a note --")) ]), SizedBox(height: 15), Row(mainAxisSize: MainAxisSize.max, children: [ - new DropdownButton( + DropdownButton( value: selected, isDense: true, items: notes @@ -82,15 +101,15 @@ showImportDialog(BuildContext context, String title, FutureNoteCallback onNew, ]); }), actions: [ - new FlatButton( + TextButton( child: Text("Cancel"), onPressed: () { Navigator.of(context).pop(); }, ), // usually buttons at the bottom of the dialog - new FlatButton( - child: new Text(importButtonText), + TextButton( + child: Text(importButtonText), onPressed: (selected != null) ? _import : null, ), ], diff --git a/lib/dialogs/initial_import_dialog.dart b/lib/dialogs/initial_import_dialog.dart index 3d1de4f..c3f250b 100644 --- a/lib/dialogs/initial_import_dialog.dart +++ b/lib/dialogs/initial_import_dialog.dart @@ -70,21 +70,26 @@ showSelectNotesDialog(BuildContext context, NoteListCallback onApply, title: Text(title), content: isImporting ? Center(child: CircularProgressIndicator()) - : ListView.builder( - itemBuilder: (context, index) { - Note note = notes[index]; - return CheckboxListTile( - activeColor: Theme.of(context).accentColor, - value: checked[note], - onChanged: (v) { - setState(() => checked[note] = v); - }, - title: ListTile( - title: Text(note.title), - subtitle: Text(note.artist), - )); - }, - itemCount: notes.length, + : SizedBox( + width: 480, + height: 320, + child: ListView.builder( + itemBuilder: (context, index) { + Note note = notes[index]; + return CheckboxListTile( + activeColor: + Theme.of(context).colorScheme.secondary, + value: checked[note], + onChanged: (v) { + setState(() => checked[note] = v ?? false); + }, + title: ListTile( + title: Text(note.title), + subtitle: Text(note.artist ?? ''), + )); + }, + itemCount: notes.length, + ), ), actions: isImporting ? [] diff --git a/lib/dialogs/permissions_dialog.dart b/lib/dialogs/permissions_dialog.dart new file mode 100644 index 0000000..3b7aa92 --- /dev/null +++ b/lib/dialogs/permissions_dialog.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +showHasNoPermissionsDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text("Permission denied"), + content: Text( + "You have to allow using the microphone permissions in the settings of your phone!"), + actions: [ + new ElevatedButton( + child: new Text("OK"), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ]); + }); +} diff --git a/lib/dialogs/text_import_dialog.dart b/lib/dialogs/text_import_dialog.dart index cae277b..3dd1587 100644 --- a/lib/dialogs/text_import_dialog.dart +++ b/lib/dialogs/text_import_dialog.dart @@ -4,7 +4,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:sound/dialogs/import_dialog.dart'; -import 'package:sound/local_storage.dart'; import 'package:sound/model.dart'; import 'package:sound/utils.dart'; import 'package:uuid/uuid.dart'; @@ -13,10 +12,10 @@ class ParsedNote { final String title; final List
sections; - ParsedNote({this.title, this.sections}); + ParsedNote({required this.title, required this.sections}); } -ParsedNote parseText(String text) { +ParsedNote? parseText(String text) { List splits = text.split('\n'); print("splits: ${splits.length}"); List> parts = []; @@ -24,8 +23,6 @@ ParsedNote parseText(String text) { List part = []; for (String s in splits) { - if (s == null) continue; - s = s.trim(); bool isEmpty = s == ""; @@ -57,11 +54,11 @@ ParsedNote parseText(String text) { if (part.length > 0) parts.add(part); // return empty if no part was found - if (parts.length == 0) return null; + if (parts.isEmpty) return null; int start = 0; List
sections = []; - String title; + String title = ""; // skip the start, cause it was the title if (parts[0].length == 1 && !parts[0][0].startsWith("[")) { @@ -91,8 +88,8 @@ ParsedNote parseText(String text) { showInvalidTextSnack(BuildContext context) { var snackbar = SnackBar( content: Text("The text the app received has no valid format!"), - backgroundColor: Theme.of(context).errorColor); - Scaffold.of(context).showSnackBar(snackbar); + backgroundColor: Theme.of(context).colorScheme.error); + ScaffoldMessenger.of(context).showSnackBar(snackbar); } Future readResponse(HttpClientResponse response) { @@ -130,11 +127,10 @@ showTextImportDialog(BuildContext context, String text) async { showImportDialog( context, "Import ${ultimateNote.title}", onNew, onImport); } else { - showSnack(Scaffold.of(context), "Cannot retrieve note from $text"); + showSnack(context, "Cannot retrieve note from $text"); } } else { - ParsedNote parsed = parseText(text); - + ParsedNote? parsed = parseText(text); if (parsed == null) { showInvalidTextSnack(context); return; @@ -143,7 +139,7 @@ showTextImportDialog(BuildContext context, String text) async { Future onNew() async { Note empty = Note.empty(); empty.sections = parsed.sections; - if (parsed.title != null) empty.title = parsed.title; + empty.title = parsed.title; return empty; } diff --git a/lib/editor_store.dart b/lib/editor_store.dart index 5e4d4b6..e74caf0 100644 --- a/lib/editor_store.dart +++ b/lib/editor_store.dart @@ -1,224 +1,228 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' show Color; -import 'package:flutter_flux/flutter_flux.dart' show Action, Store, StoreToken; -import 'local_storage.dart'; +import 'package:tuple/tuple.dart'; + import 'file_manager.dart'; +import 'local_storage.dart'; import 'model.dart'; -import 'package:tuple/tuple.dart'; -class NoteEditorStore extends Store { - Note _note; - Note get note => _note; +class NoteEditorStore extends ChangeNotifier { + Note? _note; + Note? get note => _note; - Tuple2 _lastDeletion; + Tuple2? _lastDeletion; void setNote(Note n) { _note = n; + notifyListeners(); } - NoteEditorStore() { - editorSetNote.listen((note) { - _note = note; - trigger(); - }); - - addAudioFile.listen((f) async { - _note.audioFiles.add(f); - await LocalStorage().syncNoteAttr(_note, 'audioFiles'); - - trigger(); - }); - - addSection.listen((s) async { - _note.sections.add(s); - await LocalStorage().syncNoteAttr(_note, 'sections'); - trigger(); - }); - - hardDeleteAudioFile.listen((f) async { - FileManager().delete(f); - - _note.audioFiles.remove(f); - print("Removing: ${f.name}"); - await LocalStorage().syncNoteAttr(_note, 'audioFiles'); - - trigger(); - }); - - softDeleteAudioFile.listen((AudioFile f) async { - _note.audioFiles.remove(f); - print("Softly emoving: ${f.name}"); - - //FileManager().delete(f); - //await LocalStorage().syncNoteAttr(_note, 'audioFiles'); - - trigger(); - }); - - deleteSection.listen((s) async { - int index = _note.sections.indexOf(s); - _note.sections.removeAt(index); - _lastDeletion = Tuple2(index, s); - await LocalStorage().syncNoteAttr(_note, 'sections'); - - trigger(); - }); - - undoDeleteSection.listen((_) async { - _note.sections.insert(_lastDeletion.item1, _lastDeletion.item2); - await LocalStorage().syncNoteAttr(_note, 'sections'); - trigger(); - }); - moveSectionUp.listen((s) async { - int index = _note.sections.indexOf(s); - if (index >= 1) { - print("move up with index $index"); - _note.sections.removeAt(index); - _note.sections.insert(index - 1, s); - await LocalStorage().syncNoteAttr(_note, 'sections'); - trigger(); - } - }); - moveSectionDown.listen((s) async { - int index = _note.sections.indexOf(s); - if (index != (note.sections.length - 1) && index >= 0) { - print('move down with index: $index'); - _note.sections.removeAt(index); - _note.sections.insert(index + 1, s); - await LocalStorage().syncNoteAttr(_note, 'sections'); - } - trigger(); - }); - changeSectionTitle.listen((t) async { - int index = _note.sections.indexOf(t.item1); - _note.sections[index].title = t.item2; - await LocalStorage().syncNoteAttr(_note, 'sections'); - trigger(); - }); - - changeTitle.listen((t) async { - _note.title = t; - print('chaning title...'); - await LocalStorage().syncNoteAttr(_note, 'title'); - trigger(); - }); - - changeContent.listen((t) async { - int index = _note.sections.indexOf(t.item1); - _note.sections[index].content = t.item2; - await LocalStorage().syncNoteAttr(_note, 'sections'); - trigger(); - }); - - changeCapo.listen((String x) async { - _note.capo = x; - await LocalStorage().syncNoteAttr(_note, 'capo'); - trigger(); - }); - - changeAudioFile.listen((AudioFile f) async { - int index = _note.audioFiles.indexWhere((AudioFile a) => a.id == f.id); - if (index == -1) { - print("cannot change audio file, file not found"); - return; - } - _note.audioFiles[index] = f; - - await LocalStorage().syncNoteAttr(_note, 'audioFiles'); - trigger(); - }); - - changeTuning.listen((String x) async { - if (x.trim() == "") return; - _note.tuning = x; - await LocalStorage().syncNoteAttr(_note, 'tuning'); - trigger(); - }); - changeKey.listen((String x) async { - if (x.trim() == "") return; - _note.key = x; - await LocalStorage().syncNoteAttr(_note, 'key'); - trigger(); - }); - changeLabel.listen((String x) async { - if (x.trim() == "") return; - _note.label = x; - await LocalStorage().syncNoteAttr(_note, 'label'); - trigger(); - }); - changeArtist.listen((String x) async { - if (x.trim() == "") return; - _note.artist = x; - await LocalStorage().syncNoteAttr(_note, 'artist'); - trigger(); - }); - changeInstrument.listen((String x) async { - if (x.trim() == "") return; - _note.instrument = x; - await LocalStorage().syncNoteAttr(_note, 'instrument'); - trigger(); - }); - - updateNoteEditorView.listen((_) { - trigger(); - }); - - restoreAudioFile.listen((Tuple2 a) async { - print( - "restoring audio file ${a.item1.path} at ${a.item2} into the notes"); - _note.audioFiles.insert(a.item2, a.item1); - await LocalStorage().syncNoteAttr(_note, 'audioFiles'); - trigger(); - }); - - toggleStarred.listen((event) async { - _note.starred = !_note.starred; - await LocalStorage().syncNoteAttr(_note, 'starred'); - trigger(); - }); - - changeColor.listen((Color event) async { - _note.color = event; - await LocalStorage().syncNoteAttr(_note, 'color'); - trigger(); - }); - - setDuration.listen((Tuple2 a) async { - for (AudioFile f in _note.audioFiles) { - if (f.id == a.item1.id) { - print("setting duration of ${f.id} to duration ${a.item2}"); - f.duration = a.item2; - } + Future addAudioFile(AudioFile f) async { + if (_note == null) return; + _note!.audioFiles.add(f); + await LocalStorage().syncNoteAttr(_note!, 'audioFiles'); + notifyListeners(); + } + + Future addSection(Section s) async { + if (_note == null) return; + _note!.sections.add(s); + await LocalStorage().syncNoteAttr(_note!, 'sections'); + notifyListeners(); + } + + Future hardDeleteAudioFile(AudioFile f) async { + if (_note == null) return; + FileManager().delete(f); + _note!.audioFiles.remove(f); + await LocalStorage().syncNoteAttr(_note!, 'audioFiles'); + notifyListeners(); + } + + void softDeleteAudioFile(AudioFile f) { + if (_note == null) return; + _note!.audioFiles.remove(f); + notifyListeners(); + } + + Future deleteSection(Section s) async { + if (_note == null) return; + final index = _note!.sections.indexOf(s); + if (index < 0) return; + _note!.sections.removeAt(index); + _lastDeletion = Tuple2(index, s); + await LocalStorage().syncNoteAttr(_note!, 'sections'); + notifyListeners(); + } + + Future undoDeleteSection([dynamic _]) async { + if (_note == null || _lastDeletion == null) return; + _note!.sections.insert(_lastDeletion!.item1, _lastDeletion!.item2); + await LocalStorage().syncNoteAttr(_note!, 'sections'); + notifyListeners(); + } + + Future moveSectionUp(Section s) async { + if (_note == null) return; + final index = _note!.sections.indexOf(s); + if (index < 1) return; + _note!.sections.removeAt(index); + _note!.sections.insert(index - 1, s); + await LocalStorage().syncNoteAttr(_note!, 'sections'); + notifyListeners(); + } + + Future moveSectionDown(Section s) async { + if (_note == null) return; + final index = _note!.sections.indexOf(s); + if (index < 0 || index == _note!.sections.length - 1) return; + _note!.sections.removeAt(index); + _note!.sections.insert(index + 1, s); + await LocalStorage().syncNoteAttr(_note!, 'sections'); + notifyListeners(); + } + + Future changeSectionTitle(Tuple2 t) async { + if (_note == null) return; + final index = _note!.sections.indexOf(t.item1); + if (index < 0) return; + _note!.sections[index].title = t.item2; + await LocalStorage().syncNoteAttr(_note!, 'sections'); + notifyListeners(); + } + + Future changeTitle(String t) async { + if (_note == null) return; + _note!.title = t; + await LocalStorage().syncNoteAttr(_note!, 'title'); + notifyListeners(); + } + + Future changeContent(Tuple2 t) async { + if (_note == null) return; + final index = _note!.sections.indexOf(t.item1); + if (index < 0) return; + _note!.sections[index].content = t.item2; + await LocalStorage().syncNoteAttr(_note!, 'sections'); + notifyListeners(); + } + + Future changeCapo(String x) async { + if (_note == null) return; + _note!.capo = x; + await LocalStorage().syncNoteAttr(_note!, 'capo'); + notifyListeners(); + } + + Future changeAudioFile(AudioFile f) async { + if (_note == null) return; + final index = _note!.audioFiles.indexWhere((a) => a.id == f.id); + if (index == -1) return; + _note!.audioFiles[index] = f; + await LocalStorage().syncNoteAttr(_note!, 'audioFiles'); + notifyListeners(); + } + + Future changeTuning(String x) async { + if (_note == null || x.trim().isEmpty) return; + _note!.tuning = x; + await LocalStorage().syncNoteAttr(_note!, 'tuning'); + notifyListeners(); + } + + Future changeKey(String x) async { + if (_note == null || x.trim().isEmpty) return; + _note!.key = x; + await LocalStorage().syncNoteAttr(_note!, 'key'); + notifyListeners(); + } + + Future changeLabel(String x) async { + if (_note == null || x.trim().isEmpty) return; + _note!.label = x; + await LocalStorage().syncNoteAttr(_note!, 'label'); + notifyListeners(); + } + + Future changeArtist(String x) async { + if (_note == null || x.trim().isEmpty) return; + _note!.artist = x; + await LocalStorage().syncNoteAttr(_note!, 'artist'); + notifyListeners(); + } + + Future changeInstrument(String x) async { + if (_note == null || x.trim().isEmpty) return; + _note!.instrument = x; + await LocalStorage().syncNoteAttr(_note!, 'instrument'); + notifyListeners(); + } + + void updateNoteEditorView([dynamic _]) { + notifyListeners(); + } + + Future restoreAudioFile(Tuple2 a) async { + if (_note == null) return; + _note!.audioFiles.insert(a.item2, a.item1); + await LocalStorage().syncNoteAttr(_note!, 'audioFiles'); + notifyListeners(); + } + + Future toggleStarred([dynamic _]) async { + if (_note == null) return; + _note!.starred = !_note!.starred; + await LocalStorage().syncNoteAttr(_note!, 'starred'); + notifyListeners(); + } + + Future changeColor(Color event) async { + if (_note == null) return; + _note!.color = event; + await LocalStorage().syncNoteAttr(_note!, 'color'); + notifyListeners(); + } + + Future setDuration(Tuple2 a) async { + if (_note == null) return; + for (final f in _note!.audioFiles) { + if (f.id == a.item1.id) { + f.duration = a.item2; } - await LocalStorage().syncNoteAttr(_note, 'audioFiles'); - trigger(); - }); + } + await LocalStorage().syncNoteAttr(_note!, 'audioFiles'); + notifyListeners(); } } -Action editorSetNote = Action(); -Action softDeleteAudioFile = Action(); -Action hardDeleteAudioFile = Action(); -Action
deleteSection = Action(); -Action undoDeleteSection = Action(); -Action
addSection = Action(); -Action
moveSectionUp = Action(); -Action
moveSectionDown = Action(); -Action> changeSectionTitle = Action(); -Action changeTitle = Action(); -Action changeCapo = Action(); -Action changeTuning = Action(); -Action changeKey = Action(); -Action changeLabel = Action(); -Action changeArtist = Action(); -Action changeInstrument = Action(); -Action changeAudioFile = Action(); -Action> changeContent = Action(); -Action addAudioFile = Action(); -Action> restoreAudioFile = Action(); -Action> uploadCallback = Action(); -Action updateNoteEditorView = Action(); -Action toggleStarred = Action(); -Action changeColor = Action(); -Action> setDuration = Action(); - -StoreToken noteEditorStoreToken = StoreToken(NoteEditorStore()); +final NoteEditorStore noteEditorStore = NoteEditorStore(); + +void editorSetNote(Note note) => noteEditorStore.setNote(note); +void softDeleteAudioFile(AudioFile file) => noteEditorStore.softDeleteAudioFile(file); +Future hardDeleteAudioFile(AudioFile file) => noteEditorStore.hardDeleteAudioFile(file); +Future deleteSection(Section section) => noteEditorStore.deleteSection(section); +Future undoDeleteSection([dynamic _]) => noteEditorStore.undoDeleteSection(); +Future addSection(Section section) => noteEditorStore.addSection(section); +Future moveSectionUp(Section section) => noteEditorStore.moveSectionUp(section); +Future moveSectionDown(Section section) => noteEditorStore.moveSectionDown(section); +Future changeSectionTitle(Tuple2 value) => + noteEditorStore.changeSectionTitle(value); +Future changeTitle(String value) => noteEditorStore.changeTitle(value); +Future changeCapo(String value) => noteEditorStore.changeCapo(value); +Future changeTuning(String value) => noteEditorStore.changeTuning(value); +Future changeKey(String value) => noteEditorStore.changeKey(value); +Future changeLabel(String value) => noteEditorStore.changeLabel(value); +Future changeArtist(String value) => noteEditorStore.changeArtist(value); +Future changeInstrument(String value) => noteEditorStore.changeInstrument(value); +Future changeAudioFile(AudioFile value) => noteEditorStore.changeAudioFile(value); +Future changeContent(Tuple2 value) => + noteEditorStore.changeContent(value); +Future addAudioFile(AudioFile file) => noteEditorStore.addAudioFile(file); +Future restoreAudioFile(Tuple2 value) => + noteEditorStore.restoreAudioFile(value); +void uploadCallback(Tuple2 value) {} +void updateNoteEditorView([dynamic _]) => noteEditorStore.updateNoteEditorView(); +Future toggleStarred([dynamic _]) => noteEditorStore.toggleStarred(); +Future changeColor(Color value) => noteEditorStore.changeColor(value); +Future setDuration(Tuple2 value) => + noteEditorStore.setDuration(value); diff --git a/lib/editor_views/additional_info.dart b/lib/editor_views/additional_info.dart index 72863f6..6d29f36 100644 --- a/lib/editor_views/additional_info.dart +++ b/lib/editor_views/additional_info.dart @@ -7,14 +7,13 @@ class NoteEditorTitle extends StatelessWidget { final bool allowEdit; final ValueChanged onChange; - NoteEditorTitle( - {@required this.title, - @required this.onChange, + const NoteEditorTitle( + {required this.title, + required this.onChange, this.allowEdit = true, this.hintText = 'Enter Title', this.labelText = 'Title', - Key key}) - : super(key: key); + super.key}); @override Widget build(BuildContext context) { @@ -36,12 +35,16 @@ class NoteEditorAdditionalInfo extends StatelessWidget { final Note note; final bool allowEdit; - const NoteEditorAdditionalInfo(this.note, {this.allowEdit = true, Key key}) - : super(key: key); + const NoteEditorAdditionalInfo(this.note, {this.allowEdit = true, super.key}); - _edit({initial, title, hint, onChanged}) { + Widget _edit({ + required String? initial, + required String title, + required String hint, + required ValueChanged onChanged, + }) { return TextFormField( - initialValue: initial, + initialValue: initial ?? '', decoration: InputDecoration( labelText: title, border: InputBorder.none, hintText: hint), enabled: allowEdit, @@ -55,27 +58,27 @@ class NoteEditorAdditionalInfo extends StatelessWidget { padding: EdgeInsets.only(left: 10, top: 10), child: Wrap(runSpacing: 1, children: [ _edit( - initial: note.tuning == null ? "" : note.tuning, + initial: note.tuning, title: "Tuning", hint: "f.e. Standard, Dadgad", onChanged: changeTuning), _edit( - initial: note.capo == null ? "" : note.capo.toString(), + initial: note.capo, title: "Capo", hint: "f.e. 7, 5", onChanged: changeCapo), _edit( - initial: note.key == null ? "" : note.key.toString(), + initial: note.key, title: "Key", hint: "f.e. C Major, A Minor", onChanged: changeKey), _edit( - initial: note.label == null ? "" : note.label.toString(), + initial: note.label, title: "Label", hint: "f.e. Rock, Pop...", onChanged: changeLabel), _edit( - initial: note.artist == null ? "" : note.artist.toString(), + initial: note.artist, title: "Artist", hint: "leave empty if you are the artist", onChanged: changeArtist), diff --git a/lib/editor_views/audio.dart b/lib/editor_views/audio.dart index a66b17a..58145ce 100644 --- a/lib/editor_views/audio.dart +++ b/lib/editor_views/audio.dart @@ -2,35 +2,34 @@ import 'dart:io'; import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/material.dart'; -import 'package:sound/dialogs/audio_action_dialog.dart'; -import 'package:sound/editor_store.dart'; -import 'package:sound/model.dart'; -import 'package:sound/recorder_store.dart'; -import 'package:sound/share.dart'; -import 'package:sound/utils.dart'; + +import '../dialogs/audio_action_dialog.dart'; +import '../editor_store.dart'; +import '../model.dart'; +import '../recorder_store.dart'; +import '../utils.dart'; class AudioFileListItem extends StatelessWidget { - final Function onLongPress, onPressed; + final VoidCallback? onLongPress; + final VoidCallback? onPressed; final AudioFile file; - AudioFileListItem(this.file, {this.onLongPress, this.onPressed}); + const AudioFileListItem(this.file, {this.onLongPress, this.onPressed, super.key}); @override Widget build(BuildContext context) { - Widget subTitle = Text(file.createdAt.toIso8601String()); Widget trailing = Text(file.durationString); - - if (file.loopRange != null) - trailing = Text("${file.loopString} / ${file.durationString}"); - + if (file.loopRange != null) { + trailing = Text('${file.loopString} / ${file.durationString}'); + } return ListTile( onLongPress: onLongPress, trailing: trailing, - subtitle: subTitle, + subtitle: Text(file.createdAt.toIso8601String()), dense: true, visualDensity: VisualDensity.comfortable, - contentPadding: EdgeInsets.all(2), - leading: IconButton(icon: Icon(Icons.play_arrow), onPressed: onPressed), + contentPadding: const EdgeInsets.all(2), + leading: IconButton(icon: const Icon(Icons.play_arrow), onPressed: onPressed), title: Text(file.name), ); } @@ -39,50 +38,44 @@ class AudioFileListItem extends StatelessWidget { class AudioFileView extends StatelessWidget { final AudioFile file; final int index; - final GlobalKey globalKey; - final Function onDelete, onMove, onShare; - - const AudioFileView( - {@required this.file, - @required this.index, - @required this.onDelete, - @required this.onShare, - @required this.onMove, - @required this.globalKey, - Key key}) - : super(key: key); - - _onAudioFileLongPress(BuildContext context, AudioFile file) { - var controller = - TextEditingController.fromValue(TextEditingValue(text: file.name)); - - showDialog( + final GlobalKey globalKey; + final VoidCallback onDelete; + final VoidCallback onMove; + final VoidCallback onShare; + + const AudioFileView({ + required this.file, + required this.index, + required this.onDelete, + required this.onShare, + required this.onMove, + required this.globalKey, + super.key, + }); + + Future _onAudioFileLongPress(BuildContext context, AudioFile file) async { + final controller = TextEditingController(text: file.name); + await showDialog( context: context, builder: (BuildContext context) { - // return object of type Dialog return AlertDialog( - title: new Text("Rename"), - content: new TextField( + title: const Text('Rename'), + content: TextField( autofocus: true, maxLines: 1, minLines: 1, - onSubmitted: (s) => print("submit $s"), controller: controller, ), actions: [ - new FlatButton( - child: Text("Cancel"), - onPressed: () { - Navigator.of(context).pop(); - }, + TextButton( + child: const Text('Cancel'), + onPressed: () => Navigator.of(context).pop(), ), - // usually buttons at the bottom of the dialog - new FlatButton( - child: new Text("Apply"), + TextButton( + child: const Text('Apply'), onPressed: () { file.name = controller.value.text; changeAudioFile(file); - print("Setting name of audio file to ${file.name}"); Navigator.of(context).pop(); }, ), @@ -94,153 +87,146 @@ class AudioFileView extends StatelessWidget { @override Widget build(BuildContext context) { - var view = AudioFileListItem(file, - onLongPress: () => _onAudioFileLongPress(context, file), - onPressed: () { - print("trying to play ${file.path}"); - if (File(file.path).existsSync()) { - startPlaybackAction(file); - } else { - showSnack(globalKey.currentState, "This files was removed!"); - } - }); + final view = AudioFileListItem( + file, + onLongPress: () => _onAudioFileLongPress(context, file), + onPressed: () { + if (File(file.path).existsSync()) { + startPlaybackAction(file); + } else { + showSnack(globalKey.currentState, 'This file was removed!'); + } + }, + ); return Dismissible( + key: ValueKey(file.id), child: view, onDismissed: (d) { if (d == DismissDirection.endToStart) { onDelete(); - } else {} + } }, confirmDismiss: (d) async { - if (d == DismissDirection.endToStart) { - return true; - } else { - showAudioActionDialog(context, [ - AudioAction(0, Icons.share, "Share"), - AudioAction(1, Icons.move_to_inbox, "Move"), - ], (action) { + if (d == DismissDirection.endToStart) return true; + showAudioActionDialog( + context, + [ + AudioActionEnum.share, + AudioActionEnum.move, + ], + (action) { Navigator.of(context).pop(); - if (action.id == 0) { - // shareFile(file.path); onShare(); } else if (action.id == 1) { onMove(); } - }); - // shareFile(file.path); - return false; - } + }, + ); + return false; }, direction: DismissDirection.horizontal, - key: GlobalKey(), background: Card( - child: Container( - color: Colors.greenAccent, - child: Row(children: [Icon(Icons.share)]), - padding: EdgeInsets.all(10))), + child: Container( + color: Colors.greenAccent, + padding: const EdgeInsets.all(10), + child: const Row(children: [Icon(Icons.share)]), + ), + ), secondaryBackground: Card( - child: Container( - color: Colors.redAccent, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [Icon(Icons.delete)]), - padding: EdgeInsets.all(10))), + child: Container( + color: Colors.redAccent, + padding: const EdgeInsets.all(10), + child: const Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [Icon(Icons.delete)], + ), + ), + ), ); } } -playInDialog(BuildContext context, AudioFile f) { - Duration position = Duration(seconds: 0); +void playInDialog(BuildContext context, AudioFile f) { + Duration position = Duration.zero; Duration duration = f.duration; + RecorderState state = RecorderState.playing; + final player = AudioPlayer(); - RecorderState state = RecorderState.PLAYING; - AudioPlayer player = AudioPlayer(); - - Future.delayed(Duration(milliseconds: 100), () => player.play(f.path)); + Future.delayed(const Duration(milliseconds: 100), () { + player.play(DeviceFileSource(f.path)); + }); showDialog( - context: context, - barrierDismissible: false, - builder: (context) { - return StatefulBuilder(builder: (context, setState) { - void onPlay() async { - await player.resume(); - setState(() => state = RecorderState.PLAYING); - } + context: context, + barrierDismissible: false, + builder: (context) { + return StatefulBuilder(builder: (context, setState) { + void onPlay() async { + await player.resume(); + setState(() => state = RecorderState.playing); + } - void onPause() async { - await player.pause(); - setState(() => state = RecorderState.PAUSING); - } + void onPause() async { + await player.pause(); + setState(() => state = RecorderState.pausing); + } - void onSeek(Duration duration) async { - await player.seek(duration); - } + void onSeek(Duration d) async => player.seek(d); - void onStop() async { - await player.stop(); - Navigator.of(context).pop(); - } + void onStop() async { + await player.stop(); + if (context.mounted) Navigator.of(context).pop(); + } - player.onAudioPositionChanged.listen((event) { - if (event.inMilliseconds < f.duration.inMilliseconds) { - setState(() => position = event); - } - }); - - player.onDurationChanged.listen((event) { - print("duration chaned: ${event}"); - if (event != null && - event.inMilliseconds != duration.inMilliseconds) { - setState(() { - duration = event; - }); - } - }); - - player.onPlayerCompletion.listen((event) { - setState(() { - state = RecorderState.STOP; - }); - onPlay(); - }); - return AlertDialog( - title: Text(f.name, textScaleFactor: 0.8), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - height: 50, - child: Expanded( - child: Slider( - min: 0.0, - max: (duration.inMilliseconds / 1000).toDouble(), - value: (position.inMilliseconds / 1000).toDouble(), - onChanged: (value) { - print("on changed to $value"); - onSeek(Duration(milliseconds: (value * 1000).floor())); - }, - //activeColor: Colors.yellow, - ))), // slider - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - IconButton( - icon: Icon(state == RecorderState.PLAYING - ? Icons.pause - : Icons.play_arrow), - onPressed: - state == RecorderState.PLAYING ? onPause : onPlay), - IconButton(icon: Icon(Icons.stop), onPressed: onStop), - ], - ) // controls - ], - ), - contentPadding: EdgeInsets.all(8), - titlePadding: EdgeInsets.all(16), - ); + player.onPositionChanged.listen((event) { + if (event.inMilliseconds < duration.inMilliseconds) { + setState(() => position = event); + } + }); + player.onDurationChanged.listen((event) { + if (event.inMilliseconds != duration.inMilliseconds) { + setState(() => duration = event); + } }); + player.onPlayerComplete.listen((_) { + setState(() => state = RecorderState.stop); + }); + + return AlertDialog( + title: Text(f.name, textScaler: const TextScaler.linear(0.8)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Slider( + min: 0.0, + max: (duration.inMilliseconds / 1000).toDouble(), + value: (position.inMilliseconds / 1000).toDouble().clamp( + 0.0, (duration.inMilliseconds / 1000).toDouble()), + onChanged: (value) { + onSeek(Duration(milliseconds: (value * 1000).floor())); + }, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + icon: Icon(state == RecorderState.playing + ? Icons.pause + : Icons.play_arrow), + onPressed: + state == RecorderState.playing ? onPause : onPlay, + ), + IconButton(icon: const Icon(Icons.stop), onPressed: onStop), + ], + ), + ], + ), + contentPadding: const EdgeInsets.all(8), + titlePadding: const EdgeInsets.all(16), + ); }); + }, + ); } diff --git a/lib/editor_views/section.dart b/lib/editor_views/section.dart index b7e672a..3ae9fbf 100644 --- a/lib/editor_views/section.dart +++ b/lib/editor_views/section.dart @@ -1,179 +1,187 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; -import 'package:sound/editor_store.dart'; -import 'package:sound/model.dart'; -import 'package:sound/utils.dart'; -import 'package:tuple/tuple.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:tuple/tuple.dart'; + +import '../editor_store.dart'; +import '../model.dart'; +import '../utils.dart'; class Editable extends StatefulWidget { - final String initialValue, hintText; - final TextStyle textStyle; + final String initialValue; + final TextStyle? textStyle; final ValueChanged onChange; - final int maxLines; + final String? hintText; + final int? maxLines; final bool multiline; - final String labelText; - - Editable( - {this.initialValue, - this.textStyle, - this.onChange, - this.hintText, - this.maxLines, - this.multiline = false, - this.labelText}); + final String? labelText; + + const Editable({ + super.key, + required this.initialValue, + this.textStyle, + required this.onChange, + this.hintText, + this.maxLines, + this.multiline = false, + this.labelText, + }); @override - State createState() { - return EditableState(); - } + State createState() => EditableState(); } class EditableState extends State { - TextEditingController _controller; + late TextEditingController _controller; @override void initState() { super.initState(); - _controller = TextEditingController.fromValue( - TextEditingValue(text: widget.initialValue)); + _controller = TextEditingController(text: widget.initialValue); } @override void dispose() { - _controller.clear(); + _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return TextField( - decoration: InputDecoration.collapsed(hintText: widget.hintText) - .copyWith(labelText: widget.labelText), - keyboardType: - (widget.multiline) ? TextInputType.multiline : TextInputType.text, - expands: false, - minLines: 1, - maxLines: widget.maxLines, - - //maxLines: widget.maxLines, - enableInteractiveSelection: true, - onChanged: (s) { - print("widget changed"); - widget.onChange(s); - }, - controller: _controller, - textInputAction: - (widget.multiline) ? TextInputAction.newline : TextInputAction.done, - style: widget.textStyle); + decoration: InputDecoration.collapsed(hintText: widget.hintText) + .copyWith(labelText: widget.labelText), + keyboardType: widget.multiline ? TextInputType.multiline : TextInputType.text, + expands: false, + minLines: 1, + maxLines: widget.maxLines, + enableInteractiveSelection: true, + onChanged: widget.onChange, + controller: _controller, + textInputAction: + widget.multiline ? TextInputAction.newline : TextInputAction.done, + style: widget.textStyle, + ); } } class AddSectionItem extends StatelessWidget { - const AddSectionItem({Key key}) : super(key: key); + const AddSectionItem({super.key}); @override Widget build(BuildContext context) { return Container( - padding: EdgeInsets.symmetric(horizontal: 5), - child: Container( - height: 40, - decoration: BoxDecoration( - border: - Border.all(color: Theme.of(context).cardColor, width: 2)), - child: FlatButton( - child: Text("Add Section", - style: Theme.of(context).textTheme.caption), - onPressed: () => addSection(Section(title: "", content: "")), - ))); + padding: const EdgeInsets.symmetric(horizontal: 5), + child: Container( + height: 40, + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).cardColor, width: 2), + ), + child: TextButton( + child: Text('Add Section', style: Theme.of(context).textTheme.bodySmall), + onPressed: () => addSection(Section(title: '', content: '')), + ), + ), + ); } } class SectionListItem extends StatelessWidget { - // Section section, bool moveDown, bool moveUp, GlobalKey globalKey) { final Section section; - final bool moveDown, moveUp; + final bool moveDown; + final bool moveUp; final GlobalKey globalKey; - const SectionListItem( - {this.section, this.moveUp, this.moveDown, this.globalKey, Key key}) - : super(key: key); + const SectionListItem({ + required this.section, + required this.moveUp, + required this.moveDown, + required this.globalKey, + super.key, + }); @override Widget build(BuildContext context) { - List trailingWidgets = []; - if (moveDown) - trailingWidgets.add(IconButton( - icon: Icon(Icons.arrow_drop_down), - onPressed: () => moveSectionDown(section))); - if (moveUp) - trailingWidgets.add(IconButton( - icon: Icon(Icons.arrow_drop_up), - onPressed: () => moveSectionUp(section), - )); - Widget trailing = Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: trailingWidgets - .map((t) => Row(children: [t])) - .toList(), + final trailingWidgets = []; + if (moveDown) { + trailingWidgets.add( + IconButton( + icon: const Icon(Icons.arrow_drop_down), + onPressed: () => moveSectionDown(section), + ), + ); + } + if (moveUp) { + trailingWidgets.add( + IconButton( + icon: const Icon(Icons.arrow_drop_up), + onPressed: () => moveSectionUp(section), + ), + ); + } + + final card = Card( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Editable( + initialValue: section.title, + textStyle: Theme.of(context).textTheme.titleMedium, + onChange: (s) => changeSectionTitle(Tuple2(section, s)), + hintText: 'Title', + maxLines: 4, + ), + ), + Editable( + initialValue: section.content, + textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 13, + fontWeight: FontWeight.normal, + ), + onChange: (s) => changeContent(Tuple2(section, s)), + hintText: 'Content', + multiline: true, + maxLines: null, + ), + ], + ), + ), + ), + Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: trailingWidgets, + ), + ], + ), ); - Card card = Card( - child: Container( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: Container( - padding: EdgeInsets.all(10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.only(bottom: 10), - child: Editable( - initialValue: section.title, - textStyle: Theme.of(context).textTheme.subtitle1, - onChange: (s) => - changeSectionTitle(Tuple2(section, s)), - hintText: 'Title', - maxLines: 100)), - Wrap(children: [ - Editable( - initialValue: section.content, - textStyle: Theme.of(context) - .textTheme - .subtitle2 - .copyWith( - fontSize: 13, - fontWeight: FontWeight.normal), - onChange: (s) => changeContent(Tuple2(section, s)), - hintText: 'Content', - multiline: true) - ]) - ]))), - trailing - ], - ))); - return Dismissible( - child: card, - onDismissed: (d) { - deleteSection(section); - showUndoSnackbar(Scaffold.of(context), - section.hasEmptyTitle ? "Section" : section.title, section, (_) { - undoDeleteSection(section); - }); - }, - direction: DismissDirection.startToEnd, key: globalKey, + direction: DismissDirection.startToEnd, background: Card( - child: Container( - color: Colors.redAccent, - child: Row(children: [Icon(Icons.delete)]), - padding: EdgeInsets.all(10))), + child: Container( + color: Colors.redAccent, + padding: const EdgeInsets.all(10), + child: const Row(children: [Icon(Icons.delete)]), + ), + ), + onDismissed: (_) { + deleteSection(section); + showUndoSnackbar( + context, + section.hasEmptyTitle ? 'Section' : section.title, + section, + (_) => undoDeleteSection(), + ); + }, + child: card, ); } } @@ -183,57 +191,49 @@ class SectionView extends StatelessWidget { final double textScaleFactor; final bool richChords; - SectionView({this.section, this.textScaleFactor, this.richChords = false}); + const SectionView({ + super.key, + required this.section, + required this.textScaleFactor, + this.richChords = false, + }); @override Widget build(BuildContext context) { return Card( - elevation: 0, - child: Container( - child: Row( + elevation: 0, + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: Container( - padding: EdgeInsets.all(10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.only(bottom: 10), - child: Text(section.title, - style: GoogleFonts.robotoMono( - textStyle: Theme.of(context) - .textTheme - .subtitle1 - .copyWith(), - fontSize: 14, - fontFeatures: [ - FontFeature.enable('smcp'), - ], - ), - textScaleFactor: textScaleFactor, - maxLines: 1)), - Wrap(children: [ - Text( - (richChords) - ? resolveRichContent(section.content) - : section.content, - style: GoogleFonts.robotoMono( - textStyle: - Theme.of(context).textTheme.subtitle2, - fontSize: 10, - letterSpacing: 0, - fontFeatures: [FontFeature.tabularFigures()], - fontWeight: FontWeight.normal, - ), - textScaleFactor: textScaleFactor, - maxLines: null, - ) - ]) - ]))), + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text( + section.title, + style: GoogleFonts.robotoMono( + textStyle: Theme.of(context).textTheme.titleMedium, + fontSize: 14, + fontFeatures: const [FontFeature.enable('smcp')], + ), + textScaler: TextScaler.linear(textScaleFactor), + maxLines: 1, + ), + ), + Text( + richChords ? resolveRichContent(section.content) : section.content, + style: GoogleFonts.robotoMono( + textStyle: Theme.of(context).textTheme.bodyMedium, + fontSize: 10, + letterSpacing: 0, + fontFeatures: const [FontFeature.tabularFigures()], + fontWeight: FontWeight.normal, + ), + textScaler: TextScaler.linear(textScaleFactor), + ), ], - ))); + ), + ), + ); } } diff --git a/lib/export.dart b/lib/export.dart index a9fd034..717b3ca 100644 --- a/lib/export.dart +++ b/lib/export.dart @@ -1,11 +1,10 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter_share/flutter_share.dart'; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; +import 'package:share_plus/share_plus.dart'; import 'package:sound/backup.dart'; -import 'package:sound/local_storage.dart'; import 'package:sound/model.dart'; import 'package:path/path.dart' as p; @@ -20,18 +19,11 @@ String getExtension(ExportType t) { return "pdf"; case ExportType.TEXT: return "text"; - default: - return ""; } } class Exporter { static Future export(Note note, ExportType t) async { - if (note.artist == null) { - Settings settings = await LocalStorage().getSettings(); - note.artist = settings.name; - } - switch (t) { case ExportType.JSON: return json(note); @@ -39,24 +31,23 @@ class Exporter { return pdf(note); case ExportType.TEXT: return text(note); - default: - return null; } } static Future exportShare(Note note, ExportType t) async { - String path = await export(note, t); - await FlutterShare.shareFile( - title: '${note.title}.${getExtension(t)}', - text: 'Sharing ${note.title} from SOUND', - filePath: path); + final path = await export(note, t); + await SharePlus.instance.share(ShareParams( + title: '${note.title}.${getExtension(t)}', + text: 'Sharing ${note.title} from SOUND', + files: [XFile(path)], + )); } static String getText(Note note) { - String info = note.getInfoText(); + String? info = note.getInfoText(); String contents = ""; - if (note.artist != null) { + if (note.artist != null && note.artist!.isNotEmpty) { contents += "© ${note.artist} \n"; } contents += note.title + "\n"; @@ -91,7 +82,7 @@ class Exporter { return (TextPainter( text: TextSpan(text: text, style: textStyle), maxLines: 1, - textScaleFactor: textScaleFactor, + textScaler: TextScaler.linear(textScaleFactor), textDirection: TextDirection.ltr) ..layout()) .size; @@ -101,7 +92,7 @@ class Exporter { Directory d = await Backup().getFilesDir(); String path = p.join(d.path, "${note.title}.pdf"); - String info = note.getInfoText(); + String? info = note.getInfoText(); // final Uint8List fontData = File('open-sans.ttf').readAsBytesSync(); // final ttf = pw.Font.ttf(fontData.buffer.asByteData()); final pdf = pw.Document(); @@ -141,7 +132,7 @@ class Exporter { List titleRows = []; // add capo information / artist information... - if (info != null) { + if (info != null && info.isNotEmpty) { titleRows.addAll([ pw.Row(children: [pw.Text(info, style: pw.TextStyle(fontSize: 12))]), pw.Row(children: [pw.Container(height: 10)]) @@ -154,7 +145,7 @@ class Exporter { // spacing between title and content titleRows.add(pw.Row(children: [pw.Container(height: 20)])); - String artist = (note.artist != null ? note.artist : Settings().name); + String? artist = note.artist; var copyright = (artist == null) ? pw.Container() @@ -179,7 +170,7 @@ class Exporter { })); // Page } final file = File(path); - await file.writeAsBytes(pdf.save()); + await file.writeAsBytes(await pdf.save()); return path; } } diff --git a/lib/export_note.dart b/lib/export_note.dart index 6f6c030..a5d69ff 100644 --- a/lib/export_note.dart +++ b/lib/export_note.dart @@ -1,13 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:flutter_share/flutter_share.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:sound/export.dart'; import 'package:sound/utils.dart'; import 'db.dart'; -import 'backup.dart'; import 'model.dart'; class ExportNote extends StatefulWidget { - final ScaffoldState state; + final ScaffoldState? state; ExportNote({this.state}); @@ -18,7 +17,7 @@ class ExportNote extends StatefulWidget { } class ExportNoteState extends State { - String id; + String? id; @override void initState() { @@ -26,19 +25,24 @@ class ExportNoteState extends State { id = null; } - Note get note => DB().notes.firstWhere((n) => n.id == id, orElse: () => null); + Note? get note { + for (final n in DB().notes) { + if (n.id == id) return n; + } + return null; + } _export() async { if (note == null) { showSnack(widget.state, "Please select a note to export first"); return; } - String path = await Exporter.pdf(note); - - await FlutterShare.shareFile( - title: '${note.title}.pdf', - text: 'Sharing PDF of ${note.title}', - filePath: path); + String path = await Exporter.pdf(note!); + await SharePlus.instance.share(ShareParams( + title: '${note!.title}.pdf', + text: 'Sharing PDF of ${note!.title}', + files: [XFile(path)], + )); /* String path = await Backup().exportNote(note); @@ -88,7 +92,7 @@ class ExportNoteState extends State { child: Padding( padding: EdgeInsets.only(left: 10), child: - RaisedButton(onPressed: _export, child: Text("Export"))), + ElevatedButton(onPressed: _export, child: Text("Export"))), ) ]) ]); diff --git a/lib/file_manager.dart b/lib/file_manager.dart index a3f7e56..70080c2 100644 --- a/lib/file_manager.dart +++ b/lib/file_manager.dart @@ -42,24 +42,24 @@ class FileManager { } } - Future copy(AudioFile f, String newPath, {String id}) async { + Future copy(AudioFile f, String newPath, {String? id}) async { File fileCopy = await copyFile(File(f.path), newPath); print("copy audio file ${f.name}"); return _new(f, fileCopy, id); } - AudioFile _new(AudioFile f, File newFile, String id) { + AudioFile _new(AudioFile f, File newFile, String? id) { return AudioFile( createdAt: f.createdAt, duration: f.duration, - id: id == null ? Uuid().v4() : id, + id: id, lastModified: DateTime.now(), loopRange: f.loopRange, name: f.name, path: newFile.path); } - Future copyToNew(AudioFile f, {String id}) async { + Future copyToNew(AudioFile f, {String? id}) async { Directory filesDir = await Backup().getFilesDir(); String ext = p.extension(f.path); String newPath = p.join(filesDir.path, @@ -67,7 +67,7 @@ class FileManager { return copy(f, newPath, id: id); } - Future move(AudioFile f, String newPath, {String id}) async { + Future move(AudioFile f, String newPath, {String? id}) async { File fileMove = await moveFile(File(f.path), newPath); return _new(f, fileMove, id); } diff --git a/lib/gallery.dart b/lib/gallery.dart index 25fc03d..47da4aa 100644 --- a/lib/gallery.dart +++ b/lib/gallery.dart @@ -8,18 +8,19 @@ class Gallery extends StatelessWidget { int get maxRows => (items.length / numItemsPerRow).ceil(); - Gallery( - {@required this.numItemsPerRow, - @required this.items, + const Gallery( + {required this.numItemsPerRow, + required this.items, this.padding = 8.0, - this.widthHeightRatio = 1.0}); + this.widthHeightRatio = 1.0, + super.key}); EdgeInsetsGeometry _getPadding(int row, int col) { double top = (row == 0) ? padding : padding / 2; double bottom = (row == maxRows - 1) ? padding : padding / 2; double left = (col == 0) ? padding : padding / 2; double right = (col == numItemsPerRow - 1) ? padding : padding / 2; - return new EdgeInsets.fromLTRB(left, top, right, bottom); + return EdgeInsets.fromLTRB(left, top, right, bottom); } Widget _getItem(int row, int col, double width) { @@ -29,12 +30,11 @@ class Gallery extends StatelessWidget { double _itemHeight = _itemWidth * widthHeightRatio; EdgeInsetsGeometry _padding = _getPadding(row, col); - print(_padding); if (index >= items.length) - return new Container( + return Container( width: _itemWidth, height: _itemHeight, padding: _padding); else - return new Container( + return Container( width: _itemWidth, height: _itemHeight, padding: _padding, @@ -50,7 +50,7 @@ class Gallery extends StatelessWidget { itemCount: rows, shrinkWrap: true, itemBuilder: (context, index) { - return new Row( + return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, //crossAxisAlignment: CrossAxisAlignment.center, //mainAxisSize: MainAxisSize.min, diff --git a/lib/home.dart b/lib/home.dart index 5534616..abfe4dd 100644 --- a/lib/home.dart +++ b/lib/home.dart @@ -1,79 +1,107 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:sound/dialogs/color_picker_dialog.dart'; import 'package:sound/dialogs/initial_import_dialog.dart'; import 'package:sound/note_views/appbar.dart'; import 'package:sound/note_views/seach.dart'; import 'package:tuple/tuple.dart'; +import 'package:provider/provider.dart'; import 'local_storage.dart'; import 'file_manager.dart'; import 'note_list.dart'; import 'storage.dart'; -import 'package:flutter_flux/flutter_flux.dart'; -import 'dart:ui'; import 'note_editor.dart'; import 'model.dart'; +import 'sync_debug_store.dart'; +import 'sync_conflicts_page.dart'; +import 'sync_status_store.dart'; //import 'recorder.dart'; import 'db.dart'; -class Home extends StatelessWidget { - final Function onMenuPressed; +class Home extends StatefulWidget { + final VoidCallback onMenuPressed; - Home(this.onMenuPressed); + const Home(this.onMenuPressed, {super.key}); - _floatingButtonPress(BuildContext context) { + @override + State createState() => _HomeState(); +} + +class _HomeState extends State { + bool _initialImportChecked = false; + + @override + void initState() { + super.initState(); + _bootstrapHome(); + } + + Future _bootstrapHome() async { + final notes = await LocalStorage().getNotes(); + if (!mounted) return; + + LocalStorage().controller.sink.add( + notes.where((e) => !e.discarded).toList(), + ); + await _showInitialImportIfNeeded(); + } + + Future _showInitialImportIfNeeded() async { + if (_initialImportChecked) return; + _initialImportChecked = true; + + final initialStart = await LocalStorage().isInitialStart(); + if (!mounted || !initialStart) return; + + await showInitialImportDialog(context, (_) async { + await LocalStorage().setInitialStartDone(); + }); + } + + void _floatingButtonPress(BuildContext context) { Note note = Note.empty(); LocalStorage().syncNote(note); Navigator.push( - context, new MaterialPageRoute(builder: (context) => NoteEditor(note))); + context, + MaterialPageRoute(builder: (context) => NoteEditor(note)), + ); } @override Widget build(BuildContext context) { - Future.delayed(Duration(milliseconds: 1000), () async { - bool initialStart = await LocalStorage().isInitialStart(); - if (initialStart) { - showInitialImportDialog(context, (_) { - LocalStorage().setInitialStartDone(); - }); - } - }); - - LocalStorage().getNotes().then((value) => LocalStorage() - .controller - .sink - .add(value.where((e) => !e.discarded).toList())); - var builder = StreamBuilder>( stream: LocalStorage().stream, initialData: [], builder: (context, snap) { print(snap); if (snap.hasData) { - DB().setNotes(snap.data.where((e) => !e.discarded).toList()); - return HomeContent(this.onMenuPressed); + final data = snap.data ?? []; + DB().setNotes(data.where((e) => !e.discarded).toList()); + return HomeContent(widget.onMenuPressed); } else { return CircularProgressIndicator(); } }, ); return Scaffold( - floatingActionButton: FloatingActionButton( - foregroundColor: Colors.white, - backgroundColor: Theme.of(context).accentColor, + floatingActionButton: FloatingActionButton( + foregroundColor: Colors.white, + backgroundColor: Theme.of(context).colorScheme.secondary, + onPressed: () => _floatingButtonPress(context), + child: IconButton( onPressed: () => _floatingButtonPress(context), - child: IconButton( - onPressed: () => _floatingButtonPress(context), - icon: Icon(Icons.add), - ), + icon: Icon(Icons.add), ), - //bottomSheet: RecorderBottomSheet(), - body: builder); + ), + //bottomSheet: RecorderBottomSheet(), + body: builder, + ); } } class HomeContent extends StatefulWidget { - final Function onMenuPressed; + final VoidCallback onMenuPressed; HomeContent(this.onMenuPressed); @override @@ -83,18 +111,20 @@ class HomeContent extends StatefulWidget { } class HomeContentState extends State - with StoreWatcherMixin, SingleTickerProviderStateMixin { - TextEditingController _controller; - StaticStorage storage; + with SingleTickerProviderStateMixin { + late TextEditingController _controller; + late StaticStorage storage; // settings store, use view and set recording format - bool isSearching; - bool filtersEnabled; + bool isSearching = false; + bool filtersEnabled = false; bool get isFiltering => storage.filters.length > 0; @override Widget build(BuildContext context) { + storage = context.watch(); + context.watch(); return _sliver(); } @@ -107,18 +137,16 @@ class HomeContentState extends State @override void initState() { super.initState(); - isSearching = false; - filtersEnabled = false; _controller = TextEditingController(); - storage = listenToStore(storageToken); - // init filemanager FileManager(); } _activeFiltersView() { return ActiveFiltersView( - filters: storage.filters, removeFilter: removeFilter); + filters: storage.filters, + removeFilter: removeFilter, + ); } _filtersView() { @@ -133,23 +161,26 @@ class HomeContentState extends State for (Tuple3, FilterBy, String> option in filterOptions) { if (option.item1.length >= 0) { - items.add(FilterOptionsView( - title: option.item3, - data: option.item1, - by: option.item2, - showMore: storage.showMore(option.item2), - mustShowMore: storage.mustShowMore(option.item2), - isFilterApplied: storage.isFilterApplied, - )); + items.add( + FilterOptionsView( + title: option.item3, + data: option.item1, + by: option.item2, + showMore: storage.showMore(option.item2), + mustShowMore: storage.mustShowMore(option.item2), + isFilterApplied: storage.isFilterApplied, + ), + ); } } return Padding( - padding: EdgeInsets.only(left: 25, top: 60), - child: ListView.builder( - itemBuilder: (context, i) => items[i], - itemCount: items.length, - )); + padding: EdgeInsets.only(left: 25, top: 60), + child: ListView.builder( + itemBuilder: (context, i) => items[i], + itemCount: items.length, + ), + ); } _toggleIsSearching({searching}) { @@ -182,41 +213,174 @@ class HomeContentState extends State _searchActionButtons() { return [ IconButton( - icon: Icon(filtersEnabled ? Icons.arrow_upward : Icons.filter_list), - onPressed: () { - setState(() { - filtersEnabled = !filtersEnabled; - }); - }) + icon: Icon(filtersEnabled ? Icons.arrow_upward : Icons.filter_list), + onPressed: () { + setState(() { + filtersEnabled = !filtersEnabled; + }); + }, + ), ]; } + Widget _syncStatusButton() { + final syncStore = context.watch(); + final syncDebug = context.watch(); + final hasRuntimeError = + (syncDebug.lastError != null && syncDebug.lastError!.trim().isNotEmpty); + final hasConflicts = syncStore.unresolvedConflicts > 0; + final queuedChanges = syncStore.queuedChanges; + final badgeNumber = hasRuntimeError + ? 1 + : (hasConflicts ? syncStore.unresolvedConflicts : queuedChanges); + + final icon = hasRuntimeError + ? Icons.cloud_off + : hasConflicts + ? Icons.cloud_off + : (queuedChanges > 0 ? Icons.cloud_upload : Icons.cloud_done); + final iconColor = hasRuntimeError + ? Colors.redAccent + : hasConflicts + ? Colors.orangeAccent + : (queuedChanges > 0 ? null : Colors.greenAccent); + + Future showMiniDebugOverlay() async { + final debugStore = context.read(); + final syncStore = context.read(); + final backendUrl = await LocalStorage().getSyncBackendUrl(); + final enabled = await LocalStorage().getSyncEnabled(); + if (!mounted) return; + + await showModalBottomSheet( + context: context, + builder: (sheetContext) { + final currentDebug = sheetContext.watch(); + final currentSync = sheetContext.watch(); + String fmt(DateTime? dt) => dt?.toIso8601String() ?? '-'; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Sync Debug', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Text('Enabled: $enabled'), + Text('Backend: $backendUrl'), + Text('Syncing: ${currentDebug.isSyncing}'), + Text('Queued: ${currentSync.queuedChanges}'), + Text('Conflicts: ${currentSync.unresolvedConflicts}'), + Text('Last attempt: ${fmt(currentDebug.lastAttemptAt)}'), + Text('Last success: ${fmt(currentDebug.lastSuccessAt)}'), + Text('Last error: ${currentDebug.lastError ?? '-'}'), + const SizedBox(height: 12), + Row( + children: [ + ElevatedButton.icon( + onPressed: () async { + await debugStore.syncNow(); + await syncStore.refresh(); + }, + icon: const Icon(Icons.sync), + label: const Text('Sync now'), + ), + const SizedBox(width: 10), + OutlinedButton( + onPressed: () => Navigator.pop(sheetContext), + child: const Text('Close'), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } + + return GestureDetector( + onLongPress: kDebugMode ? showMiniDebugOverlay : null, + child: IconButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SyncConflictsPage()), + ); + }, + icon: Stack( + clipBehavior: Clip.none, + children: [ + Icon(icon, color: iconColor), + if (badgeNumber > 0) + Positioned( + right: -6, + top: -6, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: Colors.redAccent, + borderRadius: BorderRadius.circular(12), + ), + constraints: + const BoxConstraints(minWidth: 18, minHeight: 16), + child: Text( + badgeNumber > 99 ? '99+' : '$badgeNumber', + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + tooltip: hasRuntimeError + ? 'Sync connection error' + : (hasConflicts + ? 'Sync conflicts' + : (queuedChanges > 0 + ? 'Queued sync changes' + : 'Everything synced')), + ), + ); + } + _sliverNoteSelectionAppBar() { - print((storage.selectedNotes - .map((e) => e.starred) - .toList() - .length - .toDouble() / - storage.selectedNotes.length.toDouble())); + print( + (storage.selectedNotes.map((e) => e.starred).toList().length.toDouble() / + storage.selectedNotes.length.toDouble()), + ); return SliverAppBar( pinned: true, leading: IconButton( - icon: Icon(Icons.clear), onPressed: () => clearSelection()), + icon: Icon(Icons.clear), + onPressed: () => clearSelection(), + ), title: Text(storage.selectedNotes.length.toString()), actions: [ IconButton( - icon: Icon(Icons.delete), - onPressed: () => discardAllSelectedNotes()), + icon: Icon(Icons.delete), + onPressed: () => discardAllSelectedNotes(), + ), IconButton( - icon: Icon(Icons.color_lens), - onPressed: () { - showColorPickerDialog(context, null, (c) { - colorAllSelectedNotes(c); - }); - }), + icon: Icon(Icons.color_lens), + onPressed: () { + showColorPickerDialog(context, null, (c) { + colorAllSelectedNotes(c); + }); + }, + ), IconButton( - icon: Icon((storage.selectedNotes + icon: Icon( + (storage.selectedNotes .where((e) => e.starred) .toList() .length @@ -224,20 +388,22 @@ class HomeContentState extends State storage.selectedNotes.length.toDouble()) < 0.5 ? Icons.star - : Icons.star_border), - onPressed: () { - if ((storage.selectedNotes - .where((e) => e.starred) - .toList() - .length - .toDouble() / - storage.selectedNotes.length.toDouble()) < - 0.5) { - starAllSelectedNotes(); - } else { - unstarAllSelectedNotes(); - } - }), + : Icons.star_border, + ), + onPressed: () { + if ((storage.selectedNotes + .where((e) => e.starred) + .toList() + .length + .toDouble() / + storage.selectedNotes.length.toDouble()) < + 0.5) { + starAllSelectedNotes(); + } else { + unstarAllSelectedNotes(); + } + }, + ), ], ); } @@ -245,17 +411,23 @@ class HomeContentState extends State _sliverAppBar() { return SliverAppBar( titleSpacing: 5.0, - actions: isSearching ? _searchActionButtons() : _listActionButtons(), + actions: [ + _syncStatusButton(), + ...(isSearching ? _searchActionButtons() : _listActionButtons()), + ], flexibleSpace: (filtersEnabled && isSearching) ? _filtersView() : (isFiltering ? _activeFiltersView() : Container()), leading: isSearching ? IconButton( - icon: Icon(Icons.arrow_back), onPressed: () => _clearSearch()) + icon: Icon(Icons.arrow_back), + onPressed: () => _clearSearch(), + ) : IconButton(icon: Icon(Icons.menu), onPressed: widget.onMenuPressed), title: Padding( - child: Center(child: _searchView()), - padding: EdgeInsets.only(left: 5)), + child: Center(child: _searchView()), + padding: EdgeInsets.only(left: 5), + ), expandedHeight: (isSearching && filtersEnabled) ? 370 : (isFiltering ? 100 : 0), floating: false, @@ -268,8 +440,10 @@ class HomeContentState extends State if (storage.isAnyNoteSelected()) { triggerSelectNote(note); } else { - Navigator.push(context, - new MaterialPageRoute(builder: (context) => NoteEditor(note))); + Navigator.push( + context, + MaterialPageRoute(builder: (context) => NoteEditor(note)), + ); } } @@ -283,48 +457,82 @@ class HomeContentState extends State print("notes are starred"); List items = storage.filteredNotes .where((n) => !n.starred) - .map((n) => - NoteListItemModel(note: n, isSelected: storage.isSelected(n))) + .map( + (n) => + NoteListItemModel(note: n, isSelected: storage.isSelected(n)), + ) .toList(); List starrtedItems = storage.filteredNotes .where((n) => n.starred) - .map((n) => - NoteListItemModel(note: n, isSelected: storage.isSelected(n))) + .map( + (n) => + NoteListItemModel(note: n, isSelected: storage.isSelected(n)), + ) .toList(); noteList = [ SliverList( - delegate: SliverChildListDelegate([ - Padding( + delegate: SliverChildListDelegate([ + Padding( padding: EdgeInsets.only(left: 16, top: 16), - child: Row(children: [ - Text("Starred", style: Theme.of(context).textTheme.caption), - Padding( + child: Row( + children: [ + Text("Starred", style: Theme.of(context).textTheme.bodySmall), + Padding( padding: EdgeInsets.only(left: 8), - child: Icon(Icons.star, size: 16)) - ])) - ])), - NoteList(true, storage.view, starrtedItems, onTap, onLongPress, - highlight: storage.search == "" ? null : storage.search.trim()), + child: Icon(Icons.star, size: 16), + ), + ], + ), + ), + ]), + ), + NoteList( + true, + storage.view, + starrtedItems, + onTap, + onLongPress, + highlight: storage.search == "" ? null : storage.search.trim(), + ), SliverList( - delegate: SliverChildListDelegate([ - Padding( + delegate: SliverChildListDelegate([ + Padding( padding: EdgeInsets.only(left: 16), - child: Text("Other", style: Theme.of(context).textTheme.caption)) - ])), - NoteList(true, storage.view, items, onTap, onLongPress, - highlight: storage.search == "" ? null : storage.search.trim()) + child: Text( + "Other", + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ]), + ), + NoteList( + true, + storage.view, + items, + onTap, + onLongPress, + highlight: storage.search == "" ? null : storage.search.trim(), + ), ]; } else { List items = storage.filteredNotes - .map((n) => - NoteListItemModel(note: n, isSelected: storage.isSelected(n))) + .map( + (n) => + NoteListItemModel(note: n, isSelected: storage.isSelected(n)), + ) .toList(); noteList = [ - NoteList(true, storage.view, items, onTap, onLongPress, - highlight: storage.search == "" ? null : storage.search) + NoteList( + true, + storage.view, + items, + onTap, + onLongPress, + highlight: storage.search == "" ? null : storage.search, + ), ]; } @@ -332,15 +540,14 @@ class HomeContentState extends State ? _sliverNoteSelectionAppBar() : _sliverAppBar(); - return CustomScrollView( - slivers: [appBar]..addAll(noteList), - ); + return CustomScrollView(slivers: [appBar]..addAll(noteList)); } _searchView() { return SearchTextView( - toggleIsSearching: _toggleIsSearching, - onChanged: searchNotes, - controller: _controller); + toggleIsSearching: _toggleIsSearching, + onChanged: searchNotes, + controller: _controller, + ); } } diff --git a/lib/intent_receive.dart b/lib/intent_receive.dart index 52f6991..23fcb96 100644 --- a/lib/intent_receive.dart +++ b/lib/intent_receive.dart @@ -1,45 +1,39 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:sound/dialogs/audio_import_dialog.dart'; -import 'package:sound/dialogs/text_import_dialog.dart'; showDataInvalidSnack(BuildContext context) { var snackbar = SnackBar( content: Text("The dataformat/files were invalid"), - backgroundColor: Theme.of(context).errorColor); - Scaffold.of(context).showSnackBar(snackbar); + backgroundColor: Theme.of(context).colorScheme.error); + ScaffoldMessenger.of(context).showSnackBar(snackbar); } setupIntentReceivers(BuildContext context) { - // For sharing or opening urls/text coming from outside the app while the app is closed - ReceiveSharingIntent.getInitialText().then((String value) { - if (value != null) { - showTextImportDialog(context, value); + if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) { + return; + } + final sharing = ReceiveSharingIntent.instance; + // For sharing files coming from outside the app while the app is closed + sharing.getInitialMedia().then((List value) async { + var audioExtensions = ['.m4a', ".wav", ".mp3", ".aac"]; + var _validFiles = value.where( + (f) => audioExtensions.any((e) => f.path.toLowerCase().endsWith(e))); + + if (_validFiles.length == 0) { + showDataInvalidSnack(context); + return; } - }); - // For sharing images coming from outside the app while the app is closed - ReceiveSharingIntent.getInitialMedia() - .then((List value) async { - if (value != null) { - var audioExtensions = ['.m4a', ".wav", ".mp3", ".aac"]; - var _validFiles = value.where( - (f) => audioExtensions.any((e) => f.path.toLowerCase().endsWith(e))); + print("Shared valid audio files:" + + _validFiles.map((f) => f.path).join(",")); + List files = _validFiles.map((f) => File(f.path)).toList(); - if (_validFiles.length == 0) { - showDataInvalidSnack(context); - return; - } + showAudioImportDialog(context, files); + // show dialog to add text/audio to file or create a new one - print("Shared valid audio files:" + - (_validFiles?.map((f) => f.path)?.join(",") ?? "")); - List files = _validFiles.map((f) => File(f.path)).toList(); - - showAudioImportDialog(context, files); - // show dialog to add text/audio to file or create a new one - - } - }); + }); } diff --git a/lib/local_storage.dart b/lib/local_storage.dart index 5d7c8f4..2ab4f52 100644 --- a/lib/local_storage.dart +++ b/lib/local_storage.dart @@ -1,226 +1,779 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:path/path.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:uuid/uuid.dart'; import 'model.dart'; -import 'dart:async'; -import 'package:path/path.dart'; -import 'package:sqflite/sqflite.dart'; -// table defintions final String noteTable = 'notes'; final String sectionTable = 'sections'; final String audioFileTable = 'audiofiles'; -final String noteSetTable = 'sets'; - -// up and downgrades of the database -final migrations = { - 4: { - 5: [ - //upgrade - "CREATE TABLE $noteSetTable(id TEXT PRIMARY KEY, title TEXT, description TEXT, createdAt TEXT, lastModified TEXT);", - "ALTER TABLE $noteTable ADD setId TEXT;" - ] - }, - 5: { - 4: [ - // downgrade - "DROP TABLE $noteSetTable;", - "ALTER TABLE $noteTable DROP COLUMN setId;" - ] - } -}; +final String collectionTable = 'collections'; +final String collectionMappingTable = 'collectionmapping'; +final String syncQueueTable = 'sync_queue'; +final String syncConflictTable = 'sync_conflicts'; +final String syncVersionTable = 'sync_versions'; class LocalStorage { LocalStorage._internal(); - static final LocalStorage _singleton = new LocalStorage._internal(); + static final LocalStorage _singleton = LocalStorage._internal(); final StreamController> _controller = StreamController>.broadcast(); + final StreamController> _collectionController = + StreamController>.broadcast(); + final StreamController _syncStatusController = + StreamController.broadcast(); StreamController> get controller => _controller; + StreamController> get collectionController => + _collectionController; Stream> get stream => _controller.stream.asBroadcastStream(); + Stream> get collectionStream => + _collectionController.stream.asBroadcastStream(); + Stream get syncStatusStream => + _syncStatusController.stream.asBroadcastStream(); - factory LocalStorage() { - return _singleton; - } + factory LocalStorage() => _singleton; - Future deleteFile(File f) { - return f.delete(); - } + Future deleteFile(File f) => f.delete(); Future getDatabase() async { return openDatabase( - // Set the path to the database. Note: Using the `join` function from the - // `path` package is best practice to ensure the path is correctly - // constructed for each platform. join(await getDatabasesPath(), 'sketchord.db'), - // When the database is first created, create a table to store dogs. - onCreate: (db, version) { - // Run the CREATE TABLE statement on the database. - createDatabase(db); - }, - onUpgrade: (Database db, int oldVersion, int newVersion) async { - print("performing upgrade from $oldVersion to $newVersion"); - migrations[oldVersion][newVersion] - .forEach((script) async => await db.execute(script)); - }, - onDowngrade: (Database db, int oldVersion, int newVersion) { - print("performing downgrade from $oldVersion to $newVersion"); - migrations[oldVersion][newVersion] - .forEach((script) async => await db.execute(script)); - }, - // Set the version. This executes the onCreate function and provides a - // path to perform database upgrades and downgrades. - version: 1, + version: 3, + onCreate: (db, version) async => createDatabase(db), + onUpgrade: (db, oldVersion, newVersion) async => _ensureSchema(db), + onOpen: (db) async => _ensureSchema(db), ); } Future createDatabase(Database db) async { - // create initial database - print("creating initial tables"); + await db.execute('''CREATE TABLE $noteTable( + id TEXT PRIMARY KEY, + title TEXT, + createdAt TEXT, + lastModified TEXT, + key TEXT, + tuning TEXT, + capo TEXT, + instrument TEXT, + label TEXT, + artist TEXT, + color TEXT, + bpm REAL, + length REAL, + zoom REAL, + scrollOffset REAL, + starred INTEGER, + discarded INTEGER + );'''); + await db.execute( + 'CREATE TABLE $sectionTable(id TEXT PRIMARY KEY, noteId TEXT, title TEXT, content TEXT, idx INTEGER);', + ); await db.execute( - """CREATE TABLE $noteTable(id TEXT PRIMARY KEY, title TEXT, createdAt TEXT, lastModified TEXT, - key TEXT, tuning TEXT, capo TEXT, instrument TEXT, label TEXT, artist TEXT, color TEXT, bpm REAL, zoom REAL, - scrollOffset REAL, starred INTEGER, discarded INTEGER); - """, + 'CREATE TABLE $audioFileTable(id TEXT PRIMARY KEY, noteId TEXT, idx INTEGER, duration TEXT, path TEXT, createdAt TEXT, lastModified TEXT, name TEXT, loopRange TEXT, text TEXT, starred INTEGER);', ); await db.execute( - 'CREATE TABLE $sectionTable(id TEXT PRIMARY KEY, noteId TEXT, title TEXT, content TEXT, idx INTEGER);'); + 'CREATE TABLE $collectionTable(id TEXT PRIMARY KEY, title TEXT, description TEXT, createdAt TEXT, lastModified TEXT, starred INTEGER);', + ); + await db.execute( + 'CREATE TABLE $collectionMappingTable(noteId TEXT, collectionId TEXT);', + ); + await db.execute('''CREATE TABLE $syncQueueTable( + id TEXT PRIMARY KEY, + entityType TEXT, + entityId TEXT, + operation TEXT, + payload TEXT, + baseVersion INTEGER, + createdAt TEXT, + status TEXT, + retryCount INTEGER, + lastError TEXT + );'''); + await db.execute('''CREATE TABLE $syncConflictTable( + id TEXT PRIMARY KEY, + entityType TEXT, + entityId TEXT, + operation TEXT, + reason TEXT, + localPayload TEXT, + remotePayload TEXT, + createdAt TEXT, + resolvedAt TEXT + );'''); + await db.execute('''CREATE TABLE $syncVersionTable( + entityType TEXT, + entityId TEXT, + serverVersion INTEGER, + PRIMARY KEY(entityType, entityId) + );'''); + } + Future _ensureSchema(Database db) async { + await db.execute( + 'CREATE TABLE IF NOT EXISTS $collectionTable(id TEXT PRIMARY KEY, title TEXT, description TEXT, createdAt TEXT, lastModified TEXT, starred INTEGER);', + ); + await db.execute( + 'CREATE TABLE IF NOT EXISTS $collectionMappingTable(noteId TEXT, collectionId TEXT);', + ); + await db.execute( + 'CREATE TABLE IF NOT EXISTS $syncQueueTable(id TEXT PRIMARY KEY, entityType TEXT, entityId TEXT, operation TEXT, payload TEXT, baseVersion INTEGER, createdAt TEXT, status TEXT, retryCount INTEGER, lastError TEXT);', + ); + await db.execute( + 'CREATE TABLE IF NOT EXISTS $syncConflictTable(id TEXT PRIMARY KEY, entityType TEXT, entityId TEXT, operation TEXT, reason TEXT, localPayload TEXT, remotePayload TEXT, createdAt TEXT, resolvedAt TEXT);', + ); await db.execute( - 'CREATE TABLE $audioFileTable(id TEXT PRIMARY KEY, noteId TEXT, idx INTEGER, duration TEXT, path TEXT, createdAt TEXT, lastModified TEXT, name TEXT, loopRange TEXT);'); + 'CREATE TABLE IF NOT EXISTS $syncVersionTable(entityType TEXT, entityId TEXT, serverVersion INTEGER, PRIMARY KEY(entityType, entityId));', + ); + + if (!await _hasColumn(db, noteTable, 'length')) { + await db.execute('ALTER TABLE $noteTable ADD length REAL;'); + } + if (!await _hasColumn(db, audioFileTable, 'text')) { + await db.execute('ALTER TABLE $audioFileTable ADD text TEXT;'); + } + if (!await _hasColumn(db, audioFileTable, 'starred')) { + await db.execute('ALTER TABLE $audioFileTable ADD starred INTEGER;'); + } + } + + Future _hasColumn(Database db, String table, String column) async { + final res = await db.rawQuery('PRAGMA table_info($table)'); + return res.any((row) => row['name'] == column); + } + + Future _getEntityBaseVersion( + Database db, + SyncEntityType entityType, + String entityId, + ) async { + final rows = await db.query( + syncVersionTable, + where: 'entityType = ? AND entityId = ?', + whereArgs: [entityType.name, entityId], + limit: 1, + ); + if (rows.isEmpty) return 0; + return (rows.first['serverVersion'] as num?)?.toInt() ?? 0; + } + + Future setEntityServerVersion({ + required SyncEntityType entityType, + required String entityId, + required int serverVersion, + }) async { + final db = await getDatabase(); + await db.insert( + syncVersionTable, + { + 'entityType': entityType.name, + 'entityId': entityId, + 'serverVersion': serverVersion, + }, + conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future enqueueSyncChange({ + required SyncEntityType entityType, + required String entityId, + required SyncOperationType operation, + required Map payload, + }) async { + final db = await getDatabase(); + final now = serializeDateTime(DateTime.now()); + final baseVersion = await _getEntityBaseVersion(db, entityType, entityId); + final existingQueuedUpserts = await db.query( + syncQueueTable, + where: 'entityType = ? AND entityId = ? AND status = ? AND operation = ?', + whereArgs: [ + entityType.name, + entityId, + SyncQueueStatus.queued.name, + SyncOperationType.upsert.name, + ], + orderBy: 'createdAt ASC', + ); + + // Coalesce rapid repeated edits of the same entity into one queued upsert. + if (operation == SyncOperationType.upsert && + existingQueuedUpserts.isNotEmpty) { + final primary = Map.from(existingQueuedUpserts.first); + final primaryId = primary['id'] as String; + await db.update( + syncQueueTable, + { + 'payload': jsonEncode(payload), + 'createdAt': now, + 'lastError': null, + }, + where: 'id = ?', + whereArgs: [primaryId], + ); + for (int i = 1; i < existingQueuedUpserts.length; i++) { + final row = Map.from(existingQueuedUpserts[i]); + await db + .delete(syncQueueTable, where: 'id = ?', whereArgs: [row['id']]); + } + await _notifySyncStatusChanged(); + return; + } + + // A delete supersedes queued upserts for the same entity. + if (operation == SyncOperationType.delete || + operation == SyncOperationType.tombstone) { + for (final row in existingQueuedUpserts) { + await db + .delete(syncQueueTable, where: 'id = ?', whereArgs: [row['id']]); + } + } + + await db.insert( + syncQueueTable, + { + 'id': const Uuid().v4(), + 'entityType': entityType.name, + 'entityId': entityId, + 'operation': operation.name, + 'payload': jsonEncode(payload), + 'baseVersion': baseVersion, + 'createdAt': now, + 'status': SyncQueueStatus.queued.name, + 'retryCount': 0, + 'lastError': null, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + await _notifySyncStatusChanged(); } - Future syncNote(Note note) async { - print("Syncing note ${note.id} with title ${note.title}"); + Future markQueueItemSynced( + String queueId, { + int? newServerVersion, + SyncEntityType? entityType, + String? entityId, + }) async { final db = await getDatabase(); + await db.delete(syncQueueTable, where: 'id = ?', whereArgs: [queueId]); + if (newServerVersion != null && entityType != null && entityId != null) { + await setEntityServerVersion( + entityType: entityType, + entityId: entityId, + serverVersion: newServerVersion, + ); + } + await _notifySyncStatusChanged(); + } + + Future markQueueItemRejected({ + required String queueId, + required String reason, + Map? remotePayload, + }) async { + final db = await getDatabase(); + final rows = await db.query( + syncQueueTable, + where: 'id = ?', + whereArgs: [queueId], + limit: 1, + ); + if (rows.isEmpty) return; + + final item = SyncQueueItem.fromJson(Map.from(rows.first)); + await db.update( + syncQueueTable, + { + 'status': SyncQueueStatus.rejected.name, + 'retryCount': item.retryCount + 1, + 'lastError': reason, + }, + where: 'id = ?', + whereArgs: [queueId], + ); + await db.insert( + syncConflictTable, + { + 'id': const Uuid().v4(), + 'entityType': item.entityType.name, + 'entityId': item.entityId, + 'operation': item.operation.name, + 'reason': reason, + 'localPayload': item.payload, + 'remotePayload': + remotePayload == null ? null : jsonEncode(remotePayload), + 'createdAt': serializeDateTime(DateTime.now()), + 'resolvedAt': null, + }, + conflictAlgorithm: ConflictAlgorithm.replace); + await _notifySyncStatusChanged(); + } + + Future> getQueuedSyncChanges() async { + final db = await getDatabase(); + final rows = await db.query( + syncQueueTable, + where: 'status = ?', + whereArgs: [SyncQueueStatus.queued.name], + orderBy: 'createdAt ASC', + ); + return rows + .map((row) => SyncQueueItem.fromJson(Map.from(row))) + .toList(); + } + + Future> getSyncConflicts({ + bool unresolvedOnly = true, + }) async { + final db = await getDatabase(); + final rows = await db.query( + syncConflictTable, + where: unresolvedOnly ? 'resolvedAt IS NULL' : null, + orderBy: 'createdAt DESC', + ); + return rows + .map((row) => SyncConflict.fromJson(Map.from(row))) + .toList(); + } + + Future resolveSyncConflict(String conflictId) async { + final db = await getDatabase(); + await db.update( + syncConflictTable, + {'resolvedAt': serializeDateTime(DateTime.now())}, + where: 'id = ?', + whereArgs: [conflictId], + ); + await _notifySyncStatusChanged(); + } + Future resolveAllSyncConflicts() async { + final db = await getDatabase(); + final count = await db.update( + syncConflictTable, + {'resolvedAt': serializeDateTime(DateTime.now())}, + where: 'resolvedAt IS NULL', + ); + await _notifySyncStatusChanged(); + return count; + } + + Future getSyncStatusSummary() async { + final db = await getDatabase(); + final queued = Sqflite.firstIntValue( + await db.rawQuery( + 'SELECT COUNT(*) FROM $syncQueueTable WHERE status = ?', + [SyncQueueStatus.queued.name], + ), + ); + final conflicts = Sqflite.firstIntValue( + await db.rawQuery( + 'SELECT COUNT(*) FROM $syncConflictTable WHERE resolvedAt IS NULL', + ), + ); + return SyncStatusSummary( + queuedChanges: queued ?? 0, + unresolvedConflicts: conflicts ?? 0, + ); + } + + Future _notifySyncStatusChanged() async { + if (_syncStatusController.isClosed) return; + final summary = await getSyncStatusSummary(); + _syncStatusController.sink.add(summary); + } + + Future syncNote( + Note note, { + bool enqueueChange = true, + bool touchLastModified = true, + }) async { + final db = await getDatabase(); + + await db.delete(sectionTable, where: 'noteId = ?', whereArgs: [note.id]); for (int i = 0; i < note.sections.length; i++) { - Map sectionData = note.sections[i].toJson(); + final sectionData = note.sections[i].toJson(); sectionData['idx'] = i; sectionData['noteId'] = note.id; - - await db.insert(sectionTable, sectionData, - conflictAlgorithm: ConflictAlgorithm.replace); + await db.insert( + sectionTable, + sectionData, + conflictAlgorithm: ConflictAlgorithm.replace, + ); } + await db.delete(audioFileTable, where: 'noteId = ?', whereArgs: [note.id]); for (int i = 0; i < note.audioFiles.length; i++) { - Map autdioFileData = note.audioFiles[i].toJson(); - autdioFileData['idx'] = i; - autdioFileData['noteId'] = note.id; + final audioFileData = note.audioFiles[i].toJson(); + audioFileData['idx'] = i; + audioFileData['noteId'] = note.id; + await db.insert( + audioFileTable, + audioFileData, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } - await db.insert(audioFileTable, autdioFileData, - conflictAlgorithm: ConflictAlgorithm.replace); + if (touchLastModified) { + note.lastModified = DateTime.now(); + } + final data = note.toJson() + ..remove('sections') + ..remove('audioFiles'); + final row = await db.insert( + noteTable, + data, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + if (enqueueChange) { + await enqueueSyncChange( + entityType: SyncEntityType.note, + entityId: note.id, + operation: SyncOperationType.upsert, + payload: note.toJson(), + ); } - note.lastModified = DateTime.now(); - Map data = note.toJson(); - data.remove('sections'); - data.remove('audioFiles'); + _controller.sink.add(await getNotes()); + return row; + } - int row = await db.insert(noteTable, data, - conflictAlgorithm: ConflictAlgorithm.replace); + Future addAudioIdea( + AudioFile f, { + bool enqueueChange = true, + }) async { + final db = await getDatabase(); + final data = f.toJson(); + data.remove('noteId'); + data.remove('idx'); + final row = await db.insert( + audioFileTable, + data, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + if (enqueueChange) { + await enqueueSyncChange( + entityType: SyncEntityType.audioIdea, + entityId: f.id, + operation: SyncOperationType.upsert, + payload: f.toJson(), + ); + } + return row; + } - print("Done Syncing ${note.id} in row $row"); - _controller.sink.add(await getNotes()); + Future syncAudioFile( + AudioFile f, { + bool enqueueChange = true, + }) async { + final db = await getDatabase(); + int row = await db.update( + audioFileTable, + f.toJson(), + where: 'id = ?', + whereArgs: [f.id], + conflictAlgorithm: ConflictAlgorithm.replace, + ); + if (row == 0) { + row = await db.insert( + audioFileTable, + f.toJson(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + if (enqueueChange) { + await enqueueSyncChange( + entityType: SyncEntityType.audioFile, + entityId: f.id, + operation: SyncOperationType.upsert, + payload: f.toJson(), + ); + } return row; } Future> getSections(String noteId) async { - List> maps = await (await getDatabase()) - .query(sectionTable, where: 'noteId = ?', whereArgs: [noteId]); - + var maps = await (await getDatabase()).query( + sectionTable, + where: 'noteId = ?', + whereArgs: [noteId], + ); maps = maps.map((m) => Map.from(m)).toList(); - if (maps == null) return []; - - // copy maps to sort them properly - maps.sort((s1, s2) => s1['idx'] - s2['idx']); + maps.sort((s1, s2) => (s1['idx'] as int) - (s2['idx'] as int)); return maps.map((s) => Section.fromJson(s)).toList(); } Future> getAudioFiles(String noteId) async { - List> maps = await (await getDatabase()) - .query(audioFileTable, where: 'noteId = ?', whereArgs: [noteId]); - if (maps == null) return []; - - // copy maps to sort them properly + var maps = await (await getDatabase()).query( + audioFileTable, + where: 'noteId = ?', + whereArgs: [noteId], + ); maps = maps.map((m) => Map.from(m)).toList(); - maps.sort((s1, s2) => s1['idx'] - s2['idx']); + maps.sort((s1, s2) => (s1['idx'] as int) - (s2['idx'] as int)); return maps.map((s) => AudioFile.fromJson(s)).toList(); } - Future getNote(Map data) async { - String noteId = data['id']; - if (noteId == null) return null; + Future> getAudioIdeas({bool descending = true}) async { + var maps = await (await getDatabase()).query( + audioFileTable, + where: 'noteId IS NULL', + ); + maps = maps.map((m) => Map.from(m)).toList(); + final files = maps.map((s) => AudioFile.fromJson(s)).toList(); + files.sort( + (a, b) => descending + ? b.createdAt.compareTo(a.createdAt) + : a.createdAt.compareTo(b.createdAt), + ); + return files; + } - Note note = Note.fromJson(data, noteId); + Future getNoteById(String id) async { + final maps = await (await getDatabase()).query( + noteTable, + where: 'id = ?', + whereArgs: [id], + ); + if (maps.isEmpty) return null; + return getNote(Map.from(maps.first)); + } + + Future getNote(Map data) async { + final noteId = data['id'] as String; + final note = Note.fromJson(data, noteId); note.sections = await getSections(noteId); note.audioFiles = await getAudioFiles(noteId); return note; } Future> getNotes() async { - final List> maps = - await (await getDatabase()).query(noteTable); + final maps = await (await getDatabase()).query(noteTable); + final notes = []; + for (final map in maps) { + notes.add(await getNote(Map.from(map))); + } + return notes; + } + + Future> getCollections() async { + final maps = await (await getDatabase()).query(collectionTable); + final collections = []; + for (final map in maps) { + final collection = NoteCollection.fromJson( + Map.from(map), + ); + collection.notes = await getNotesByCollectionId(collection.id); + collections.add(collection); + } + return collections; + } + + Future syncCollection( + NoteCollection collection, { + bool enqueueChange = true, + bool touchLastModified = true, + }) async { + final db = await getDatabase(); + if (touchLastModified) { + collection.lastModified = DateTime.now(); + } + + final data = collection.toJson()..remove('notes'); + await db.insert( + collectionTable, + data, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + + final existingNoteIds = await _getNoteIdsByCollectionId(collection.id, db); + for (final note in collection.notes) { + if (!existingNoteIds.contains(note.id)) { + await db.insert( + collectionMappingTable, + { + 'noteId': note.id, + 'collectionId': collection.id, + }, + conflictAlgorithm: ConflictAlgorithm.replace); + } else { + existingNoteIds.remove(note.id); + } + } - if (maps == null) return []; + for (final noteId in existingNoteIds) { + await db.delete( + collectionMappingTable, + where: 'collectionId = ? AND noteId = ?', + whereArgs: [collection.id, noteId], + ); + } + + if (enqueueChange) { + await enqueueSyncChange( + entityType: SyncEntityType.collection, + entityId: collection.id, + operation: SyncOperationType.upsert, + payload: collection.toJson(), + ); + } - List notes = []; + _collectionController.sink.add(await getCollections()); + } - for (var map in maps) { - Note note = await getNote(map); + Future getNumCollectionsByNoteId(String noteId) async { + final maps = await (await getDatabase()).query( + collectionMappingTable, + where: 'noteId = ?', + whereArgs: [noteId], + ); + return maps.length; + } + + Future> getNotesByCollectionId(String collectionId) async { + final maps = await (await getDatabase()).query( + collectionMappingTable, + where: 'collectionId = ?', + whereArgs: [collectionId], + ); + final notes = []; + for (final map in maps) { + final noteId = map['noteId'] as String?; + if (noteId == null) continue; + final note = await getNoteById(noteId); if (note != null) notes.add(note); } return notes; } - Future _deleteAudioFile(AudioFile audioFile) async { - FileSystemEntity e = await audioFile.file.delete(); - return !e.existsSync(); + Future> _getNoteIdsByCollectionId( + String collectionId, + Database db, + ) async { + final maps = await db.query( + collectionMappingTable, + where: 'collectionId = ?', + whereArgs: [collectionId], + ); + return maps + .map((row) => row['noteId']) + .whereType() + .toList(growable: true); } - Future deleteNote(Note note) async { + Future _deleteAudioFile( + AudioFile audioFile, { + bool enqueueChange = true, + }) async { final db = await getDatabase(); + await db.delete(audioFileTable, where: 'id = ?', whereArgs: [audioFile.id]); + if (enqueueChange) { + await enqueueSyncChange( + entityType: SyncEntityType.audioIdea, + entityId: audioFile.id, + operation: SyncOperationType.delete, + payload: {'id': audioFile.id}, + ); + } + if (audioFile.file.existsSync()) { + await audioFile.file.delete(); + } + return !audioFile.file.existsSync(); + } + Future deleteAudioIdea( + AudioFile audioFile, { + bool enqueueChange = true, + }) => + _deleteAudioFile(audioFile, enqueueChange: enqueueChange); + + Future deleteNote( + Note note, { + bool enqueueChange = true, + }) async { + final db = await getDatabase(); + await db.delete(noteTable, where: 'id = ?', whereArgs: [note.id]); await db.delete( - noteTable, - where: 'id = ?', + collectionMappingTable, + where: 'noteId = ?', whereArgs: [note.id], ); - for (AudioFile f in note.audioFiles) { - await _deleteAudioFile(f); + for (final f in note.audioFiles) { + await _deleteAudioFile(f, enqueueChange: false); + } + if (enqueueChange) { + await enqueueSyncChange( + entityType: SyncEntityType.note, + entityId: note.id, + operation: SyncOperationType.delete, + payload: {'id': note.id}, + ); } _controller.sink.add(await getNotes()); } - Future _updateTable(String table, Map data, - {String where = 'id = ?'}) async { + Future deleteCollection( + NoteCollection collection, { + bool enqueueChange = true, + }) async { final db = await getDatabase(); - - return await db.update( - table, - data, - where: where, - whereArgs: [data['id']], + await db.delete( + collectionTable, + where: 'id = ?', + whereArgs: [collection.id], ); + await db.delete( + collectionMappingTable, + where: 'collectionId = ?', + whereArgs: [collection.id], + ); + if (enqueueChange) { + await enqueueSyncChange( + entityType: SyncEntityType.collection, + entityId: collection.id, + operation: SyncOperationType.delete, + payload: {'id': collection.id}, + ); + } + _collectionController.sink.add(await getCollections()); } - Future discardNote(Note note) async { + Future _updateTable( + String table, + Map data, { + String where = 'id = ?', + }) async { + final db = await getDatabase(); + return db.update(table, data, where: where, whereArgs: [data['id']]); + } + + Future discardNote( + Note note, { + bool removeFromCollection = false, + }) async { note.discarded = true; - _updateNote(note); + await _updateNote(note); + if (removeFromCollection) { + final db = await getDatabase(); + await db.delete( + collectionMappingTable, + where: 'noteId = ?', + whereArgs: [note.id], + ); + } } Future _updateNote(Note note) async { - // this function does not update sections and audio files note.lastModified = DateTime.now(); - var data = note.toJson(); - data.remove("sections"); - data.remove("audioFiles"); - + final data = note.toJson() + ..remove('sections') + ..remove('audioFiles'); await _updateTable(noteTable, data); _controller.sink.add(await getNotes()); } @@ -236,13 +789,13 @@ class LocalStorage { } Future isInitialStart() async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - bool started = prefs.getBool('started'); - return started == null ? true : !started; + final prefs = await SharedPreferences.getInstance(); + final started = prefs.getBool('started'); + return !(started ?? false); } Future setInitialStartDone() async { - SharedPreferences prefs = await SharedPreferences.getInstance(); + final prefs = await SharedPreferences.getInstance(); await prefs.setBool('started', true); } @@ -254,15 +807,155 @@ class LocalStorage { return (await getNotes()).where((n) => n.discarded).toList(); } - Future syncSettings(Settings settings) async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - return await prefs.setString("settings", jsonEncode(settings.toJson())); + Future syncSettings( + Settings settings, { + bool enqueueChange = true, + }) async { + final prefs = await SharedPreferences.getInstance(); + final synced = await prefs.setString( + 'settings', + jsonEncode(settings.toJson()), + ); + if (synced && enqueueChange) { + await enqueueSyncChange( + entityType: SyncEntityType.settings, + entityId: 'settings', + operation: SyncOperationType.upsert, + payload: settings.toJson(), + ); + } + return synced; + } + + Future getSyncBackendUrl() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('syncBackendUrl') ?? 'http://192.168.178.52:8009'; + } + + Future setSyncBackendUrl(String url) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('syncBackendUrl', url); + } + + Future getSyncEnabled() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool('syncEnabled') ?? true; + } + + Future setSyncEnabled(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('syncEnabled', enabled); + } + + Future getSyncPullCursor() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt('syncPullCursor') ?? 0; + } + + Future setSyncPullCursor(int seq) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('syncPullCursor', seq); + } + + Future applyRemoteChange({ + required SyncEntityType entityType, + required String entityId, + required SyncOperationType operation, + required Map payload, + required int serverVersion, + }) async { + if (entityType == SyncEntityType.note) { + if (operation == SyncOperationType.delete || + operation == SyncOperationType.tombstone) { + final note = await getNoteById(entityId); + if (note != null) { + await deleteNote(note, enqueueChange: false); + } + } else { + final note = Note.fromJson(payload, entityId); + await syncNote(note, enqueueChange: false, touchLastModified: false); + } + await setEntityServerVersion( + entityType: entityType, + entityId: entityId, + serverVersion: serverVersion, + ); + return; + } + + if (entityType == SyncEntityType.collection) { + if (operation == SyncOperationType.delete || + operation == SyncOperationType.tombstone) { + NoteCollection? collection; + for (final c in await getCollections()) { + if (c.id == entityId) { + collection = c; + break; + } + } + if (collection != null) { + await deleteCollection(collection, enqueueChange: false); + } + } else { + final collection = NoteCollection.fromJson(payload); + await syncCollection( + collection, + enqueueChange: false, + touchLastModified: false, + ); + } + await setEntityServerVersion( + entityType: entityType, + entityId: entityId, + serverVersion: serverVersion, + ); + return; + } + + if (entityType == SyncEntityType.settings && + operation == SyncOperationType.upsert) { + final settings = Settings.fromJson(payload); + await syncSettings(settings, enqueueChange: false); + await setEntityServerVersion( + entityType: entityType, + entityId: entityId, + serverVersion: serverVersion, + ); + return; + } + + if (entityType == SyncEntityType.audioIdea || + entityType == SyncEntityType.audioFile) { + if (operation == SyncOperationType.delete || + operation == SyncOperationType.tombstone) { + final db = await getDatabase(); + await db.delete(audioFileTable, where: 'id = ?', whereArgs: [entityId]); + } else { + final f = AudioFile.fromJson(payload); + final updated = await syncAudioFile(f, enqueueChange: false); + if (updated == 0) { + await addAudioIdea(f, enqueueChange: false); + } + } + await setEntityServerVersion( + entityType: entityType, + entityId: entityId, + serverVersion: serverVersion, + ); + return; + } } Future getSettings() async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - String data = prefs.getString('settings'); - if (data == null) return null; + final prefs = await SharedPreferences.getInstance(); + final data = prefs.getString('settings'); + if (data == null || data.isEmpty) { + return Settings( + theme: SettingsTheme.dark, + view: EditorView.single, + audioFormat: AudioFormat.wav, + ); + } return Settings.fromJson(jsonDecode(data)); } } diff --git a/lib/looper.dart b/lib/looper.dart index f3b88c7..e71dbc0 100644 --- a/lib/looper.dart +++ b/lib/looper.dart @@ -1,121 +1,90 @@ -import 'package:flushbar/flushbar.dart'; +import 'package:another_flushbar/flushbar.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_flux/flutter_flux.dart'; -import 'package:sound/editor_store.dart'; -import 'package:sound/model.dart'; -import 'package:sound/recorder_store.dart'; -import 'package:sound/utils.dart'; +import 'package:provider/provider.dart'; + +import 'editor_store.dart'; import 'range_slider.dart' as frs; +import 'recorder_store.dart'; class Looper extends StatefulWidget { final Color color; - Looper(this.color, {Key key}) : super(key: key); + const Looper(this.color, {super.key}); @override - _LooperState createState() => _LooperState(); + State createState() => _LooperState(); } -class _LooperState extends State with StoreWatcherMixin { - RangeValues range; - RecorderBottomSheetStore store; - ActionSubscription stopSubscription; +class _LooperState extends State { + RangeValues? range; - @override - void initState() { - super.initState(); - store = listenToStore(recorderBottomSheetStoreToken); - range = store.loopRange; + void _onSaveLoop(RecorderBottomSheetStore store) { + if (range == null) return; + Flushbar( + message: 'Saved ${range!.start} to ${range!.end}', + duration: const Duration(seconds: 2), + ).show(context); - stopSubscription = stopAction.listen((event) { - setState(() { - range = null; - }); - }); + final current = store.currentAudioFile; + if (current != null) { + current.loopRange = range; + changeAudioFile(current); + } } @override - void dispose() { - stopSubscription.cancel(); - super.dispose(); - } - - _onSaveLoop() { - Flushbar( - //title: "Hey Ninja", - message: "Saved ${range.start} to ${range.end}", - duration: Duration(seconds: 2), - )..show(context); - - AudioFile newFile = store.currentAudioFile; - newFile.loopRange = range; - changeAudioFile(newFile); - } + Widget build(BuildContext context) { + final store = context.watch(); + if (store.state == RecorderState.stop && range != null) { + range = null; + } + if (!((store.state == RecorderState.playing || + store.state == RecorderState.pausing) && + store.currentLength != null)) { + return const SizedBox.shrink(); + } - _view() { - var defaultRange = - RangeValues(0.0, store.currentLength.inSeconds.toDouble()); + final defaultRange = RangeValues(0.0, store.currentLength!.inSeconds.toDouble()); + final lowerValue = range?.start ?? defaultRange.start; + final upperValue = range?.end ?? defaultRange.end; - var lowerValue = range == null ? defaultRange.start : range.start; - var upperValue = range == null ? defaultRange.end : range.end; return Container( color: widget.color, height: 100, - child: Column(children: [ - Row(mainAxisAlignment: MainAxisAlignment.end, children: [ - Padding( - padding: EdgeInsets.only(right: 8), - child: FlatButton( - visualDensity: VisualDensity.compact, - child: Text("Save Loop"), - onPressed: (range == null) ? null : _onSaveLoop)) - ]), - Text( - "Looper:", - ), - SizedBox(height: 20), - Expanded( + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Padding( + padding: const EdgeInsets.only(right: 8), + child: TextButton( + child: const Text('Save Loop'), + onPressed: range == null ? null : () => _onSaveLoop(store), + ), + ), + ], + ), + const Text('Looper:'), + const SizedBox(height: 20), + Expanded( child: frs.RangeSlider( - min: 0, - onChangeEnd: (double endLowerValue, double endUpperValue) { - setLoopRange(RangeValues(endLowerValue, endUpperValue)); - }, - max: (store.currentLength.inMilliseconds / 1000.0).toDouble(), - showValueIndicator: true, - lowerValue: lowerValue, - upperValue: upperValue, - onChanged: (double newLowerValue, double newUpperValue) { - setState(() { - print("change looper....."); - range = RangeValues(newLowerValue, newUpperValue); - }); - }, - )) - ]), + min: 0, + onChangeEnd: (endLowerValue, endUpperValue) { + setLoopRange(RangeValues(endLowerValue, endUpperValue)); + }, + max: (store.currentLength!.inMilliseconds / 1000.0).toDouble(), + showValueIndicator: true, + lowerValue: lowerValue, + upperValue: upperValue, + onChanged: (newLowerValue, newUpperValue) { + setState(() { + range = RangeValues(newLowerValue, newUpperValue); + }); + }, + ), + ), + ], + ), ); } - - @override - Widget build(BuildContext context) { - if ((store.state == RecorderState.PLAYING || - store.state == RecorderState.PAUSING) && - store.currentLength != null) { - return _view(); - } else { - return Container(); - } - } } - -/** -frs.RangeSlider( - key: GlobalKey(), - onChanged: (RangeValues newRange) { - print("changed to $newRange"); - setState(() => range = newRange); - }, - min: 0, - divisions: 100, - max: store.currentLength.inSeconds.toDouble(), - values: range == null ? defaultRange : range, - ) - **/ diff --git a/lib/main.dart b/lib/main.dart index 0597f64..1652767 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,11 +1,14 @@ -import 'dart:async'; - -import 'package:flutter_flux/flutter_flux.dart'; import 'package:flutter/material.dart'; -import 'package:receive_sharing_intent/receive_sharing_intent.dart'; +import 'package:provider/provider.dart'; +import 'package:sound/editor_store.dart'; import 'package:sound/local_storage.dart'; import 'package:sound/menu.dart'; import 'package:sound/model.dart'; +import 'package:sound/recorder_store.dart'; +import 'package:sound/storage.dart'; +import 'package:sound/sync_debug_store.dart'; +import 'package:sound/sync_engine.dart'; +import 'package:sound/sync_status_store.dart'; import 'settings_store.dart'; void main() { @@ -14,87 +17,92 @@ void main() { // ffe57c73 Color mainColor = Colors.red.shade300; -Color appBarColor = Colors.grey[900]; +Color appBarColor = Colors.grey.shade900; class App extends StatefulWidget { + App({super.key}); + // This widget is the root of your application. final ThemeData dark = ThemeData.dark().copyWith( - indicatorColor: mainColor, - primaryColor: mainColor, - accentColor: mainColor, - textSelectionTheme: ThemeData().textSelectionTheme.copyWith( + primaryColor: mainColor, + textSelectionTheme: ThemeData().textSelectionTheme.copyWith( selectionColor: mainColor, cursorColor: mainColor, - selectionHandleColor: mainColor), - highlightColor: Colors.black54, - cardColor: Colors.grey.shade800, - selectedRowColor: mainColor, - appBarTheme: ThemeData.dark() - .appBarTheme - .copyWith(color: appBarColor, textTheme: ThemeData.dark().textTheme), - buttonTheme: - ThemeData.dark().buttonTheme.copyWith(buttonColor: mainColor), - chipTheme: ThemeData.dark().chipTheme.copyWith(selectedColor: mainColor), - sliderTheme: ThemeData.dark().sliderTheme.copyWith( + selectionHandleColor: mainColor, + ), + highlightColor: Colors.black54, + cardColor: Colors.grey.shade800, + appBarTheme: ThemeData.dark().appBarTheme.copyWith( + backgroundColor: appBarColor, + titleTextStyle: ThemeData.dark().textTheme.titleLarge, + ), + buttonTheme: ThemeData.dark().buttonTheme.copyWith(buttonColor: mainColor), + chipTheme: ThemeData.dark().chipTheme.copyWith(selectedColor: mainColor), + sliderTheme: ThemeData.dark().sliderTheme.copyWith( trackHeight: 5, - showValueIndicator: ShowValueIndicator.always, + showValueIndicator: ShowValueIndicator.onDrag, activeTrackColor: mainColor, valueIndicatorColor: mainColor, activeTickMarkColor: mainColor, thumbColor: mainColor, - valueIndicatorTextStyle: ThemeData.dark().primaryTextTheme.bodyText1, + valueIndicatorTextStyle: ThemeData.dark().textTheme.bodyMedium, //overlayColor: mainColor - inactiveTrackColor: Colors.white), - visualDensity: VisualDensity.adaptivePlatformDensity, - floatingActionButtonTheme: - FloatingActionButtonThemeData(backgroundColor: mainColor)); + inactiveTrackColor: Colors.white, + ), + visualDensity: VisualDensity.adaptivePlatformDensity, + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: mainColor, + ), + tabBarTheme: TabBarThemeData(indicatorColor: mainColor), + ); final ThemeData light = ThemeData.light().copyWith( - primaryColor: mainColor, - textSelectionTheme: ThemeData().textSelectionTheme.copyWith( + primaryColor: mainColor, + textSelectionTheme: ThemeData().textSelectionTheme.copyWith( selectionColor: mainColor, cursorColor: mainColor, - selectionHandleColor: mainColor), - cardColor: Colors.grey.shade200, - appBarTheme: ThemeData.light().appBarTheme.copyWith( - color: appBarColor, textTheme: ThemeData.light().accentTextTheme), - chipTheme: ThemeData.light().chipTheme.copyWith(selectedColor: mainColor), - indicatorColor: mainColor, - accentColor: mainColor, - highlightColor: mainColor, - sliderTheme: ThemeData.light().sliderTheme.copyWith( + selectionHandleColor: mainColor, + ), + cardColor: Colors.grey.shade200, + appBarTheme: ThemeData.light().appBarTheme.copyWith( + backgroundColor: appBarColor, + titleTextStyle: ThemeData.light().textTheme.titleLarge, + ), + chipTheme: ThemeData.light().chipTheme.copyWith(selectedColor: mainColor), + highlightColor: mainColor, + sliderTheme: ThemeData.light().sliderTheme.copyWith( trackHeight: 4, thumbColor: mainColor, - showValueIndicator: ShowValueIndicator.always, - valueIndicatorTextStyle: ThemeData.light().primaryTextTheme.bodyText1, + showValueIndicator: ShowValueIndicator.onDrag, + valueIndicatorTextStyle: ThemeData.light().textTheme.bodyMedium, //overlayColor: mainColor, valueIndicatorColor: mainColor, activeTickMarkColor: mainColor, activeTrackColor: mainColor, // inactive loop area - inactiveTrackColor: appBarColor), - visualDensity: VisualDensity.adaptivePlatformDensity, - floatingActionButtonTheme: - FloatingActionButtonThemeData(backgroundColor: mainColor)); + inactiveTrackColor: appBarColor, + ), + visualDensity: VisualDensity.adaptivePlatformDensity, + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: mainColor, + ), + tabBarTheme: TabBarThemeData(indicatorColor: mainColor), + ); @override - State createState() { - return AppState(); - } + State createState() => AppState(); } -class AppState extends State with StoreWatcherMixin { - SettingsStore store; - +class AppState extends State { @override void initState() { super.initState(); - store = listenToStore(settingsToken); - // initialize app with loaded settings LocalStorage().getSettings().then((s) { updateSettings(s); }); + syncStatusStore.start(); + syncEngine.start(); // _intentDataStreamSubscription = ReceiveSharingIntent.getMediaStream() // .listen((List value) { @@ -118,12 +126,38 @@ class AppState extends State with StoreWatcherMixin { // }); } + @override + void dispose() { + syncEngine.stop(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return MaterialApp( - //debugShowCheckedModeBanner: false, - title: 'SketChord', - theme: store.theme == SettingsTheme.dark ? widget.dark : widget.light, - home: Menu()); + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: settingsStore), + ChangeNotifierProvider.value(value: storageStore), + ChangeNotifierProvider.value(value: noteEditorStore), + ChangeNotifierProvider.value( + value: recorderBottomSheetStore, + ), + ChangeNotifierProvider.value( + value: playerPositionStore, + ), + ChangeNotifierProvider.value( + value: recorderPositionStore, + ), + ChangeNotifierProvider.value(value: syncStatusStore), + ChangeNotifierProvider.value(value: syncDebugStore), + ], + child: Consumer( + builder: (context, store, _) => MaterialApp( + title: 'SketChord', + theme: store.theme == SettingsTheme.dark ? widget.dark : widget.light, + home: Menu(), + ), + ), + ); } } diff --git a/lib/menu.dart b/lib/menu.dart index 4cdeec9..d31af03 100644 --- a/lib/menu.dart +++ b/lib/menu.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:sound/audio_ideas.dart'; +import 'package:sound/collections_page.dart'; import 'package:sound/home.dart'; import 'package:sound/intent_receive.dart'; import 'package:sound/settings.dart'; import 'package:sound/trash.dart'; class Menu extends StatefulWidget { - Menu(); + const Menu({super.key}); @override State createState() { @@ -13,28 +15,31 @@ class Menu extends StatefulWidget { } } -enum MenuItem { HOME, SETTINGS, TRASH } +enum MenuItem { HOME, AUDIO, SETS, SETTINGS, TRASH } class MenuOption { MenuItem item; String name; IconData icon; - MenuOption({this.item, this.name, this.icon}); + MenuOption({required this.item, required this.name, required this.icon}); } class _MenuState extends State with SingleTickerProviderStateMixin { bool isCollapsed = true; final animateMenuDuration = const Duration(milliseconds: 300); - AnimationController _controller; - Animation _slideAnimation; // slide menu from left to right - Animation _scaleAnimation, + late AnimationController _controller; + late Animation _slideAnimation; // slide menu from left to right + late Animation _scaleAnimation, _menuScaleAnimation; // scale home content from 1.0 to 0.8 MenuItem current = MenuItem.HOME; var options = [ MenuOption(icon: Icons.dashboard, name: "Home", item: MenuItem.HOME), + MenuOption(icon: Icons.music_note, name: "Ideas", item: MenuItem.AUDIO), + MenuOption( + icon: Icons.list_alt_outlined, name: "Sets", item: MenuItem.SETS), MenuOption(icon: Icons.delete_sweep, name: "Trash", item: MenuItem.TRASH), MenuOption(icon: Icons.settings, name: "Settings", item: MenuItem.SETTINGS), ]; @@ -86,7 +91,7 @@ class _MenuState extends State with SingleTickerProviderStateMixin { //mainAxisAlignment: MainAxisAlignment.spaceAround, //crossAxisAlignment: CrossAxisAlignment.start, children: options - .map((e) => FlatButton.icon( + .map((e) => TextButton.icon( label: Text(e.name, style: TextStyle(fontSize: 20)), icon: Icon(e.icon), @@ -100,12 +105,14 @@ class _MenuState extends State with SingleTickerProviderStateMixin { switch (current) { case MenuItem.HOME: return Home(this._onMenuPressed); + case MenuItem.AUDIO: + return AudioIdeasPage(onMenuPressed: this._onMenuPressed); + case MenuItem.SETS: + return CollectionsPage(onMenuPressed: this._onMenuPressed); case MenuItem.SETTINGS: return Settings(this._onMenuPressed); case MenuItem.TRASH: return Trash(this._onMenuPressed); - default: - return Container(); } } @@ -131,7 +138,8 @@ class _MenuState extends State with SingleTickerProviderStateMixin { : _getView(), borderRadius: BorderRadius.all(Radius.circular(isCollapsed ? 0 : 10)), - color: Theme.of(context).appBarTheme.color, + color: Theme.of(context).appBarTheme.backgroundColor ?? + Theme.of(context).colorScheme.surface, clipBehavior: Clip.antiAlias, elevation: 5, )))); diff --git a/lib/menu_store.dart b/lib/menu_store.dart new file mode 100644 index 0000000..23291d4 --- /dev/null +++ b/lib/menu_store.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart' show IconData, Icons; +import 'package:flutter_flux/flutter_flux.dart' show Action, Store, StoreToken; + +enum MenuItem { HOME, SETTINGS, TRASH, SETS, AUDIO } + +class MenuOption { + MenuItem item; + String name; + IconData icon; + MenuOption({this.item, this.name, this.icon}); +} + +var options = [ + MenuOption(icon: Icons.dashboard, name: "Home", item: MenuItem.HOME), + MenuOption(icon: Icons.music_note, name: "Ideas", item: MenuItem.AUDIO), + MenuOption(icon: Icons.list_alt_outlined, name: "Sets", item: MenuItem.SETS), + MenuOption(icon: Icons.delete_sweep, name: "Trash", item: MenuItem.TRASH), + MenuOption(icon: Icons.settings, name: "Settings", item: MenuItem.SETTINGS), +]; + +class MenuStore extends Store { + // default values + + MenuItem _item = MenuItem.HOME; + + bool _collapsed; + + bool get collapsed => _collapsed; + MenuItem get item => _item; + + MenuStore() { + // init listener + _collapsed = true; + + toggleMenu.listen((s) { + _collapsed = !_collapsed; + trigger(); + }); + + setMenuItem.listen((item) { + _item = item; + trigger(); + }); + } +} + +Action setMenuItem = Action(); +Action toggleMenu = Action(); +StoreToken menuStoreToken = StoreToken(MenuStore()); diff --git a/lib/model.dart b/lib/model.dart index bafab72..9e3bf74 100644 --- a/lib/model.dart +++ b/lib/model.dart @@ -1,154 +1,181 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; -import 'package:flutter_audio_recorder/flutter_audio_recorder.dart'; import 'package:uuid/uuid.dart'; -import 'dart:io'; -RangeValues deserializeRangeValues(String c) { - if (c == null) return null; +const EMPTY_TEXT = 'Empty'; +RangeValues? deserializeRangeValues(String? c) { + if (c == null || c.isEmpty) return null; try { - var range = c.split(",").map((b) => double.parse(b)).toList(); + final range = c.split(',').map((b) => double.parse(b)).toList(); return RangeValues(range[0], range[1]); - } catch (e) { + } catch (_) { return null; } } -String serializeRangeValues(RangeValues v) { +String? serializeRangeValues(RangeValues? v) { if (v == null) return null; - return "${v.start},${v.end}"; + return '${v.start},${v.end}'; } class AudioFile { - String path, id, name; - DateTime createdAt, lastModified; - RangeValues loopRange; + String path; + String id; + String name; + DateTime createdAt; + DateTime lastModified; + RangeValues? loopRange; + Duration duration; + String text; + bool starred; File get file => File(path); - String get loopString => loopRange == null + String? get loopString => loopRange == null ? null - : "${(loopRange.end - loopRange.start).toStringAsFixed(1)}"; - - Duration duration; // duration is milliseconds - AudioFile( - {@required this.path, - @required this.duration, - this.id, - this.createdAt, - this.lastModified, - this.name, - this.loopRange}) { - print("creating audio file with ${this.name} ${this.id}"); - if (id == null) id = Uuid().v4().toString(); - if (createdAt == null) createdAt = DateTime.now(); - if (lastModified == null) lastModified = DateTime.now(); - if (name == null) { - name = path - .split('/') - .last - .replaceAll(".mp4", "") - .replaceAll(".m4a", "") - .replaceAll(".mp3", "") - .replaceAll('.wav', ''); - } - } - - factory AudioFile.create( - {String path, Duration duration, String id, String name}) { + : (loopRange!.end - loopRange!.start).toStringAsFixed(1); + + AudioFile({ + required this.path, + required this.duration, + String? id, + DateTime? createdAt, + DateTime? lastModified, + String? name, + String? text, + bool? starred, + this.loopRange, + }) : id = id ?? const Uuid().v4(), + createdAt = createdAt ?? DateTime.now(), + lastModified = lastModified ?? DateTime.now(), + text = text ?? '', + starred = starred ?? false, + name = + name ?? + path + .split('/') + .last + .replaceAll('.mp4', '') + .replaceAll('.m4a', '') + .replaceAll('.mp3', '') + .replaceAll('.wav', ''); + + factory AudioFile.create({ + required String path, + required Duration duration, + String? id, + String? name, + String? text, + bool? starred, + }) { return AudioFile( - createdAt: DateTime.now(), - lastModified: DateTime.now(), - path: path, - duration: duration, - id: id, - name: name); + path: path, + duration: duration, + id: id, + name: name, + text: text, + starred: starred, + createdAt: DateTime.now(), + lastModified: DateTime.now(), + ); } factory AudioFile.fromJson(Map map) { return AudioFile( - createdAt: deserializeDateTime(map["createdAt"]), - lastModified: deserializeDateTime(map["createdAt"]), - duration: deserializeDuration(map["duration"]), - loopRange: deserializeRangeValues(map['loopRange']), - id: map["id"], - name: map['name'], - path: map["path"]); + createdAt: deserializeDateTime(map['createdAt']), + lastModified: deserializeDateTime( + map['lastModified'] ?? map['createdAt'], + ), + duration: deserializeDuration(map['duration']), + loopRange: deserializeRangeValues(map['loopRange']), + id: map['id'], + name: map['name'], + path: map['path'], + text: map['text'] ?? '', + starred: (map['starred'] ?? 0) == 1 || map['starred'] == true, + ); } Map toJson() { return { - "createdAt": serializeDateTime(createdAt), - "loopRange": serializeRangeValues(loopRange), - "id": id, - "path": path, - "name": name, - "duration": serializeDuration(duration) + 'createdAt': serializeDateTime(createdAt), + 'lastModified': serializeDateTime(lastModified), + 'loopRange': serializeRangeValues(loopRange), + 'id': id, + 'path': path, + 'name': name, + 'text': text, + 'starred': starred ? 1 : 0, + 'duration': serializeDuration(duration), }; } @override int get hashCode => id.hashCode; - - bool operator ==(o) => o is AudioFile && id == o.id; + @override + bool operator ==(Object o) => o is AudioFile && id == o.id; String get durationString => - (duration.inMilliseconds / 1000).toStringAsFixed(1) + " s"; + '${(duration.inMilliseconds / 1000).toStringAsFixed(1)} s'; } class Section { - String title, content; + String title; + String content; String id; - DateTime lastModified, createdAt; + DateTime lastModified; + DateTime createdAt; - Section({ - this.title, - this.content, - this.id, - }) { - if (id == null) id = Uuid().v4().toString(); - this.lastModified = DateTime.now(); - this.createdAt = DateTime.now(); - } + Section({String? title, String? content, String? id}) + : title = title ?? '', + content = content ?? '', + id = id ?? const Uuid().v4(), + lastModified = DateTime.now(), + createdAt = DateTime.now(); factory Section.fromJson(Map map) { return Section(content: map['content'], title: map['title'], id: map['id']); } - Map toJson() { - return {"title": title, "content": content, "id": id}; - } + Map toJson() => { + 'title': title, + 'content': content, + 'id': id, + }; - bool get hasEmptyTitle => title == null || title.trim() == ""; + bool get hasEmptyTitle => title.trim().isEmpty; @override int get hashCode => id.hashCode; - - bool operator ==(o) => o is Section && id == o.id; + @override + bool operator ==(Object o) => o is Section && id == o.id; } -Duration deserializeDuration(String s) { - return Duration(microseconds: int.parse(s)); -} +Duration deserializeDuration(String s) => Duration(microseconds: int.parse(s)); +String serializeDuration(Duration d) => d.inMicroseconds.toString(); -String serializeDuration(Duration d) { - return d.inMicroseconds.toString(); -} - -DateTime deserializeDateTime(String s) { - List params = s.split("-"); - List t = params.map((i) => int.parse(i)).toList(); +DateTime deserializeDateTime(String? s) { + if (s == null || s.isEmpty) return DateTime.now(); + final params = s.split('-'); + final t = params.map((i) => int.parse(i)).toList(); return DateTime(t[0], t[1], t[2], t[3], t[4], t[5], t[6], t[7]); } -String serializeDateTime(DateTime t) { - return "${t.year}-${t.month}-${t.day}-${t.hour}-${t.minute}-${t.second}-${t.microsecond}-${t.millisecond}"; +String serializeDateTime(DateTime t) => + '${t.year}-${t.month}-${t.day}-${t.hour}-${t.minute}-${t.second}-${t.microsecond}-${t.millisecond}'; + +List? serializeColor(Color? color) { + if (color == null) return null; + return [ + (color.a * 255.0).round() & 0xff, + (color.r * 255.0).round() & 0xff, + (color.g * 255.0).round() & 0xff, + (color.b * 255.0).round() & 0xff, + ]; } -List serializeColor(Color color) { - return [color.alpha, color.red, color.green, color.blue]; -} - -Color deserializeColor(List data) { +Color? deserializeColor(List? data) { if (data == null) return null; return Color.fromARGB(data[0], data[1], data[2], data[3]); } @@ -158,184 +185,392 @@ class Note { String id; List audioFiles; String title; - String key; - String tuning; - String label; - String instrument; + String? key; + String? tuning; + String? label; + String? instrument; bool starred; - String capo; - String artist; - DateTime createdAt, lastModified; + String? capo; + String? artist; + DateTime createdAt; + DateTime lastModified; bool discarded; - Color color; - int bpm; - + Color? color; + int? bpm; + int? length; double scrollOffset; - double zoom; // text scaling factor + double zoom; factory Note.empty() { return Note( - title: "", - createdAt: DateTime.now(), - lastModified: DateTime.now(), - key: null, - tuning: null, - id: Uuid().v4(), - capo: null, - instrument: "Guitar", - label: "", - artist: null, - starred: false, - sections: [Section(content: "", title: "")], - color: null, - bpm: null, - zoom: 1.0, - scrollOffset: 1.0, - audioFiles: []); + title: '', + createdAt: DateTime.now(), + lastModified: DateTime.now(), + id: const Uuid().v4(), + instrument: 'Guitar', + label: '', + starred: false, + sections: [Section(content: '', title: '')], + zoom: 1.0, + scrollOffset: 1.0, + audioFiles: [], + ); } + Note({ + String? id, + String? title, + DateTime? createdAt, + DateTime? lastModified, + this.key, + this.tuning, + this.capo, + this.instrument, + this.label, + List
? sections, + List? audioFiles, + this.artist, + this.color, + this.bpm, + this.length, + this.zoom = 1.0, + this.scrollOffset = 1.0, + this.starred = false, + this.discarded = false, + }) : id = id ?? const Uuid().v4(), + title = title ?? '', + createdAt = createdAt ?? DateTime.now(), + lastModified = lastModified ?? DateTime.now(), + sections = sections ?? [], + audioFiles = audioFiles ?? []; + Map toJson() { return { - "id": id, - "title": title, - "createdAt": serializeDateTime(createdAt), - "lastModified": serializeDateTime(lastModified), - "key": key, - "tuning": tuning, - "capo": capo, - "instrument": instrument, - "label": label, - "artist": artist, - "starred": (starred) ? 1 : 0, - "scrollOffset": scrollOffset, - "zoom": zoom, - "bpm": bpm, - "color": color == null ? null : serializeColor(color), - "sections": - sections.map>((s) => s.toJson()).toList(), - "audioFiles": - audioFiles.map>((a) => a.toJson()).toList(), - "discarded": discarded ? 1 : 0, + 'id': id, + 'title': title, + 'createdAt': serializeDateTime(createdAt), + 'lastModified': serializeDateTime(lastModified), + 'key': key, + 'tuning': tuning, + 'capo': capo, + 'instrument': instrument, + 'label': label, + 'artist': artist, + 'starred': starred ? 1 : 0, + 'scrollOffset': scrollOffset, + 'zoom': zoom, + 'bpm': bpm, + 'length': length, + 'color': serializeColor(color), + 'sections': sections.map((s) => s.toJson()).toList(), + 'audioFiles': audioFiles.map((a) => a.toJson()).toList(), + 'discarded': discarded ? 1 : 0, }; } factory Note.fromJson(Map json, String id) { + final sectionsRaw = json['sections'] as List?; + final audioRaw = json['audioFiles'] as List?; return Note( - // general info - id: id, - title: json['title'], - createdAt: json.containsKey(json['createdAt']) - ? deserializeDateTime(json['createdAt']) - : DateTime.now(), - lastModified: json.containsKey(json['lastModified']) - ? deserializeDateTime(json['lastModified']) - : DateTime.now(), - - // additional info - key: json.containsKey("key") ? json['key'] : null, - tuning: json.containsKey("tuning") ? json['tuning'] : null, - capo: json.containsKey("capo") ? json['capo'] : null, - instrument: json.containsKey("instrument") ? json['instrument'] : null, - label: json.containsKey("label") ? json['label'] : null, - bpm: json.containsKey("bpm") ? json['bpm'] : null, - starred: json.containsKey("starred") ? json['starred'] == 1 : false, - color: - json.containsKey("color") ? deserializeColor(json['color']) : null, - discarded: - json.containsKey("discarded") ? json['discarded'] == 1 : false, - artist: json.containsKey("artist") ? json['artist'] : null, - - // viewer info - zoom: json.containsKey("zoom") ? json['zoom'] : 1.0, - scrollOffset: - json.containsKey("scrollOffset") ? json['scrollOffset'] : 1.0, - - // sections/audiofiles - sections: json.containsKey("sections") - ? json['sections'].map
((s) => Section.fromJson(s)).toList() - : [], - audioFiles: json.containsKey("audioFiles") - ? json['audioFiles'] - .map((s) => AudioFile.fromJson(s)) - .toList() - : []); + id: id, + title: json['title'], + createdAt: deserializeDateTime(json['createdAt']), + lastModified: deserializeDateTime(json['lastModified']), + key: json['key'], + tuning: json['tuning'], + capo: json['capo'], + instrument: json['instrument'], + label: json['label'], + bpm: json['bpm'], + length: json['length'] == null ? null : (json['length'] as num).toInt(), + starred: (json['starred'] ?? 0) == 1 || json['starred'] == true, + color: deserializeColor((json['color'] as List?)?.cast()), + discarded: (json['discarded'] ?? 0) == 1 || json['discarded'] == true, + artist: json['artist'], + zoom: (json['zoom'] ?? 1.0).toDouble(), + scrollOffset: (json['scrollOffset'] ?? 1.0).toDouble(), + sections: sectionsRaw == null + ? [] + : sectionsRaw.map((s) => Section.fromJson(s)).toList(), + audioFiles: audioRaw == null + ? [] + : audioRaw.map((s) => AudioFile.fromJson(s)).toList(), + ); } - String getInfoText() { - List info = []; - if (capo != null) { - info.add("Capo: $capo"); - } - if (key != null) { - info.add("Key: $key"); - } - if (tuning != null) { - info.add("Tuning: $tuning"); + + bool get hasEmptyTitle => title.trim().isEmpty; + + String get lengthStr { + if (length == null) return ''; + final totalSeconds = length!; + final hours = totalSeconds ~/ 3600; + final minutes = (totalSeconds % 3600) ~/ 60; + final seconds = totalSeconds % 60; + if (hours > 0) { + return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; } - if (info.length == 0) return null; - return info.join(" | "); + return '$minutes:${seconds.toString().padLeft(2, '0')}'; } - Note( - {this.id, - this.title, - this.createdAt, - this.lastModified, - this.key, - this.tuning, - this.capo, - this.instrument, - this.label, - this.sections, - this.audioFiles, - this.artist, - this.color, - this.bpm, - this.zoom = 1.0, - this.scrollOffset = 1.0, - this.starred = false, - this.discarded = false}) { - if (this.id == null) { - this.id = Uuid().v4(); - } + String? getInfoText() { + final info = []; + if (capo != null && capo!.isNotEmpty) info.add('Capo: $capo'); + if (key != null && key!.isNotEmpty) info.add('Key: $key'); + if (tuning != null && tuning!.isNotEmpty) info.add('Tuning: $tuning'); + if (info.isEmpty) return null; + return info.join(' | '); } } +class NoteCollection { + String id; + List notes; + String title; + String description; + bool starred; + DateTime createdAt; + DateTime lastModified; + + NoteCollection({ + String? id, + List? notes, + String? title, + String? description, + bool? starred, + DateTime? createdAt, + DateTime? lastModified, + }) : id = id ?? const Uuid().v4(), + notes = notes ?? [], + title = title ?? '', + description = description ?? '', + starred = starred ?? false, + createdAt = createdAt ?? DateTime.now(), + lastModified = lastModified ?? DateTime.now(); + + factory NoteCollection.empty() => NoteCollection(); + + factory NoteCollection.fromJson(Map json) { + final rawNotes = json['notes'] as List?; + return NoteCollection( + id: json['id'], + notes: rawNotes == null + ? [] + : rawNotes + .map( + (n) => Note.fromJson( + Map.from(n), + (n['id'] ?? '') as String, + ), + ) + .toList(), + title: json['title'] ?? '', + description: json['description'] ?? '', + starred: (json['starred'] ?? 0) == 1 || json['starred'] == true, + createdAt: deserializeDateTime(json['createdAt']), + lastModified: deserializeDateTime(json['lastModified']), + ); + } + + Map toJson() { + return { + 'id': id, + 'notes': notes.map((e) => e.toJson()).toList(), + 'title': title, + 'description': description, + 'starred': starred ? 1 : 0, + 'createdAt': serializeDateTime(createdAt), + 'lastModified': serializeDateTime(lastModified), + }; + } + + List get activeNotes => + notes.where((element) => !element.discarded).toList(); + + int get length => notes.fold(0, (p, e) => p + (e.length ?? 0)); + + String get lengthStr { + if (length == 0) return ''; + final hours = length ~/ 3600; + final minutes = (length % 3600) ~/ 60; + final seconds = length % 60; + return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } + + bool get empty => + title.trim().isEmpty && description.trim().isEmpty && notes.isEmpty; +} + +enum SyncEntityType { note, audioIdea, audioFile, collection, settings } + +enum SyncOperationType { upsert, delete, tombstone } + +enum SyncQueueStatus { queued, syncing, rejected, synced } + +SyncEntityType deserializeSyncEntityType(String raw) { + return SyncEntityType.values.firstWhere( + (e) => e.name == raw, + orElse: () => SyncEntityType.note, + ); +} + +SyncOperationType deserializeSyncOperationType(String raw) { + return SyncOperationType.values.firstWhere( + (e) => e.name == raw, + orElse: () => SyncOperationType.upsert, + ); +} + +SyncQueueStatus deserializeSyncQueueStatus(String raw) { + return SyncQueueStatus.values.firstWhere( + (e) => e.name == raw, + orElse: () => SyncQueueStatus.queued, + ); +} + +class SyncQueueItem { + String id; + SyncEntityType entityType; + String entityId; + SyncOperationType operation; + String payload; + int baseVersion; + DateTime createdAt; + SyncQueueStatus status; + int retryCount; + String? lastError; + + SyncQueueItem({ + required this.id, + required this.entityType, + required this.entityId, + required this.operation, + required this.payload, + required this.baseVersion, + required this.createdAt, + required this.status, + required this.retryCount, + this.lastError, + }); + + factory SyncQueueItem.fromJson(Map json) { + return SyncQueueItem( + id: json['id'] as String, + entityType: deserializeSyncEntityType( + (json['entityType'] ?? '').toString(), + ), + entityId: (json['entityId'] ?? '') as String, + operation: deserializeSyncOperationType( + (json['operation'] ?? '').toString(), + ), + payload: (json['payload'] ?? '{}') as String, + baseVersion: (json['baseVersion'] as num?)?.toInt() ?? 0, + createdAt: deserializeDateTime(json['createdAt'] as String?), + status: deserializeSyncQueueStatus((json['status'] ?? '').toString()), + retryCount: (json['retryCount'] as num?)?.toInt() ?? 0, + lastError: json['lastError'] as String?, + ); + } +} + +class SyncConflict { + String id; + SyncEntityType entityType; + String entityId; + SyncOperationType operation; + String reason; + String localPayload; + String? remotePayload; + DateTime createdAt; + DateTime? resolvedAt; + + SyncConflict({ + required this.id, + required this.entityType, + required this.entityId, + required this.operation, + required this.reason, + required this.localPayload, + required this.remotePayload, + required this.createdAt, + this.resolvedAt, + }); + + bool get isResolved => resolvedAt != null; + + factory SyncConflict.fromJson(Map json) { + final resolvedAt = json['resolvedAt'] as String?; + return SyncConflict( + id: json['id'] as String, + entityType: deserializeSyncEntityType( + (json['entityType'] ?? '').toString(), + ), + entityId: (json['entityId'] ?? '') as String, + operation: deserializeSyncOperationType( + (json['operation'] ?? '').toString(), + ), + reason: (json['reason'] ?? 'Conflict rejected by backend') as String, + localPayload: (json['localPayload'] ?? '{}') as String, + remotePayload: json['remotePayload'] as String?, + createdAt: deserializeDateTime(json['createdAt'] as String?), + resolvedAt: (resolvedAt == null || resolvedAt.isEmpty) + ? null + : deserializeDateTime(resolvedAt), + ); + } +} + +class SyncStatusSummary { + final int queuedChanges; + final int unresolvedConflicts; + + const SyncStatusSummary({ + required this.queuedChanges, + required this.unresolvedConflicts, + }); + + bool get isFullySynced => queuedChanges == 0 && unresolvedConflicts == 0; +} + enum SettingsTheme { dark, light } + enum EditorView { single, double } +enum AudioFormat { aac, wav } + class Settings { SettingsTheme theme; - EditorView view; // single, double - AudioFormat audioFormat; // aac, wav - String name; + EditorView view; + AudioFormat audioFormat; + String? name; bool isInitialStart; - Settings( - {this.theme, - this.view, - this.audioFormat, - this.name, - this.isInitialStart}); + Settings({ + required this.theme, + required this.view, + required this.audioFormat, + this.name, + this.isInitialStart = false, + }); Map toJson() { return { - "theme": theme == SettingsTheme.dark ? "dark" : "light", - "view": view == EditorView.single ? "single" : "double", - "audioFormat": audioFormat == AudioFormat.AAC ? "aac" : "wav", - "name": name, - "isInitialStart": isInitialStart + 'theme': theme == SettingsTheme.dark ? 'dark' : 'light', + 'view': view == EditorView.single ? 'single' : 'double', + 'audioFormat': audioFormat == AudioFormat.aac ? 'aac' : 'wav', + 'name': name, + 'isInitialStart': isInitialStart, }; } factory Settings.fromJson(Map json) { return Settings( - theme: - json['theme'] == 'dark' ? SettingsTheme.dark : SettingsTheme.light, - view: json['view'] == "single" ? EditorView.single : EditorView.double, - name: json.containsKey("name") ? json['name'] : null, - isInitialStart: - json.containsKey("isInitialStart") ? json['isInitialStart'] : false, - audioFormat: - json["audioFormat"] == "aac" ? AudioFormat.AAC : AudioFormat.WAV); + theme: json['theme'] == 'dark' ? SettingsTheme.dark : SettingsTheme.light, + view: json['view'] == 'single' ? EditorView.single : EditorView.double, + name: json['name'], + isInitialStart: json['isInitialStart'] ?? false, + audioFormat: json['audioFormat'] == 'aac' + ? AudioFormat.aac + : AudioFormat.wav, + ); } } diff --git a/lib/note_editor.dart b/lib/note_editor.dart index 2dc1639..76aeba4 100644 --- a/lib/note_editor.dart +++ b/lib/note_editor.dart @@ -1,145 +1,120 @@ -import 'dart:io'; +import 'dart:async'; -import 'package:audioplayers/audio_cache.dart'; -import 'package:audioplayers/audioplayers.dart'; -import 'package:clipboard_manager/clipboard_manager.dart'; -import 'package:flushbar/flushbar.dart'; +import 'package:another_flushbar/flushbar.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_flux/flutter_flux.dart' show StoreWatcherMixin; -import 'package:flutter_share/flutter_share.dart'; -import 'package:sound/dialogs/color_picker_dialog.dart'; -import 'package:sound/dialogs/import_dialog.dart'; -import 'package:sound/editor_views/additional_info.dart'; -import 'package:sound/editor_views/audio.dart'; -import 'package:sound/editor_views/section.dart'; -import 'package:sound/dialogs/export_dialog.dart'; -import 'package:sound/export.dart'; -import 'package:sound/file_manager.dart'; -import 'package:sound/local_storage.dart'; -import 'package:sound/note_viewer.dart'; -import 'package:sound/share.dart'; -import 'editor_store.dart'; -import 'model.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; -//import 'recorder.dart'; -//import 'file_manager.dart'; -// import 'recorder_bottom_sheet_store2.dart'; -//import 'package:progress_indicators/progress_indicators.dart'; -import "recorder_bottom_sheet.dart"; -import "recorder_store.dart"; +import 'dialogs/color_picker_dialog.dart'; +import 'dialogs/export_dialog.dart'; +import 'dialogs/import_dialog.dart'; +import 'collections_page.dart'; +import 'editor_store.dart'; +import 'editor_views/additional_info.dart'; +import 'editor_views/audio.dart'; +import 'editor_views/section.dart'; +import 'export.dart'; +import 'file_manager.dart'; +import 'local_storage.dart'; +import 'model.dart'; +import 'note_viewer.dart'; +import 'recorder_bottom_sheet.dart'; +import 'recorder_store.dart'; +import 'share.dart'; import 'utils.dart'; class NoteEditor extends StatefulWidget { final Note note; - - NoteEditor(this.note); + const NoteEditor(this.note, {super.key}); @override - State createState() { - return NoteEditorState(); - } + State createState() => NoteEditorState(); } -class NoteEditorState extends State - with StoreWatcherMixin { - RecorderBottomSheetStore recorderStore; - NoteEditorStore store; - GlobalKey _globalKey = GlobalKey(); - List popupMenuActions = ["share", "copy"]; - - Map dismissables = {}; +class NoteEditorState extends State { + final GlobalKey _globalKey = GlobalKey(); + final List popupMenuActions = ['share', 'copy', 'add_to_set']; + final Map popupMenuActionTitles = const { + 'share': 'Share', + 'copy': 'Copy to Clipboard', + 'add_to_set': 'Add to Set', + }; + final Map dismissables = {}; + StreamSubscription? _recordingSub; @override void initState() { super.initState(); - recorderStore = listenToStore(recorderBottomSheetStoreToken); - store = listenToStore(noteEditorStoreToken); - store.setNote(widget.note); - print("INIT EDITOR"); - - recordingFinished.clearListeners(); - recordingFinished.listen((f) async { - print("recording finished ${f.path} with duration ${f.duration}"); - - final player = AudioPlayer(); - await player.setUrl(f.path); - - return Future.delayed( - const Duration(milliseconds: 200), - () async { - f.duration = Duration(milliseconds: await player.getDuration()); - addAudioFile(f); - }, - ); + noteEditorStore.setNote(widget.note); + _recordingSub = recorderBottomSheetStore.onRecordingFinished.listen((f) async { + await addAudioFile(f); }); } @override void dispose() { - recordingFinished.clearListeners(); - //store.dispose(); - //recorderStore.dispose(); + _recordingSub?.cancel(); super.dispose(); } - _onFloatingActionButtonPress() { - if (recorderStore.state == RecorderState.RECORDING) { - stopAction(); + Future _onFloatingActionButtonPress() async { + if (recorderBottomSheetStore.state == RecorderState.recording) { + await stopAction(); } else { - startRecordingAction(); + await startRecordingAction(); } } - _onAudioFileDelete(AudioFile file, int index) { - Flushbar bar; - - bar = Flushbar( - //title: "Hey Ninja", - message: "${file.name} was deleted", + void _onAudioFileDelete(NoteEditorStore store, AudioFile file, int index) { + late Flushbar bar; + bar = Flushbar( + message: '${file.name} was deleted', onStatusChanged: (status) { - // lets check whether the file was restored or not if (status == FlushbarStatus.DISMISSED && - !store.note.audioFiles.contains(file)) { + !store.note!.audioFiles.contains(file)) { hardDeleteAudioFile(file); } }, - mainButton: FlatButton( - child: Text("Undo"), - onPressed: () { - if (!store.note.audioFiles.contains(file)) { - restoreAudioFile(Tuple2(file, index)); - } - bar.dismiss(); - }), - duration: Duration(seconds: 3), + mainButton: TextButton( + child: const Text('Undo'), + onPressed: () { + if (!store.note!.audioFiles.contains(file)) { + restoreAudioFile(Tuple2(file, index)); + } + bar.dismiss(); + }, + ), + duration: const Duration(seconds: 3), ); bar.show(context); - softDeleteAudioFile(file); } - _copyToClipboard(BuildContext context) { - String text = Exporter.getText(store.note); - - ClipboardManager.copyToClipBoard(text).then((result) { - final snackBar = SnackBar( - content: Text('Copied to Clipboard'), - ); - _globalKey.currentState.showSnackBar(snackBar); - }); + Future _copyToClipboard(NoteEditorStore store) async { + final text = Exporter.getText(store.note!); + await Clipboard.setData(ClipboardData(text: text)); + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + const SnackBar(content: Text('Copied to Clipboard')), + ); } - _runPopupAction(String action) { + void _runPopupAction(NoteEditorStore store, String action) { switch (action) { - case "share": - showExportDialog(context, store.note); + case 'share': + showExportDialog(context, store.note!); break; - case "star": + case 'star': toggleStarred(); break; - case "copy": - _copyToClipboard(context); + case 'copy': + _copyToClipboard(store); + break; + case 'add_to_set': + if (store.note != null) { + showAddNoteToSetDialog(context, store.note!); + } break; default: break; @@ -148,190 +123,172 @@ class NoteEditorState extends State @override Widget build(BuildContext context) { - List items = []; - - items.add(NoteEditorTitle( - title: store.note.title, - onChange: changeTitle, - allowEdit: true, - )); + final store = context.watch(); + final recorderStore = context.watch(); + final note = store.note; + if (note == null) { + return const SizedBox.shrink(); + } - // sections - for (var i = 0; i < store.note.sections.length; i++) { - if (!dismissables.containsKey(store.note.sections[i])) - dismissables[store.note.sections[i]] = GlobalKey(); + final items = [ + NoteEditorTitle( + title: note.title, + onChange: changeTitle, + allowEdit: true, + ), + ]; - bool showMoveUp = (i != 0); - bool showMoveDown = (i != (store.note.sections.length - 1)); + for (var i = 0; i < note.sections.length; i++) { + final section = note.sections[i]; + dismissables.putIfAbsent(section, () => GlobalKey()); items.add(SectionListItem( - globalKey: dismissables[store.note.sections[i]], - section: store.note.sections[i], - moveDown: showMoveDown, - moveUp: showMoveUp)); + globalKey: dismissables[section]!, + section: section, + moveDown: i != (note.sections.length - 1), + moveUp: i != 0, + )); } - // add section item - items.add(AddSectionItem()); - // all additional info - items.add(NoteEditorAdditionalInfo(store.note)); + items.add(const AddSectionItem()); + items.add(NoteEditorAdditionalInfo(note)); - // audio files as stack - if (store.note.audioFiles.length > 0) + if (note.audioFiles.isNotEmpty) { items.add(Padding( - padding: EdgeInsets.symmetric(vertical: 20), - child: Text( - 'Audio Files', - style: Theme.of(context).textTheme.subtitle1, - ))); + padding: const EdgeInsets.symmetric(vertical: 20), + child: Text('Audio Files', style: Theme.of(context).textTheme.titleMedium), + )); + } - store.note.audioFiles.asMap().forEach((int index, AudioFile f) { + note.audioFiles.asMap().forEach((index, f) { items.add(AudioFileView( - file: f, - index: index, - onDelete: () => _onAudioFileDelete(f, index), - onMove: () { - showImportDialog(context, "Copy audio to", () async { - // new audio file - - AudioFile copy = await FileManager().copyToNew(f); - Future.delayed(Duration(milliseconds: 200), () { - showSnack(_globalKey.currentState, - "The audio file as copiedr to a new note"); + file: f, + index: index, + onDelete: () => _onAudioFileDelete(store, f, index), + onMove: () { + showImportDialog( + context, + 'Copy audio to', + () async { + final copy = await FileManager().copyToNew(f); + Future.delayed(const Duration(milliseconds: 200), () { + showSnack(_globalKey.currentState, 'The audio file was copied to a new note'); }); - Note note = Note.empty(); - note.audioFiles.add(copy); - - // manual sync - LocalStorage().syncNote(note); - return note; - }, (Note note) async { - AudioFile copy = await FileManager().copyToNew(f); - - Future.delayed(Duration(milliseconds: 200), () { - showSnack(_globalKey.currentState, - "The audio file as copied to a ${note.title}"); + final newNote = Note.empty(); + newNote.audioFiles.add(copy); + await LocalStorage().syncNote(newNote); + return newNote; + }, + (Note targetNote) async { + final copy = await FileManager().copyToNew(f); + Future.delayed(const Duration(milliseconds: 200), () { + showSnack(_globalKey.currentState, 'The audio file was copied to ${targetNote.title}'); }); - - if (note.id == widget.note.id) { - copy.name += " - copy"; - addAudioFile(copy); + if (targetNote.id == widget.note.id) { + copy.name += ' - copy'; + await addAudioFile(copy); } else { - // manual sync - note.audioFiles.add(copy); - LocalStorage().syncNote(note); + targetNote.audioFiles.add(copy); + await LocalStorage().syncNote(targetNote); } - - return note; + return targetNote; }, - openNote: false, - syncNote: - false, // do not sync note, because otherwise this component gets updated twice - importButtonText: "Copy", - newButtonText: "Copy as NEW"); - }, - onShare: () => shareFile(f.path), - globalKey: _globalKey)); + openNote: false, + syncNote: false, + importButtonText: 'Copy', + newButtonText: 'Copy as NEW', + ); + }, + onShare: () => shareFile(f.path), + globalKey: _globalKey, + )); }); - List stackChildren = []; - - stackChildren.add(Container( - padding: EdgeInsets.all(16), - child: ListView.builder( - itemBuilder: (context, index) => items[index], - itemCount: items.length, - ))); + final showSheet = recorderStore.state == RecorderState.pausing || + recorderStore.state == RecorderState.playing || + recorderStore.state == RecorderState.recording; - // bottom sheets - bool showSheet = recorderStore.state == RecorderState.PAUSING || - recorderStore.state == RecorderState.PLAYING || - recorderStore.state == RecorderState.RECORDING; - - Icon icon = Icon( - ((recorderStore.state == RecorderState.RECORDING)) - ? Icons.mic_none - : Icons.mic, - color: recorderStore.state == RecorderState.RECORDING - ? Theme.of(context).accentColor - : null); - PopupMenuButton popup = PopupMenuButton( - onSelected: _runPopupAction, - itemBuilder: (context) { - return popupMenuActions.map>((String action) { - return PopupMenuItem(value: action, child: Text(action)); - }).toList(); - }, + final icon = Icon( + recorderStore.state == RecorderState.recording ? Icons.mic_none : Icons.mic, + color: recorderStore.state == RecorderState.recording + ? Theme.of(context).colorScheme.secondary + : null, ); - // actions - List actions = [ - // IconButton( - // icon: Icon(Icons.share), - // onPressed: () => showExportDialog(context, store.note)), + final actions = [ IconButton( - icon: Icon((store.note.starred) ? Icons.star : Icons.star_border), - onPressed: toggleStarred), + icon: Icon(note.starred ? Icons.star : Icons.star_border), + onPressed: () => toggleStarred(), + ), IconButton(icon: icon, onPressed: _onFloatingActionButtonPress), - Stack(alignment: Alignment.center, children: [ - IconButton( - icon: Icon(Icons.color_lens), - onPressed: () => - showColorPickerDialog(context, store.note.color, changeColor)), - Positioned( + Stack( + alignment: Alignment.center, + children: [ + IconButton( + icon: const Icon(Icons.color_lens), + onPressed: () => showColorPickerDialog(context, note.color, changeColor), + ), + Positioned( bottom: 17, right: 14, child: Container( - decoration: BoxDecoration( - color: store.note.color, - borderRadius: BorderRadius.circular(10)), - height: 10, - width: 10)), - ]), + decoration: BoxDecoration( + color: note.color, + borderRadius: BorderRadius.circular(10), + ), + height: 10, + width: 10, + ), + ), + ], + ), IconButton( - icon: Icon(Icons.play_circle_filled), - onPressed: () { - Navigator.push( - context, - new MaterialPageRoute( - builder: (context) => NoteViewer(store.note, - showAdditionalInformation: false, - showAudioFiles: false, - showSheet: true, - showTitle: false))); - }), - // IconButton( - // icon: Icon(Icons.content_copy), - // onPressed: () => _copyToClipboard(context)) - popup, + icon: const Icon(Icons.play_circle_filled), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => NoteViewer( + note, + showAdditionalInformation: false, + showAudioFiles: false, + showSheet: true, + showTitle: false, + ), + ), + ); + }, + ), + PopupMenuButton( + onSelected: (action) => _runPopupAction(store, action), + itemBuilder: (context) { + return popupMenuActions + .map((action) => PopupMenuItem( + value: action, + child: Text(popupMenuActionTitles[action] ?? action), + )) + .toList(); + }, + ), ]; - // will pop score - return WillPopScope( - onWillPop: () async { + return PopScope( + onPopInvokedWithResult: (didPop, result) { + if (didPop) { stopAction(); - return true; - }, - child: Scaffold( - key: _globalKey, - appBar: AppBar( - //backgroundColor: store.note.color, - actions: actions, - ), - bottomSheet: - showSheet ? RecorderBottomSheet(key: Key("bottomSheet")) : null, - body: Container(child: Stack(children: stackChildren)))); + } + }, + child: Scaffold( + key: _globalKey, + appBar: AppBar(actions: actions), + bottomSheet: showSheet ? const RecorderBottomSheet(key: Key('bottomSheet')) : null, + body: Padding( + padding: const EdgeInsets.all(16), + child: ListView.builder( + itemBuilder: (context, index) => items[index], + itemCount: items.length, + ), + ), + ), + ); } } - -/* - if (store.loading) { - stackChildren.add(BackdropFilter( - filter: new ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), - child: new Container( - child: Center(child: CircularProgressIndicator()), - decoration: new BoxDecoration( - color: - Theme.of(context).scaffoldBackgroundColor.withOpacity(0.5)), - ))); - } -*/ diff --git a/lib/note_item.dart b/lib/note_item.dart index f4325eb..3cef7ac 100644 --- a/lib/note_item.dart +++ b/lib/note_item.dart @@ -1,111 +1,103 @@ -import 'dart:ui'; - -import 'package:flushbar/flushbar.dart'; import 'package:flutter/material.dart'; -import 'package:sound/model.dart'; -import 'package:sound/utils.dart'; + +import 'model.dart'; +import 'utils.dart'; class AbstractNoteItem extends StatelessWidget { final Note note; final bool isSelected; - final Function onTap, onLongPress; - final String highlight; + final VoidCallback onTap; + final VoidCallback onLongPress; + final String? highlight; - AbstractNoteItem( - {this.note, - this.isSelected, - this.onTap, - this.onLongPress, - this.highlight}); + const AbstractNoteItem({ + required this.note, + required this.isSelected, + required this.onTap, + required this.onLongPress, + this.highlight, + super.key, + }); - bool get empty => ((note.title == null || note.title.trim() == "") && - this.sectionText().trim() == ""); + bool get empty => (note.title.trim().isEmpty && sectionText().trim().isEmpty); + + String sectionText() => note.sections.map((s) => s.content).join('\n'); Widget singleText(BuildContext context, String text) { return Padding( - padding: EdgeInsets.all(8.0), - child: Text(text, - style: Theme.of(context) - .textTheme - .headline5 - .copyWith(fontWeight: FontWeight.w200))); + padding: const EdgeInsets.all(8.0), + child: Text( + text, + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(fontWeight: FontWeight.w200), + ), + ); } - Widget highlightTitle(BuildContext context, String title, String highlight) { - List spans = []; - print("$highlight $title"); - if (highlight == null) { - spans.add(TextSpan(text: title)); - } else { - int start = title.toLowerCase().indexOf(highlight); - if (start == -1) { - spans.add(TextSpan(text: title)); - } else { - spans.add(TextSpan(text: title.substring(0, start))); - spans.add(TextSpan( - text: title.substring(start, start + highlight.length), - style: - TextStyle(backgroundColor: Theme.of(context).highlightColor))); - spans.add(TextSpan(text: title.substring(start + highlight.length))); - } + Widget highlightTitle(BuildContext context, String title, String? highlight) { + if (highlight == null || highlight.isEmpty) { + return Text(title, style: Theme.of(context).textTheme.titleLarge); + } + final lowerTitle = title.toLowerCase(); + final lowerHighlight = highlight.toLowerCase(); + final start = lowerTitle.indexOf(lowerHighlight); + if (start == -1) { + return Text(title, style: Theme.of(context).textTheme.titleLarge); } - return Text.rich( TextSpan( - children: spans, + children: [ + TextSpan(text: title.substring(0, start)), + TextSpan( + text: title.substring(start, start + highlight.length), + style: TextStyle(backgroundColor: Theme.of(context).highlightColor), + ), + TextSpan(text: title.substring(start + highlight.length)), + ], ), - //softWrap: true, - //overflow: TextOverflow.clip, - //maxLines: 1, - style: Theme.of(context).textTheme.headline6, - textScaleFactor: 0.75, + style: Theme.of(context).textTheme.titleLarge, textAlign: TextAlign.left, ); } Widget highlightSectionText( - BuildContext context, String text, String highlight, - {int maxLines = 9}) { - List spans = []; - - if (highlight == null) { - spans.add(TextSpan(text: text)); - } else { - List sections = text.split("\n"); - int start = text.indexOf(highlight); - - if (start == -1) - spans.add(TextSpan(text: text)); - else { - int k = 0; - int inSection = 0; - for (var i = 0; i < sections.length; i++) { - if (start >= k && start <= (k + sections[i].length)) { - inSection = i; - break; - } - k += sections[i].length + 1; - } - - print("section: ${sections[inSection]}, text: $highlight"); - // start at the start of the found section - int sectionStart = text.indexOf(sections[inSection]); - print("$sectionStart, $start, ${text.length}"); - int end = start + highlight.length; - - spans.add(TextSpan(text: text.substring(sectionStart, start))); - spans.add(TextSpan( - text: text.substring(start, end), - style: - TextStyle(backgroundColor: Theme.of(context).highlightColor))); - spans.add(TextSpan(text: text.substring(end))); - } + BuildContext context, + String text, + String? highlight, { + int maxLines = 9, + }) { + if (highlight == null || highlight.isEmpty) { + return Text( + text, + softWrap: true, + overflow: TextOverflow.clip, + maxLines: maxLines, + textAlign: TextAlign.left, + ); } - + final start = text.toLowerCase().indexOf(highlight.toLowerCase()); + if (start == -1) { + return Text( + text, + softWrap: true, + overflow: TextOverflow.clip, + maxLines: maxLines, + textAlign: TextAlign.left, + ); + } + final end = start + highlight.length; return Text.rich( TextSpan( - //text: 'TEST', - children: spans, + children: [ + TextSpan(text: text.substring(0, start)), + TextSpan( + text: text.substring(start, end), + style: TextStyle(backgroundColor: Theme.of(context).highlightColor), + ), + TextSpan(text: text.substring(end)), + ], ), softWrap: true, overflow: TextOverflow.clip, @@ -114,169 +106,145 @@ class AbstractNoteItem extends StatelessWidget { ); } - Widget emptyText(BuildContext context) { - return singleText(context, "Empty"); - } - - Widget onlyTitle(BuildContext context) { - return singleText(context, note.title); - } - - bool get hasOnlyTitle => - (note.title != null && note.title != "") && - this.sectionText().trim() == ""; - - String sectionText() { - String text = ""; - for (Section section in note.sections) { - text += section.content + '\n'; - } - return text; - } + Widget emptyText(BuildContext context) => singleText(context, 'Empty'); + Widget onlyTitle(BuildContext context) => singleText(context, note.title); + bool get hasOnlyTitle => note.title.isNotEmpty && sectionText().trim().isEmpty; @override - Widget build(BuildContext context) { - return null; - } + Widget build(BuildContext context) => const SizedBox.shrink(); } class SmallNoteItem extends AbstractNoteItem { final double width; final EdgeInsets padding; - SmallNoteItem(Note note, bool isSelected, Function onTap, - Function onLongPress, String highlight, this.width, this.padding) - : super( - note: note, - isSelected: isSelected, - onTap: onTap, - onLongPress: onLongPress, - highlight: highlight); + SmallNoteItem( + Note note, + bool isSelected, + VoidCallback onTap, + VoidCallback onLongPress, + String? highlight, + this.width, + this.padding, { + super.key, + }) : super( + note: note, + isSelected: isSelected, + onTap: onTap, + onLongPress: onLongPress, + highlight: highlight, + ); @override Widget build(BuildContext context) { - Widget child = Card( - color: note.color, - child: Container( - decoration: isSelected ? getSelectedDecoration(context) : null, - child: empty - ? emptyText(context) - : hasOnlyTitle - ? onlyTitle(context) - : Padding( - padding: EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.only(bottom: 10), - child: highlightTitle( - context, note.title, highlight)), - highlightSectionText( - context, this.sectionText(), highlight), - ])))); - List stackChildren = []; - stackChildren.add(child); - + final child = Card( + color: note.color, + child: Container( + decoration: isSelected ? getSelectedDecoration(context) : null, + child: empty + ? emptyText(context) + : hasOnlyTitle + ? onlyTitle(context) + : Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: highlightTitle(context, note.title, highlight), + ), + highlightSectionText(context, sectionText(), highlight), + ], + ), + ), + ), + ); return GestureDetector( - onTap: onTap, - onLongPress: onLongPress, - child: Container( - width: this.width, - height: (empty) ? 50 : null, - padding: this.padding, - child: Stack(children: stackChildren))); + onTap: onTap, + onLongPress: onLongPress, + child: Container( + width: width, + height: empty ? 50 : null, + padding: padding, + child: child, + ), + ); } } class NoteItem extends AbstractNoteItem { final double padding; - NoteItem(Note note, bool isSelected, Function onTap, Function onLongPress, - String highlight, {this.padding = 8}) - : super( - note: note, - isSelected: isSelected, - onTap: onTap, - onLongPress: onLongPress, - highlight: highlight); - - _top() { - return Padding( - padding: EdgeInsets.only(left: padding, right: padding, top: padding), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(note.key == null ? 'No Key' : note.key), - Text(note.capo == null ? "No Capo" : "Capo ${note.capo}") - ], - )); - } - - _title(context) { - return Padding( - child: Row(children: [ - highlightTitle(context, note.title, highlight) - // Text( - // note.title, - // textScaleFactor: .8, - // style: Theme.of(context).textTheme.headline6, - // ) - ]), - padding: EdgeInsets.all(padding)); - } - - _text(context) { - return Padding( - padding: EdgeInsets.all(padding), - child: highlightSectionText(context, this.sectionText(), highlight, - maxLines: 6) - // Text( - // this.sectionText(), - // textAlign: TextAlign.left, - // softWrap: true, - // maxLines: 5, - // overflow: TextOverflow.clip, - // ) + NoteItem( + Note note, + bool isSelected, + VoidCallback onTap, + VoidCallback onLongPress, + String? highlight, { + this.padding = 8, + super.key, + }) : super( + note: note, + isSelected: isSelected, + onTap: onTap, + onLongPress: onLongPress, + highlight: highlight, ); - } - - _bottom() { - return Padding( - padding: EdgeInsets.all(padding), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text("${note.sections.length} Sections"), - Text((note.tuning == null) ? "Standard" : "${note.tuning}") - ], - )); - } @override Widget build(BuildContext context) { return GestureDetector( - onTap: onTap, - onLongPress: onLongPress, + onTap: onTap, + onLongPress: onLongPress, + child: Card( + color: note.color, child: Container( - child: Card( - color: note.color, - child: Container( - decoration: - isSelected ? getSelectedDecoration(context) : null, - child: (empty) - ? emptyText(context) - : hasOnlyTitle - ? onlyTitle(context) - : Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _top(), - _title(context), - _text(context), - _bottom(), - ]))))); + decoration: isSelected ? getSelectedDecoration(context) : null, + child: empty + ? emptyText(context) + : hasOnlyTitle + ? onlyTitle(context) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.all(padding), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(note.key ?? 'No Key'), + Text(note.capo == null ? 'No Capo' : 'Capo ${note.capo}'), + ], + ), + ), + Padding( + padding: EdgeInsets.all(padding), + child: highlightTitle(context, note.title, highlight), + ), + Padding( + padding: EdgeInsets.all(padding), + child: highlightSectionText( + context, + sectionText(), + highlight, + maxLines: 6, + ), + ), + Padding( + padding: EdgeInsets.all(padding), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('${note.sections.length} Sections'), + Text(note.tuning ?? 'Standard'), + ], + ), + ), + ], + ), + ), + ), + ); } } diff --git a/lib/note_list.dart b/lib/note_list.dart index 91b2716..854170f 100644 --- a/lib/note_list.dart +++ b/lib/note_list.dart @@ -1,14 +1,13 @@ import 'package:sound/note_item.dart'; import 'model.dart'; import 'package:flutter/material.dart'; -import 'storage.dart'; class NoteListItemModel { final Note note; final bool isSelected; - final String highlight; // a test to highlight + final String? highlight; // a test to highlight - NoteListItemModel({this.note, this.isSelected, this.highlight}); + NoteListItemModel({required this.note, required this.isSelected, this.highlight}); } class NoteList extends StatefulWidget { @@ -16,7 +15,7 @@ class NoteList extends StatefulWidget { final bool singleView; final ValueChanged onTap; final ValueChanged onLongPress; - final String highlight; + final String? highlight; final List items; NoteList( @@ -36,7 +35,7 @@ class NoteListState extends State { } List processList(List data, bool even) { - List returns = new List(); + List returns = []; for (int i = 0; i < data.length; i++) { if (even && i % 2 == 0) returns.add(data[i]); @@ -95,8 +94,6 @@ class NoteListState extends State { ])) ])); } else { - print("index: $index"); - print(widget.items); var item = widget.items[index]; return Padding( diff --git a/lib/note_search_view.dart b/lib/note_search_view.dart new file mode 100644 index 0000000..1e04e6d --- /dev/null +++ b/lib/note_search_view.dart @@ -0,0 +1,286 @@ +import 'package:flutter/material.dart'; +import 'package:sound/dialogs/color_picker_dialog.dart'; +import 'package:sound/dialogs/initial_import_dialog.dart'; +import 'package:sound/note_viewer.dart'; +import 'package:sound/note_views/appbar.dart'; +import 'package:sound/note_views/seach.dart'; +import 'package:tuple/tuple.dart'; +import 'local_storage.dart'; +import 'file_manager.dart'; +import 'note_list.dart'; +import 'storage.dart'; +import 'package:flutter_flux/flutter_flux.dart'; +import 'dart:ui'; +import 'note_editor.dart'; +import 'model.dart'; +//import 'recorder.dart'; +import 'db.dart'; + +// search for notes +// have a bottom bar that has a list of all notes to add (as well remove notes from this list of notes to add) +// bottom bar can have round corners +// TODO: Back button always present, but with a simple dialog box asking wheather you actually want to leave or not +// maybe round the dialog boxes at the corners + +class NoteSearchViewLoader extends StatelessWidget { + final NoteCollection collection; + final ValueChanged> onAddNotes; + final bool single; + + const NoteSearchViewLoader( + {this.collection, this.onAddNotes, this.single = false, Key key}) + : super(key: key); + + @override + Widget build(BuildContext context) { + var builder = FutureBuilder( + builder: (context, AsyncSnapshot> snap) { + if (!snap.hasData) + return CircularProgressIndicator(); + else { + // add all notes that are active and note already part of this collection + DB().setNotes(snap.data.where((element) { + try { + collection.notes.firstWhere((n) => n.id == element.id); + return false; + } catch (e) { + return true; + } + }).toList()); + + return _NoteSearchView( + collection: collection, onAddNotes: onAddNotes, single: single); + } + }, + future: LocalStorage().getActiveNotes()); + return Scaffold(body: builder); + } +} + +class _NoteSearchView extends StatefulWidget { + final NoteCollection collection; + final ValueChanged> onAddNotes; // function that will be called + final bool single; + _NoteSearchView({this.collection, this.onAddNotes, this.single = false}); + + @override + State createState() { + return _NoteSearchViewState(); + } +} + +class _NoteSearchViewState extends State<_NoteSearchView> + with StoreWatcherMixin, SingleTickerProviderStateMixin { + TextEditingController _controller; + StaticStorage storage; + // settings store, use view and set recording format + + bool isSearching = true; + bool filtersEnabled; + + bool get isFiltering => storage.filters.length > 0; + + @override + Widget build(BuildContext context) { + return _sliver(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + filtersEnabled = false; + _controller = TextEditingController(); + + // set notes note belonging already to this collection + storage = listenToStore(searchNoteStorageToken); + setTwoPerRow(true); + } + + _clear() { + // clears the state of the view + resetStaticStorage(); + } + + _activeFiltersView() { + return ActiveFiltersView( + filters: storage.filters, removeFilter: removeFilter); + } + + _filtersView() { + List items = []; + + List, FilterBy, String>> filterOptions = [ + Tuple3(DB().uniqueKeys, FilterBy.KEY, "keys"), + Tuple3(DB().uniqueCapos, FilterBy.CAPO, "capos"), + Tuple3(DB().uniqueTunings, FilterBy.TUNING, "tunings"), + Tuple3(DB().uniqueLabels, FilterBy.LABEL, "labels"), + ]; + + for (Tuple3, FilterBy, String> option in filterOptions) { + if (option.item1.length >= 0) { + items.add(FilterOptionsView( + title: option.item3, + data: option.item1, + by: option.item2, + showMore: storage.showMore(option.item2), + mustShowMore: storage.mustShowMore(option.item2), + isFilterApplied: storage.isFilterApplied, + )); + } + } + + return Padding( + padding: EdgeInsets.only(left: 25, top: 60), + child: ListView.builder( + itemBuilder: (context, i) => items[i], + itemCount: items.length, + )); + } + + _searchActionButtons() { + return [ + IconButton( + icon: Icon(filtersEnabled ? Icons.arrow_upward : Icons.filter_list), + onPressed: () { + setState(() { + filtersEnabled = !filtersEnabled; + }); + }) + ]; + } + + _onOk() { + widget.onAddNotes(storage.selectedNotes); + _clear(); + Navigator.of(context).pop(); + } + + _sliverNoteSelectionAppBar() { + return SliverAppBar( + pinned: true, + leading: IconButton( + icon: Icon(Icons.clear), onPressed: () => clearSelection()), + title: Text(storage.selectedNotes.length.toString()), + actions: [ + IconButton(icon: Icon(Icons.check), onPressed: _onOk), + ], + ); + } + + _onBackPressed() { + Navigator.of(context).pop(); + } + + _sliverAppBar() { + return SliverAppBar( + titleSpacing: 5.0, + actions: _searchActionButtons(), + flexibleSpace: (filtersEnabled) + ? _filtersView() + : (isFiltering ? _activeFiltersView() : Container()), + leading: IconButton( + icon: Icon(Icons.arrow_back), onPressed: () => _onBackPressed()), + title: Padding( + child: Center(child: _searchView()), + padding: EdgeInsets.only(left: 5)), + expandedHeight: + (isSearching && filtersEnabled) ? 370 : (isFiltering ? 100 : 0), + floating: false, + pinned: true, + ); + } + + _sliver() { + onTap(Note note) { + if (widget.single && (storage.selectedNotes.length == 1)) { + if (storage.isSelected(note)) { + removeAllSelectedNotes(); + } else { + removeAllSelectedNotes(); + triggerSelectNote(note); + } + } else { + triggerSelectNote(note); + } + } + + onLongPress(Note note) { + if (widget.single && (storage.selectedNotes.length == 1)) return; + triggerSelectNote(note); + } + + List noteList = []; + + if (storage.isAnyNoteStarred()) { + print("notes are starred"); + List items = storage.filteredNotes + .where((n) => !n.starred) + .map((n) => + NoteListItemModel(note: n, isSelected: storage.isSelected(n))) + .toList(); + + List starrtedItems = storage.filteredNotes + .where((n) => n.starred) + .map((n) => + NoteListItemModel(note: n, isSelected: storage.isSelected(n))) + .toList(); + + noteList = [ + SliverList( + delegate: SliverChildListDelegate([ + Padding( + padding: EdgeInsets.only(left: 16, top: 16), + child: Row(children: [ + Text("Starred", style: Theme.of(context).textTheme.caption), + Padding( + padding: EdgeInsets.only(left: 8), + child: Icon(Icons.star, size: 16)) + ])) + ])), + NoteList(true, storage.view, starrtedItems, onTap, onLongPress, + highlight: storage.search == "" ? null : storage.search.trim()), + SliverList( + delegate: SliverChildListDelegate([ + Padding( + padding: EdgeInsets.only(left: 16), + child: Text("Other", style: Theme.of(context).textTheme.caption)) + ])), + NoteList(true, storage.view, items, onTap, onLongPress, + highlight: storage.search == "" ? null : storage.search.trim()) + ]; + } else { + List items = storage.filteredNotes + .map((n) => + NoteListItemModel(note: n, isSelected: storage.isSelected(n))) + .toList(); + + noteList = [ + NoteList(true, storage.view, items, onTap, onLongPress, + highlight: storage.search == "" ? null : storage.search) + ]; + } + + SliverAppBar appBar = storage.isAnyNoteSelected() + ? _sliverNoteSelectionAppBar() + : _sliverAppBar(); + + return CustomScrollView( + slivers: [appBar]..addAll(noteList), + ); + } + + _searchView() { + return SearchTextView( + toggleIsSearching: ({searching}) {}, + onChanged: (s) { + searchNotes(s); + }, + controller: _controller); + } +} diff --git a/lib/note_viewer.dart b/lib/note_viewer.dart index 945daa1..ebe19f0 100644 --- a/lib/note_viewer.dart +++ b/lib/note_viewer.dart @@ -17,21 +17,21 @@ class NoteViewer extends StatefulWidget { showSheet; NoteViewer(this.note, - {this.actions, + {List? actions, this.showZoomPlayback = true, this.showAudioFiles = true, this.showTitle = true, this.showSheet = false, this.showAdditionalInformation = true, - Key key}) - : super(key: key); + super.key}) + : actions = actions ?? const []; @override _NoteViewerState createState() => _NoteViewerState(); } class _NoteViewerState extends State { - ScrollController _controller; + late ScrollController _controller; bool showButtons = true; double textScaleFactor = 1.0; bool isPlaying = false; @@ -42,8 +42,7 @@ class _NoteViewerState extends State { super.initState(); _controller = ScrollController() ..addListener(() { - bool upDirection = - _controller.position.userScrollDirection == ScrollDirection.forward; + _controller.position.userScrollDirection == ScrollDirection.forward; }); textScaleFactor = widget.note.zoom; @@ -91,7 +90,7 @@ class _NoteViewerState extends State { List playingActions = [ IconButton( - color: Theme.of(context).accentColor, + color: Theme.of(context).colorScheme.secondary, icon: Icon(Icons.stop), onPressed: () { setState(() { @@ -99,7 +98,7 @@ class _NoteViewerState extends State { }); }), IconButton( - color: Theme.of(context).accentColor, + color: Theme.of(context).colorScheme.secondary, icon: Icon(Icons.fast_rewind), onPressed: () { setState(() { @@ -108,7 +107,7 @@ class _NoteViewerState extends State { _updateScrollOffset(); }), IconButton( - color: Theme.of(context).accentColor, + color: Theme.of(context).colorScheme.secondary, icon: Icon(Icons.fast_forward), onPressed: () { setState(() { @@ -121,7 +120,7 @@ class _NoteViewerState extends State { List actions = [ IconButton( icon: Icon(Icons.play_arrow), - color: Theme.of(context).accentColor, + color: Theme.of(context).colorScheme.secondary, onPressed: () { if (!isPlaying) { setState(() { @@ -141,7 +140,7 @@ class _NoteViewerState extends State { } }), IconButton( - color: Theme.of(context).accentColor, + color: Theme.of(context).colorScheme.secondary, icon: Icon(Icons.zoom_in), onPressed: () { setState(() { @@ -150,7 +149,7 @@ class _NoteViewerState extends State { _updateZoom(); }), IconButton( - color: Theme.of(context).accentColor, + color: Theme.of(context).colorScheme.secondary, icon: Icon(Icons.zoom_out), onPressed: () { setState(() { @@ -159,7 +158,7 @@ class _NoteViewerState extends State { _updateZoom(); }), IconButton( - color: Theme.of(context).accentColor, + color: Theme.of(context).colorScheme.secondary, icon: Icon(Icons.settings_backup_restore_outlined), onPressed: () { setState(() { @@ -195,7 +194,7 @@ class _NoteViewerState extends State { padding: EdgeInsets.symmetric(vertical: 20), child: Text( 'Audio Files', - style: Theme.of(context).textTheme.subtitle1, + style: Theme.of(context).textTheme.titleMedium, ))); items.addAll(widget.note.audioFiles.map((e) { @@ -206,9 +205,7 @@ class _NoteViewerState extends State { } return Scaffold( - appBar: widget.actions == null - ? null - : AppBar( + appBar: AppBar( actions: widget.actions, ), bottomSheet: widget.showSheet diff --git a/lib/note_views/appbar.dart b/lib/note_views/appbar.dart index 7406557..8f40f1b 100644 --- a/lib/note_views/appbar.dart +++ b/lib/note_views/appbar.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:sound/db.dart'; import 'package:sound/storage.dart'; typedef FilterByCallback = bool Function(FilterBy); @@ -10,11 +9,16 @@ class FilterView extends StatelessWidget { final Filter filter; final ValueChanged remove, add; - FilterView({this.filter, this.active, this.remove, this.add}); + const FilterView( + {required this.filter, + required this.active, + required this.remove, + required this.add, + super.key}); @override Widget build(BuildContext context) { - Color backgroundColor = (active) + Color? backgroundColor = (active) ? Theme.of(context).chipTheme.selectedColor : Theme.of(context).chipTheme.backgroundColor; @@ -36,15 +40,14 @@ class FilterOptionsView extends StatelessWidget { final bool mustShowMore; final FilterCallback isFilterApplied; - FilterOptionsView( - {this.title, - this.data, - this.by, - this.showMore, - this.mustShowMore, - this.isFilterApplied, - Key key}) - : super(key: key); + const FilterOptionsView( + {required this.title, + required this.data, + required this.by, + required this.showMore, + required this.mustShowMore, + required this.isFilterApplied, + super.key}); @override Widget build(BuildContext context) { @@ -62,7 +65,7 @@ class FilterOptionsView extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title.toUpperCase(), - style: Theme.of(context).appBarTheme.textTheme.caption), + style: Theme.of(context).textTheme.bodySmall), (mustShowMore) ? GestureDetector( onTap: () => toggleShowMore(by), @@ -72,10 +75,10 @@ class FilterOptionsView extends StatelessWidget { (showMore) ? 'Show Less' : 'Show More', style: Theme.of(context) .textTheme - .caption - .copyWith( + .bodySmall + ?.copyWith( color: - Theme.of(context).accentColor)))) + Theme.of(context).colorScheme.secondary)))) : Container(height: 0, width: 0), ], ), @@ -101,8 +104,8 @@ class ActiveFiltersView extends StatelessWidget { final List filters; final ValueChanged removeFilter; - ActiveFiltersView({this.filters, this.removeFilter, Key key}) - : super(key: key); + const ActiveFiltersView( + {required this.filters, required this.removeFilter, super.key}); @override Widget build(BuildContext context) { return Padding( @@ -119,7 +122,9 @@ class ActiveFiltersView extends StatelessWidget { scrollDirection: Axis.horizontal, itemBuilder: (context, index) { Filter filter = filters[index]; - Color color = Theme.of(context).chipTheme.selectedColor; + Color color = + Theme.of(context).chipTheme.selectedColor ?? + Theme.of(context).colorScheme.secondary; return Padding( padding: EdgeInsets.symmetric(horizontal: 5), @@ -132,7 +137,6 @@ class ActiveFiltersView extends StatelessWidget { }, )) ]))); - ; } } diff --git a/lib/note_views/seach.dart b/lib/note_views/seach.dart index f7ece56..4a58b60 100644 --- a/lib/note_views/seach.dart +++ b/lib/note_views/seach.dart @@ -1,26 +1,28 @@ import 'package:flutter/material.dart'; class SearchTextView extends StatelessWidget { - final Function toggleIsSearching; + final Function({bool searching}) toggleIsSearching; final ValueChanged onChanged; final TextEditingController controller; - SearchTextView( - {this.toggleIsSearching, this.onChanged, this.controller, Key key}) - : super(key: key); + const SearchTextView( + {required this.toggleIsSearching, + required this.onChanged, + required this.controller, + super.key}); @override Widget build(BuildContext context) { return TextField( controller: controller, autofocus: false, - style: Theme.of(context).appBarTheme.textTheme.subtitle1, + style: Theme.of(context).textTheme.titleMedium, onTap: () => toggleIsSearching(searching: true), onSubmitted: (s) => toggleIsSearching(searching: false), decoration: InputDecoration( border: InputBorder.none, hintText: "Search...", - hintStyle: Theme.of(context).appBarTheme.textTheme.subtitle1), + hintStyle: Theme.of(context).textTheme.titleMedium), maxLines: 1, minLines: 1, onChanged: (String s) => onChanged(s)); diff --git a/lib/range_slider.dart b/lib/range_slider.dart index 52c0993..9f21abd 100644 --- a/lib/range_slider.dart +++ b/lib/range_slider.dart @@ -1,1303 +1,54 @@ -// Copyright 2018-2019 Didier Boelens. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. +import 'package:flutter/material.dart' as m; -import 'dart:math' as math; +typedef RangeChanged = void Function(double lowerValue, double upperValue); -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; - -typedef RangeSliderCallback(double lowerValue, double upperValue); -typedef RangeSliderValueIndicatorFormatter(int index, double value); - -/// A Material Design range slider, extension of the original Flutter Slider. -/// -/// Used to select a range of values using 2 thumbs. -/// -/// A RangeSlider can be used to select from either a continuous or a discrete set of -/// values. The default is to use a continuous range of values from [min] to -/// [max]. To use discrete values, use a non-null value for [divisions], which -/// indicates the number of discrete intervals. For example, if [min] is 0.0 and -/// [max] is 50.0 and [divisions] is 5, then the RangeSlider can take on the -/// discrete values 0.0, 10.0, 20.0, 30.0, 40.0, and 50.0. -/// -/// The terms for the parts of a RangeSlider are: -/// -/// * The "thumb", which is a shape that slides horizontally when the user -/// drags it. -/// * The "track", which is the line that the slider thumb slides along. -/// * The "value indicator", which is a shape that pops up when the user -/// is dragging the active thumb to indicate the value [lowerValue] or -/// [upperValue] being selected. -/// -/// The RangeSlider will be disabled if [onChanged] is null. -/// -/// The RangeSlider widget itself does not maintain any state. Instead, when the state -/// of the RangeSlider changes, the widget calls the [onChanged] callback. Most -/// widgets that use a RangeSlider will listen for the [onChanged] callback and -/// rebuild the RangeSlider with a new set of values [lowerValue] and [upperValue] -/// to update the visual appearance of the RangeSlider. -/// -/// To know when the values start to change, or when it is done -/// changing, set the optional callbacks [onChangeStart] and/or [onChangeEnd]. -/// -/// By default, a RangeSlider will be as wide as possible, centered vertically. When -/// given unbounded constraints, it will attempt to make the track 144 pixels -/// wide (with margins on each side) and will shrink-wrap vertically. -/// -/// Requires one of its ancestors to be a [Material] widget. -/// -/// To determine how it should be displayed (e.g. colors, thumb shape, etc.), -/// a RangeSlider uses the [SliderThemeData] available from either a [SliderTheme] -/// widget or the [ThemeData.sliderTheme] a [Theme] widget above it in the -/// widget tree. -/// -/// See also: -/// -/// * [SliderTheme] and [SliderThemeData] for information about controlling -/// the visual appearance of the RangeSlider. -class RangeSlider extends StatefulWidget { - /// Creates a material design RangeSlider. - /// - /// The RangeSlider itself does not maintain any state. Instead, when the state of - /// the RangeSlider changes, the widget calls the [onChanged] callback. Most - /// widgets that use a RangeSlider will listen for the [onChanged] callback and - /// rebuild the RangeSlider with a new set of values ([lowerValue] and [upperValue]) - /// to update the visual appearance of the RangeSlider. - /// - /// * [lowerValue] determines the currently selected lower value for this RangeSlider. - /// * [upperValue] determines the currently selected upper value for this RangeSlider. - /// * [onChanged] is called while the user is selecting a new value ([lowerValue] or [upperValue]) - /// for the RangeSlider. - /// * [onChangeStart] is called when the user starts to select a new value ([lowerValue] or [upperValue]) - /// for the RangeSlider - /// * [onChangeEnd] is called when the user is done selecting a new value ([lowerValue] or [upperValue]) - /// for the RangeSlider. - /// * [showValueIndicator] determines whether the RangeSlider should show a "value indicator" when - /// the user is dragging one of the 2 thumbs - /// * [touchRadiusExpansionRatio] determines the ratio with which to expand - /// the thumb size, to increase (>1) or decrease (<1) the touch surface of the thumbs. - /// It is advised to set this value such that the touch surface of a thumb - /// becomes at least 40.0 x 40.0. The default thumbs have a radius of 6, - /// so a value of at least 3.33 is advisable in that case. - /// * [valueIndicatorMaxDecimals] determines the maximum number of decimals to use to display - /// the value of the currently dragged thumb, inside the "value indicator" - /// * [valueIndicatorFormatter] if defined, is called to format the text to be displayed - /// inside a "value indicator" - /// A fine-grained control of the appearance is achieved using a [SliderThemeData]. - const RangeSlider({ - Key key, - this.min = 0.0, - this.max = 1.0, - this.divisions, - @required this.lowerValue, - @required this.upperValue, - this.onChanged, - this.onChangeStart, - this.onChangeEnd, - this.showValueIndicator: false, - this.touchRadiusExpansionRatio = 3.33, - this.valueIndicatorMaxDecimals = 1, - this.valueIndicatorFormatter, - this.allowThumbOverlap = false, - }) : assert(min != null), - assert(max != null), - assert(min <= max), - assert(divisions == null || divisions > 0), - assert(lowerValue != null), - assert(upperValue != null), - assert(lowerValue >= min && lowerValue <= max), - assert(upperValue >= lowerValue && upperValue <= max), - assert(valueIndicatorMaxDecimals >= 0 && valueIndicatorMaxDecimals < 5), - super(key: key); - - /// The minimum value the user can select. - /// - /// Defaults to 0.0. Must be less than or equal to [max]. +class RangeSlider extends m.StatelessWidget { final double min; - - /// The maximum value the user can select. - /// - /// Defaults to 1.0. Must be greater than or equal to [min]. final double max; - - /// The currently selected lower value. - /// - /// Corresponds to the left thumb - /// Must be greater than or equal to [min], - /// less than or equal to [max]. final double lowerValue; - - /// The currently selected upper value. - /// - /// Corresponds to the right thumb - /// Must be greater than [lowerValue], - /// less than or equal to [max]. final double upperValue; - - /// The number of discrete divisions. - /// - /// If null, the slider is continuous, - /// otherwise discrete - final int divisions; - - /// Do we show a label above the active thumb when - /// the RangeSlider is active ? + final RangeChanged onChanged; + final RangeChanged? onChangeEnd; final bool showValueIndicator; - /// The ratio with which to expand the thumb size, to increase (>1) or - /// decrease (<1) the touch surface of the thumbs. - /// It is advised to set this value such that the touch surface of a thumb - /// becomes at least 40.0 x 40.0. The default thumbs have a radius of 6, - /// so a value of at least 3.33 is advisable. - final double touchRadiusExpansionRatio; - - /// Max number of decimals when displaying - /// the value in the label above the active - /// thumb - final int valueIndicatorMaxDecimals; - - /// External function to format the value to be displayed in the label above the - /// active thumb. Ignored if null. - /// - /// [index] gives the index of the value indicator: 0: lower value, 1: upper value - /// - /// ## Sample Code - /// ```dart - /// frs.RangeSlider( - /// lowerValue: _lowerValue, - /// upperValue: _upperValue, - /// min: 1.0, - /// max: 10.0, - /// divisions: 10, - /// valueIndicatorFormatter: (int index, double value){ - /// String threeDecimals = value.toStringAsFixed(3); - /// return '$threeDecimals m'; - /// }, - /// onChanged: (double newLowerValue, double newUpperValue) { - /// setState(() { - /// _lowerValue = newLowerValue; - /// _upperValue = newUpperValue; - /// }); - /// }, - /// ) - /// ``` - final RangeSliderValueIndicatorFormatter valueIndicatorFormatter; - - /// Callback to invoke when the user is changing the - /// values. - /// - /// The RangeSlider passes both values [lowerValue] and [upperValue] - /// to the callback but does not actually change state until the parent - /// widget rebuilds the RangeSlider with the new values. - /// - /// If null, the RangeSlider will be displayed as disabled. - /// - /// The callback provided to onChanged should update the state of the parent - /// [StatefulWidget] using the [State.setState] method, so that the parent - /// gets rebuilt; for example: - /// - /// ## Sample code - /// - /// ```dart - /// frs.RangeSlider( - /// lowerValue: _lowerValue, - /// upperValue: _upperValue, - /// min: 1.0, - /// max: 10.0, - /// divisions: 10, - /// onChanged: (double newLowerValue, double newUpperValue) { - /// setState(() { - /// _lowerValue = newLowerValue; - /// _upperValue = newUpperValue; - /// }); - /// }, - /// ) - /// ``` - /// - /// See also: - /// - /// * [onChangeStart] for a callback that is called when the user starts - /// changing the value. - /// * [onChangeEnd] for a callback that is called when the user stops - /// changing the value. - final RangeSliderCallback onChanged; - - /// Callback to invoke when the user starts dragging - /// - /// This callback shouldn't be used to update the RangeSlider values ([lowerValue] or [upperValue]) - /// (use [onChanged] for that), but rather to be notified when the user has started - /// selecting a new value by starting a drag. - /// - /// The values passed will be the last [lowerValue] and [upperValue] that the RangeSlider had before the - /// change began. - /// - /// ## Sample code - /// - /// ```dart - /// frs.RangeSlider( - /// lowerValue: _lowerValue, - /// upperValue: _upperValue, - /// min: 1.0, - /// max: 10.0, - /// divisions: 10, - /// onChanged: (double newLowerValue, double newUpperValue) { - /// setState(() { - /// _lowerValue = newLowerValue; - /// _upperValue = newUpperValue; - /// }); - /// }, - /// onChangeStart: (double startLowerValue, double startUpperValue) { - /// print('Started change with $startLowerValue and $startUpperValue'); - /// }, - /// ) - /// ``` - /// - /// See also: - /// - /// * [onChangeEnd] for a callback that is called when the value change is - /// complete. - final RangeSliderCallback onChangeStart; - - /// Callback to invoke when the user ends the dragging - /// - /// This callback shouldn't be used to update the RangeSlider values ([lowerValue] or [upperValue]) - /// (use [onChanged] for that), but rather to know when the user has completed - /// selecting a new range of values [lowerValue] and [upperValue] - /// by ending a drag. - /// - /// ## Sample code - /// - /// ```dart - /// frs.RangeSlider( - /// lowerValue: _lowerValue, - /// upperValue: _upperValue, - /// min: 1.0, - /// max: 10.0, - /// divisions: 10, - /// onChanged: (double newLowerValue, double newUpperValue) { - /// setState(() { - /// _lowerValue = newLowerValue; - /// _upperValue = newUpperValue; - /// }); - /// }, - /// onChangeEnd: (double newLowerValue, double newUpperValue) { - /// print('Ended change with $newLowerValue and $newUpperValue'); - /// }, - /// ) - /// ``` - /// - /// See also: - /// - /// * [onChangeStart] for a callback that is called when a value change - /// begins. - final RangeSliderCallback onChangeEnd; - - /// - /// Allows thumbs to overlap (default: false) - /// - final bool allowThumbOverlap; - - @override - _RangeSliderState createState() => _RangeSliderState(); - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DoubleProperty('lowerValue', lowerValue)); - properties.add(DoubleProperty('upperValue', upperValue)); - properties.add(DoubleProperty('min', min)); - properties.add(DoubleProperty('max', max)); - properties.add(IntProperty('divisions', divisions)); - } -} - -class _RangeSliderState extends State - with TickerProviderStateMixin { - static const Duration kEnableAnimationDuration = - const Duration(milliseconds: 75); - static const Duration kValueIndicatorAnimationDuration = - const Duration(milliseconds: 100); - - // Animation controller that is run when the overlay (a.k.a radial reaction) - // is shown in response to user interaction. - AnimationController overlayController; - - // Animation controller that is run when enabling/disabling the slider. - AnimationController enableController; - - // Animation controller that is run when the value indicator is being shown - // or hidden. - AnimationController valueIndicatorController; - - @override - void initState() { - super.initState(); - - // Initialize the animation controllers - overlayController = AnimationController( - duration: kRadialReactionDuration, - vsync: this, - ); - - enableController = AnimationController( - duration: kEnableAnimationDuration, - vsync: this, - ); - - valueIndicatorController = AnimationController( - duration: kValueIndicatorAnimationDuration, - vsync: this, - ); - - // Set the enableController value to active (1 if we are handling callback) - // or to inactive (0 otherwise) - enableController.value = widget.onChanged != null ? 1.0 : 0.0; - } - - @override - void dispose() { - // release the animation controllers - valueIndicatorController.dispose(); - enableController.dispose(); - overlayController.dispose(); - super.dispose(); - } - - // ------------------------------------------------- - // Returns a value between 0.0 and 1.0 - // given a value between min and max - // ------------------------------------------------- - double _unlerp(double value) { - assert(value <= widget.max); - assert(value >= widget.min); - return widget.max > widget.min - ? (value - widget.min) / (widget.max - widget.min) - : 0.0; - } - - // ------------------------------------------------- - // Returns the number, between min and max - // proportional to value, which must be - // between 0.0 and 1.0 - // ------------------------------------------------- - double lerp(double value) { - assert(value >= 0.0); - assert(value <= 1.0); - return value * (widget.max - widget.min) + widget.min; - } - - // ------------------------------------------------- - // Handling of any change applied to lowerValue - // and/or upperValue - // Invokes the corresponding callback - // ------------------------------------------------- - void _handleChanged(double lowerValue, double upperValue) { - if (widget.onChanged is RangeSliderCallback) { - widget.onChanged(lerp(lowerValue), lerp(upperValue)); - } - } - - void _handleChangeStart(double lowerValue, double upperValue) { - if (widget.onChangeStart is RangeSliderCallback) { - widget.onChangeStart(lerp(lowerValue), lerp(upperValue)); - } - } - - void _handleChangeEnd(double lowerValue, double upperValue) { - if (widget.onChangeEnd is RangeSliderCallback) { - widget.onChangeEnd(lerp(lowerValue), lerp(upperValue)); - } - } - - static const double _defaultTrackHeight = 2.0; - static const SliderTrackShape _defaultTrackShape = - RoundedRectSliderTrackShape(); - static const SliderTickMarkShape _defaultTickMarkShape = - RoundSliderTickMarkShape(); - static const SliderComponentShape _defaultOverlayShape = - RoundSliderOverlayShape(); - static const SliderComponentShape _defaultThumbShape = - RoundSliderThumbShape(); - static const SliderComponentShape _defaultValueIndicatorShape = - PaddleSliderValueIndicatorShape(); - static const ShowValueIndicator _defaultShowValueIndicator = - ShowValueIndicator.onlyForDiscrete; - - @override - Widget build(BuildContext context) { - final ThemeData theme = Theme.of(context); - SliderThemeData sliderTheme = SliderTheme.of(context); - - // - // Make sure the sliderTheme has all necessary pieces of information - // - sliderTheme = sliderTheme.copyWith( - trackHeight: sliderTheme.trackHeight ?? _defaultTrackHeight, - activeTrackColor: - sliderTheme.activeTrackColor ?? theme.colorScheme.primary, - inactiveTrackColor: sliderTheme.inactiveTrackColor ?? - theme.colorScheme.primary.withOpacity(0.24), - disabledActiveTrackColor: sliderTheme.disabledActiveTrackColor ?? - theme.colorScheme.onSurface.withOpacity(0.32), - disabledInactiveTrackColor: sliderTheme.disabledInactiveTrackColor ?? - theme.colorScheme.onSurface.withOpacity(0.12), - activeTickMarkColor: sliderTheme.activeTickMarkColor ?? - theme.colorScheme.onPrimary.withOpacity(0.54), - inactiveTickMarkColor: sliderTheme.inactiveTickMarkColor ?? - theme.colorScheme.primary.withOpacity(0.54), - disabledActiveTickMarkColor: sliderTheme.disabledActiveTickMarkColor ?? - theme.colorScheme.onPrimary.withOpacity(0.12), - disabledInactiveTickMarkColor: - sliderTheme.disabledInactiveTickMarkColor ?? - theme.colorScheme.onSurface.withOpacity(0.12), - thumbColor: sliderTheme.thumbColor ?? theme.colorScheme.primary, - disabledThumbColor: sliderTheme.disabledThumbColor ?? - theme.colorScheme.onSurface.withOpacity(0.38), - overlayColor: sliderTheme.overlayColor ?? - theme.colorScheme.primary.withOpacity(0.12), - valueIndicatorColor: - sliderTheme.valueIndicatorColor ?? theme.colorScheme.primary, - trackShape: sliderTheme.trackShape ?? _defaultTrackShape, - tickMarkShape: sliderTheme.tickMarkShape ?? _defaultTickMarkShape, - thumbShape: sliderTheme.thumbShape ?? _defaultThumbShape, - overlayShape: sliderTheme.overlayShape ?? _defaultOverlayShape, - valueIndicatorShape: - sliderTheme.valueIndicatorShape ?? _defaultValueIndicatorShape, - showValueIndicator: - sliderTheme.showValueIndicator ?? _defaultShowValueIndicator, - valueIndicatorTextStyle: sliderTheme.valueIndicatorTextStyle ?? - theme.textTheme.body2.copyWith( - color: theme.colorScheme.onPrimary, - ), - ); - - return Padding( - padding: EdgeInsets.symmetric(horizontal: 8), - child: _RangeSliderRenderObjectWidget( - lowerValue: _unlerp(widget.lowerValue), - upperValue: _unlerp(widget.upperValue), - divisions: widget.divisions, - onChanged: (widget.onChanged != null) ? _handleChanged : null, - onChangeStart: _handleChangeStart, - onChangeEnd: _handleChangeEnd, - sliderTheme: sliderTheme, - state: this, - showValueIndicator: widget.showValueIndicator, - valueIndicatorMaxDecimals: widget.valueIndicatorMaxDecimals, - touchRadiusExpansionRatio: widget.touchRadiusExpansionRatio, - valueIndicatorFormatter: widget.valueIndicatorFormatter, - allowThumbOverlap: widget.allowThumbOverlap, - )); - } -} - -// ------------------------------------------------------ -// Widget that instantiates a RenderObject -// ------------------------------------------------------ -class _RangeSliderRenderObjectWidget extends LeafRenderObjectWidget { - const _RangeSliderRenderObjectWidget({ - Key key, - this.lowerValue, - this.upperValue, - this.divisions, - this.onChanged, - this.onChangeStart, + const RangeSlider({ + super.key, + required this.min, + required this.max, + required this.lowerValue, + required this.upperValue, + required this.onChanged, this.onChangeEnd, - this.sliderTheme, - this.state, - this.showValueIndicator, - this.valueIndicatorMaxDecimals, - this.touchRadiusExpansionRatio, - this.valueIndicatorFormatter, - this.allowThumbOverlap, - }) : super(key: key); - - final _RangeSliderState state; - final RangeSliderCallback onChanged; - final RangeSliderCallback onChangeStart; - final RangeSliderCallback onChangeEnd; - final SliderThemeData sliderTheme; - final double lowerValue; - final double upperValue; - final int divisions; - final bool showValueIndicator; - final int valueIndicatorMaxDecimals; - final double touchRadiusExpansionRatio; - final RangeSliderValueIndicatorFormatter valueIndicatorFormatter; - final bool allowThumbOverlap; + this.showValueIndicator = false, + }); @override - RenderObject createRenderObject(BuildContext context) { - return _RenderRangeSlider( - lowerValue: lowerValue, - upperValue: upperValue, - divisions: divisions, - onChanged: onChanged, - onChangeStart: onChangeStart, - onChangeEnd: onChangeEnd, - sliderTheme: sliderTheme, - state: state, - showValueIndicator: showValueIndicator, - valueIndicatorMaxDecimals: valueIndicatorMaxDecimals, - touchRadiusExpansionRatio: touchRadiusExpansionRatio, - valueIndicatorFormatter: valueIndicatorFormatter, - allowThumbOverlap: allowThumbOverlap, + m.Widget build(m.BuildContext context) { + final values = m.RangeValues( + lowerValue.clamp(min, max), + upperValue.clamp(min, max), ); - } - - @override - void updateRenderObject( - BuildContext context, _RenderRangeSlider renderObject) { - renderObject - ..lowerValue = lowerValue - ..upperValue = upperValue - ..divisions = divisions - ..onChanged = onChanged - ..onChangeStart = onChangeStart - ..onChangeEnd = onChangeEnd - ..sliderTheme = sliderTheme - ..showValueIndicator = showValueIndicator - ..valueIndicatorMaxDecimals = valueIndicatorMaxDecimals - ..touchRadiusExpansionRatio = touchRadiusExpansionRatio - ..valueIndicatorFormatter = valueIndicatorFormatter - ..allowThumbOverlap = allowThumbOverlap; - } -} - -// ------------------------------------------------------ -// Class that renders the RangeSlider as a pure drawing -// in a Canvas and allows the user to interact. -// ------------------------------------------------------ -class _RenderRangeSlider extends RenderBox { - _RenderRangeSlider({ - double lowerValue, - double upperValue, - int divisions, - RangeSliderCallback onChanged, - RangeSliderCallback onChangeStart, - RangeSliderCallback onChangeEnd, - SliderThemeData sliderTheme, - @required this.state, - bool showValueIndicator, - int valueIndicatorMaxDecimals, - double touchRadiusExpansionRatio, - RangeSliderValueIndicatorFormatter valueIndicatorFormatter, - bool allowThumbOverlap, - }) { - // Initialization - this.divisions = divisions; - this.lowerValue = lowerValue; - this.upperValue = upperValue; - this.onChanged = onChanged; - this.onChangeStart = onChangeStart; - this.onChangeEnd = onChangeEnd; - this.sliderTheme = sliderTheme; - this.showValueIndicator = showValueIndicator; - this.valueIndicatorMaxDecimals = valueIndicatorMaxDecimals; - this._touchRadiusExpansionRatio = touchRadiusExpansionRatio; - this.valueIndicatorFormatter = valueIndicatorFormatter; - this.allowThumbOverlap = allowThumbOverlap; - - // Initialization of the Drag Gesture Recognizer - _drag = HorizontalDragGestureRecognizer() - ..onStart = _handleDragStart - ..onEnd = _handleDragEnd - ..onUpdate = _handleDragUpdate - ..onCancel = _handleDragCancel; - - // Initialization of the overlay animation - _overlayAnimation = CurvedAnimation( - parent: state.overlayController, - curve: Curves.fastOutSlowIn, - ); - - // Initialization of the enable/disable animation - _enableAnimation = CurvedAnimation( - parent: state.enableController, - curve: Curves.easeInOut, - ); - - // Initialization of the animation to show the value indicator - _valueIndicatorAnimation = CurvedAnimation( - parent: state.valueIndicatorController, - curve: Curves.fastOutSlowIn, - ); - } - - // ------------------------------------------------- - // Global Constants. See Material.io - // ------------------------------------------------- - static const double _overlayRadius = 16.0; - static const double _overlayDiameter = _overlayRadius; - static const double _preferredTrackWidth = 144.0; - static const double _preferredTotalWidth = - _preferredTrackWidth + 2 * _overlayDiameter; - static final Tween _overlayRadiusTween = - Tween(begin: 0.0, end: _overlayRadius); - - // ------------------------------------------------- - // Instance specific properties - // ------------------------------------------------- - _RangeSliderState state; - RangeSliderCallback _onChanged; - RangeSliderCallback _onChangeStart; - RangeSliderCallback _onChangeEnd; - double _lowerValue; - double _upperValue; - int _divisions; - Animation _overlayAnimation; - Animation _enableAnimation; - Animation _valueIndicatorAnimation; - HorizontalDragGestureRecognizer _drag; - SliderThemeData _sliderTheme; - bool _showValueIndicator; - double _touchRadiusExpansionRatio; - int _valueIndicatorMaxDecimals; - final TextPainter _valueIndicatorPainter = TextPainter(); - RangeSliderValueIndicatorFormatter _valueIndicatorFormatter; - bool _allowThumbOverlap; - - // -------------------------------------------------- - // Setters - // Setters are necessary since we will need - // to update the values via the - // _RangeSliderRenderObjectWidget.updateRenderObject - // -------------------------------------------------- - set lowerValue(double value) { - assert(value != null && value >= 0.0 && value <= 1.0); - _lowerValue = _discretize(value); - } - - set upperValue(double value) { - assert(value != null && value >= 0.0 && value <= 1.0); - _upperValue = _discretize(value); - } - - set touchRadiusExpansionRatio(double value) { - assert(value != null && value >= 0.1); - _touchRadiusExpansionRatio = value; - } - - set divisions(int value) { - _divisions = value; - - // If we change the value, we need to repaint - markNeedsPaint(); - } - - set onChanged(RangeSliderCallback value) { - // If no changes were applied, skip - if (_onChanged == value) { - return; - } - - // Were we handling callbacks? - final bool wasInteractive = isInteractive; - - // Record the new callback - _onChanged = value; - - // Did we change the callbacks - if (wasInteractive != isInteractive) { - if (isInteractive) { - state.enableController.forward(); - } else { - state.enableController.reverse(); - } - - // As we apply a change, we need to redraw - markNeedsPaint(); - } - } - set onChangeStart(RangeSliderCallback value) { - _onChangeStart = value; - } - - set onChangeEnd(RangeSliderCallback value) { - _onChangeEnd = value; - } - - set sliderTheme(SliderThemeData value) { - assert(value != null); - _sliderTheme = value; - - // If we change the theme, we need to repaint - markNeedsPaint(); - } - - set showValueIndicator(bool value) { - // Skip if no changes - if (value == _showValueIndicator) { - return; - } - _showValueIndicator = value; - - // Force a repaint of the value indicator - _updateValueIndicatorPainter(); - } - - set valueIndicatorMaxDecimals(int value) { - // Skip if no changes - if (value == _valueIndicatorMaxDecimals) { - return; - } - - _valueIndicatorMaxDecimals = value; - - // Force a repaint - markNeedsPaint(); - } - - set valueIndicatorFormatter(RangeSliderValueIndicatorFormatter formatter) { - _valueIndicatorFormatter = formatter; - } - - set allowThumbOverlap(bool value) { - _allowThumbOverlap = value; - } - - // ---------------------------------------------- - // Are we handling callbacks? - // ---------------------------------------------- - bool get isInteractive => (_onChanged != null); - - // ---------------------------------------------- - // Obtain the radius of a thumb from the Theme - // ---------------------------------------------- - double get _thumbRadius { - final Size preferredSize = _sliderTheme.thumbShape - .getPreferredSize(isInteractive, (_divisions != null)); - return math.max(preferredSize.width, preferredSize.height) / 2.0; - } - - // ---------------------------------------------- - // Get from the SliderTheme the right to show - // the value indicator unless said otherwise - // ---------------------------------------------- - bool get showValueIndicator { - bool showValueIndicator; - switch (_sliderTheme.showValueIndicator) { - case ShowValueIndicator.onlyForDiscrete: - showValueIndicator = (_divisions != null); - break; - case ShowValueIndicator.onlyForContinuous: - showValueIndicator = (_divisions == null); - break; - case ShowValueIndicator.always: - showValueIndicator = true; - break; - case ShowValueIndicator.never: - showValueIndicator = false; - break; - } - return (showValueIndicator && _showValueIndicator); - } - - // -------------------------------------------- - // Update the value indicator painter, based - // on the SliderTheme - // -------------------------------------------- - void _updateValueIndicatorPainter() { - if (_showValueIndicator != false) { - _valueIndicatorPainter - ..text = TextSpan(style: _sliderTheme.valueIndicatorTextStyle, text: '') - ..textDirection = TextDirection.ltr - ..layout(); - } else { - _valueIndicatorPainter.text = null; - } - - // Force a re-layout - markNeedsLayout(); - } - - // -------------------------------------------- - // We need to repaint - // we are dragging and changing the activation - // -------------------------------------------- - @override - void attach(PipelineOwner owner) { - super.attach(owner); - _overlayAnimation.addListener(markNeedsPaint); - _enableAnimation.addListener(markNeedsPaint); - _valueIndicatorAnimation.addListener(markNeedsPaint); - } - - @override - void detach() { - _valueIndicatorAnimation.removeListener(markNeedsPaint); - _enableAnimation.removeListener(markNeedsPaint); - _overlayAnimation.removeListener(markNeedsPaint); - super.detach(); - } - - // ------------------------------------------- - // The size of this RenderBox is defined by - // the parent - // ------------------------------------------- - @override - bool get sizedByParent => true; - - // ------------------------------------------- - // Update of the RenderBox size using only - // the constraints. - // Compulsory when sizedByParent returns true - // ------------------------------------------- - @override - void performResize() { - size = Size( - constraints.hasBoundedWidth ? constraints.maxWidth : _preferredTotalWidth, - constraints.hasBoundedHeight - ? constraints.maxHeight - : _overlayDiameter * 2.0, + return m.SliderTheme( + data: m.Theme.of(context).sliderTheme.copyWith( + showValueIndicator: showValueIndicator + ? m.ShowValueIndicator.onDrag + : m.ShowValueIndicator.never, + ), + child: m.RangeSlider( + min: min, + max: max, + values: values, + labels: showValueIndicator + ? m.RangeLabels( + values.start.toStringAsFixed(1), + values.end.toStringAsFixed(1), + ) + : null, + onChanged: (v) => onChanged(v.start, v.end), + onChangeEnd: + onChangeEnd == null ? null : (v) => onChangeEnd!(v.start, v.end), + ), ); } - - // ------------------------------------------- - // Mandatory if we want any interaction - // ------------------------------------------- - @override - bool hitTestSelf(Offset position) => true; - - // ------------------------------------------- - // Computation of the min,max intrinsic - // width and height of the box - // ------------------------------------------- - @override - double computeMinIntrinsicWidth(double height) { - return 2 * - math.max( - _overlayDiameter, - _sliderTheme.thumbShape - .getPreferredSize(true, (_divisions != null)) - .width); - } - - @override - double computeMaxIntrinsicWidth(double height) { - return _preferredTotalWidth; - } - - @override - double computeMinIntrinsicHeight(double width) { - return math.max( - _overlayDiameter, - _sliderTheme.thumbShape - .getPreferredSize(true, (_divisions != null)) - .height); - } - - @override - double computeMaxIntrinsicHeight(double width) { - return math.max( - _overlayDiameter, - _sliderTheme.thumbShape - .getPreferredSize(true, (_divisions != null)) - .height); - } - - // --------------------------------------------- - // Paint the Range Slider - // --------------------------------------------- - @override - void paint(PaintingContext context, Offset offset) { - final Canvas canvas = context.canvas; - - _paintTrack(canvas, offset); - _paintOverlay(canvas); - if (_divisions != null) { - _paintTickMarks(canvas, offset); - } - _paintValueIndicator(context); - _paintThumbs(context, offset); - } - - // --------------------------------------------- - // Paint the track - // --------------------------------------------- - double _trackLength; - double _trackVerticalCenter; - double _trackLeft; - double _trackTop; - double _trackBottom; - double _trackRight; - double _thumbLeftPosition; - double _thumbRightPosition; - - void _paintTrack(Canvas canvas, Offset offset) { - final double trackRadius = _sliderTheme.trackHeight / 2.0; - - _trackLength = size.width - 2 * _overlayDiameter; - _trackVerticalCenter = offset.dy + (size.height) / 2.0; - _trackLeft = offset.dx + _overlayDiameter; - _trackTop = _trackVerticalCenter - trackRadius; - _trackBottom = _trackVerticalCenter + trackRadius; - _trackRight = _trackLeft + _trackLength; - - // Compute the position of the thumbs - _thumbLeftPosition = _trackLeft + _lowerValue * _trackLength; - _thumbRightPosition = _trackLeft + _upperValue * _trackLength; - - // Define the paint colors for both unselected and selected track segments - Paint unselectedTrackPaint = Paint() - ..color = isInteractive - ? _sliderTheme.inactiveTrackColor - : _sliderTheme.disabledInactiveTrackColor; - Paint selectedTrackPaint = Paint() - ..color = isInteractive - ? _sliderTheme.activeTrackColor - : _sliderTheme.disabledActiveTrackColor; - - // Draw the track - if (_lowerValue > 0.0) { - // Draw the unselected left range - canvas.drawRect( - Rect.fromLTRB( - _trackLeft, _trackTop, _thumbLeftPosition, _trackBottom), - unselectedTrackPaint); - } - // Draw the selected range - canvas.drawRect( - Rect.fromLTRB( - _thumbLeftPosition, _trackTop, _thumbRightPosition, _trackBottom), - selectedTrackPaint); - - if (_upperValue < 1.0) { - // Draw the unselected right range - canvas.drawRect( - Rect.fromLTRB( - _thumbRightPosition, _trackTop, _trackRight, _trackBottom), - unselectedTrackPaint); - } - } - - // --------------------------------------------- - // Paint the overlay - // --------------------------------------------- - void _paintOverlay(Canvas canvas) { - if (!_overlayAnimation.isDismissed && - _previousActiveThumb != _ActiveThumb.none) { - final Paint overlayPaint = Paint()..color = _sliderTheme.overlayColor; - final double radius = _overlayRadiusTween.evaluate(_overlayAnimation); - - // We need to find the position of the overlay % active thumb - Offset center; - if (_previousActiveThumb == _ActiveThumb.lowerThumb) { - center = Offset(_thumbLeftPosition, _trackVerticalCenter); - } else { - center = Offset(_thumbRightPosition, _trackVerticalCenter); - } - - canvas.drawCircle(center, radius, overlayPaint); - } - } - - // --------------------------------------------- - // Paint the tick marks - // --------------------------------------------- - void _paintTickMarks(Canvas canvas, Offset offset) { - final double trackWidth = _trackRight - _trackLeft; - final double dx = (trackWidth - _sliderTheme.trackHeight) / _divisions; - final double _tickRadius = (_sliderTheme.trackHeight / 2.0).clamp(1.0, 2.0); - final double _tickOffset = (_sliderTheme.trackHeight - _tickRadius) / 2.0; - - for (int i = 0; i <= _divisions; i++) { - final double left = _trackLeft + i * dx; - final Offset center = Offset( - left + _tickOffset, - _trackTop + - _tickOffset + - (_sliderTheme.trackHeight > 2.0 ? 1.0 : 0.0)); - - canvas.drawCircle( - center, - _tickRadius, - Paint() - ..color = isInteractive - ? _sliderTheme.activeTickMarkColor - : _sliderTheme.disabledActiveTickMarkColor); - } - } - - // --------------------------------------------- - // Paint the thumbs - // --------------------------------------------- - Rect _thumbLowerRect; - Rect _thumbUpperRect; - - void _paintThumbs(PaintingContext context, Offset offset) { - final Offset thumbLowerCenter = - Offset(_thumbLeftPosition, _trackVerticalCenter); - final Offset thumbUpperCenter = - Offset(_thumbRightPosition, _trackVerticalCenter); - final double thumbRadius = _thumbRadius; - - _thumbLowerRect = - Rect.fromCircle(center: thumbLowerCenter - offset, radius: thumbRadius); - _thumbUpperRect = - Rect.fromCircle(center: thumbUpperCenter - offset, radius: thumbRadius); - - // Paint the thumbs, via the Theme - _sliderTheme.thumbShape.paint(context, thumbLowerCenter, - isDiscrete: (_divisions != null), - parentBox: this, - sliderTheme: _sliderTheme, - value: _lowerValue, - enableAnimation: _enableAnimation, - activationAnimation: _valueIndicatorAnimation, - labelPainter: _valueIndicatorPainter, - textDirection: TextDirection.ltr, - textScaleFactor: 1.0, - sizeWithOverflow: Size(thumbRadius, thumbRadius)); - - _sliderTheme.thumbShape.paint(context, thumbUpperCenter, - isDiscrete: (_divisions != null), - parentBox: this, - sliderTheme: _sliderTheme, - value: _upperValue, - enableAnimation: _enableAnimation, - activationAnimation: _valueIndicatorAnimation, - labelPainter: _valueIndicatorPainter, - textDirection: TextDirection.ltr, - textScaleFactor: 1.0, - sizeWithOverflow: Size(thumbRadius, thumbRadius)); - } - - // --------------------------------------------- - // Paint the value indicator - // --------------------------------------------- - void _paintValueIndicator(PaintingContext context) { - if (isInteractive && - _showValueIndicator && - _previousActiveThumb != _ActiveThumb.none) { - if (_valueIndicatorAnimation.status != AnimationStatus.dismissed && - showValueIndicator) { - // We need to find the position of the value indicator % active thumb - // as well as the value to be displayed - Offset thumbCenter; - double value; - String textValue; - int index = 0; - - if (_previousActiveThumb == _ActiveThumb.lowerThumb) { - thumbCenter = Offset(_thumbLeftPosition, _trackVerticalCenter); - value = _lowerValue; - } else { - thumbCenter = Offset(_thumbRightPosition, _trackVerticalCenter); - value = _upperValue; - index = 1; - } - - // Adapt the value to be displayed to the max number of decimals - // as well as convert it to the initial range (min, max) - value = state.lerp(value); - - // Invoke the external value indicator formatter, if any and valid - if (_valueIndicatorFormatter is RangeSliderValueIndicatorFormatter) { - try { - textValue = _valueIndicatorFormatter(index, value); - } catch (_) {} - } - textValue = - textValue ?? value.toStringAsFixed(_valueIndicatorMaxDecimals); - - // Adapt the value indicator with the active thumb value - _valueIndicatorPainter - ..text = TextSpan( - style: _sliderTheme.valueIndicatorTextStyle, - text: textValue, - ) - ..layout(); - - // Ask the SliderTheme to paint the valueIndicator - _sliderTheme.valueIndicatorShape.paint(context, thumbCenter, - activationAnimation: _valueIndicatorAnimation, - enableAnimation: _enableAnimation, - isDiscrete: (_divisions != null), - labelPainter: _valueIndicatorPainter, - parentBox: this, - sliderTheme: _sliderTheme, - value: value, - textDirection: TextDirection.ltr, - textScaleFactor: 1.0, - sizeWithOverflow: Size(_thumbRadius, _thumbRadius)); - } - } - } - - // --------------------------------------------- - // Drag related routines - // --------------------------------------------- - double _currentDragValue = 0.0; - double _minDragValue; - double _maxDragValue; - - // ------------------------------------------- - // When we start dragging, we need to - // memorize the initial position of the - // pointer, relative to the track. - // ------------------------------------------- - void _handleDragStart(DragStartDetails details) { - _currentDragValue = _getValueFromGlobalPosition(details.globalPosition); - - // As we are starting to drag, let's invoke the corresponding callback - _onChangeStart(_lowerValue, _upperValue); - - // Show the overlay - state.overlayController.forward(); - - // Show the value indicator - if (showValueIndicator) { - state.valueIndicatorController.forward(); - } - } - - // ------------------------------------------- - // When we are dragging, we need to - // consider the delta between the initial - // pointer position and the current and - // compute the new position of the thumb. - // Then, we call the handler of a value change - // ------------------------------------------- - void _handleDragUpdate(DragUpdateDetails details) { - final double valueDelta = details.primaryDelta / _trackLength; - _currentDragValue += valueDelta; - - // we need to limit the movement to the track - _onRangeChanged(_currentDragValue.clamp(_minDragValue, _maxDragValue)); - } - - // ------------------------------------------- - // End or Cancellation of the drag - // ------------------------------------------- - void _handleDragEnd(DragEndDetails details) { - _handleDragCancel(); - } - - void _handleDragCancel() { - _previousActiveThumb = _activeThumb; - _activeThumb = _ActiveThumb.none; - _currentDragValue = 0.0; - - // As we have finished with the drag, let's invoke - // the appropriate callback - _onChangeEnd(_lowerValue, _upperValue); - - // Hide the overlay - state.overlayController.reverse(); - - // Hide the value indicator - if (showValueIndicator) { - state.valueIndicatorController.reverse(); - } - } - - // ---------------------------------------------- - // Handling of a change in the Range selection - // ---------------------------------------------- - void _onRangeChanged(double value) { - // If there are divisions, we need to stick to one - value = _discretize(value); - - if (_activeThumb == _ActiveThumb.lowerThumb) { - _lowerValue = value; - } else { - _upperValue = value; - } - - // Invoke the appropriate callback during the drag - _onChanged(_lowerValue, _upperValue); - - // Force a repaint - markNeedsPaint(); - } - - // ---------------------------------------------- - // If there are divisions, values should be - // aligned to divisions - // ---------------------------------------------- - double _discretize(double value) { - if (_divisions != null) { - value = (value * _divisions).round() / _divisions; - } - return value; - } - - // ---------------------------------------------- - // Position helper. - // Translates the Pointer global position to - // a percentage of the track length - // ---------------------------------------------- - double _getValueFromGlobalPosition(Offset globalPosition) { - final double visualPosition = - (globalToLocal(globalPosition).dx - _overlayDiameter) / _trackLength; - - return visualPosition; - } - - // ---------------------------------------------- - // Event Handling - // We need to validate that the pointer hits - // a thumb before accepting to initiate a Drag. - // ---------------------------------------------- - @override - void handleEvent(PointerEvent event, BoxHitTestEntry entry) { - if (event is PointerDownEvent && isInteractive) { - _validateActiveThumb(entry.localPosition); - - // If a thumb is active, initiates the GestureDrag - if (_activeThumb != _ActiveThumb.none) { - _drag.addPointer(event); - - // Force the event related to a drag start - _handleDragStart(DragStartDetails(globalPosition: event.position)); - } - } - } - - // ---------------------------------------------- - // Determine whether the user presses a thumb - // If yes, activate the thumb - // ---------------------------------------------- - _ActiveThumb _activeThumb = _ActiveThumb.none; - _ActiveThumb _previousActiveThumb = _ActiveThumb.none; - - _validateActiveThumb(Offset position) { - var _thumbLowerExpandedRect = Rect.fromCircle( - center: _thumbLowerRect.centerLeft, - radius: _thumbRadius * _touchRadiusExpansionRatio); - var _thumbUpperExpandedRect = Rect.fromCircle( - center: _thumbUpperRect.centerRight, - radius: _thumbRadius * _touchRadiusExpansionRatio); - double calculatedDivisionOffset = (_divisions != null) - ? _discretize(1.0 / _divisions) - : (_thumbRadius * 2.0) / _trackLength; - double divisionOffset = _allowThumbOverlap ? 0.0 : calculatedDivisionOffset; - - if (_thumbLowerExpandedRect.contains(position)) { - _activeThumb = _ActiveThumb.lowerThumb; - _minDragValue = 0.0; - _maxDragValue = _discretize(_upperValue - divisionOffset); - } else if (_thumbUpperExpandedRect.contains(position)) { - _activeThumb = _ActiveThumb.upperThumb; - _minDragValue = _discretize(_lowerValue + divisionOffset); - _maxDragValue = 1.0; - } else { - _activeThumb = _ActiveThumb.none; - } - _previousActiveThumb = _activeThumb; - } -} - -enum _ActiveThumb { - // no thumb is currently active - none, - // the lowerThumb is active - lowerThumb, - // the upperThumb is active - upperThumb, } diff --git a/lib/recorder_bottom_sheet.dart b/lib/recorder_bottom_sheet.dart index 1bd3cc2..5d43ca5 100644 --- a/lib/recorder_bottom_sheet.dart +++ b/lib/recorder_bottom_sheet.dart @@ -1,231 +1,165 @@ import 'package:flutter/material.dart'; -import 'package:flutter_flux/flutter_flux.dart'; -import 'package:sound/looper.dart'; +import 'package:provider/provider.dart'; + +import 'looper.dart'; import 'recorder_store.dart'; -class BottomInfo extends StatefulWidget { +class BottomInfo extends StatelessWidget { final Color color; final double pad; final double height; - BottomInfo(this.color, {this.pad = 4, this.height = 50, Key key}) - : super(key: key); - - @override - _BottomInfoState createState() => _BottomInfoState(); -} - -class _BottomInfoState extends State - with StoreWatcherMixin { - RecorderBottomSheetStore recorderStore; - PlayerPositionStore playerPositionStore; - RecorderPositionStore recorderPositionStore; - - @override - void initState() { - super.initState(); - recorderStore = listenToStore(recorderBottomSheetStoreToken); - playerPositionStore = listenToStore(playerPositionStoreToken); - recorderPositionStore = listenToStore(recorderPositionStoreToken); - } - - _onButtonPress() { - stopAction(); - } + const BottomInfo(this.color, {this.pad = 4, this.height = 50, super.key}); @override Widget build(BuildContext context) { - Duration elapsed; - Duration length; + final recorderStore = context.watch(); + final playerPositionStore = context.watch(); + final recorderPositionStore = context.watch(); - String _elapsed = ""; + Duration elapsed = Duration.zero; + Duration? length; - if (recorderStore.state == RecorderState.PAUSING || - recorderStore.state == RecorderState.PLAYING) { + if (recorderStore.state == RecorderState.pausing || + recorderStore.state == RecorderState.playing) { elapsed = playerPositionStore.position; - _elapsed = (elapsed.inMilliseconds / 1000).toStringAsFixed(1); - if (recorderStore.currentLength != null) { - length = recorderStore.currentLength; - } - } else if (recorderStore.state == RecorderState.RECORDING) { + length = recorderStore.currentLength; + } else if (recorderStore.state == RecorderState.recording) { elapsed = recorderPositionStore.position; - _elapsed = elapsed.inSeconds.toString(); } - String timeString = _elapsed; - + String timeString = (elapsed.inMilliseconds / 1000).toStringAsFixed(1); if (length != null) { - timeString += " / " + (length.inMilliseconds / 1000).toStringAsFixed(1); + timeString += ' / ${(length.inMilliseconds / 1000).toStringAsFixed(1)}'; } - timeString += " s"; - - IconData icon = Icons.stop; - - String state = (RecorderState.RECORDING == recorderStore.state) - ? "Recording" - : (recorderStore.state == RecorderState.PAUSING) - ? "Pausing" - : "Playing"; + timeString += ' s'; - double pad = widget.pad; - List children = [ + final children = [ Padding( - child: IconButton(icon: Icon(icon), onPressed: _onButtonPress), - padding: EdgeInsets.only(left: pad)), + padding: EdgeInsets.only(left: pad), + child: IconButton(icon: const Icon(Icons.stop), onPressed: () => stopAction()), + ), ]; - var timeWidget = Padding( - child: Text(timeString), - padding: EdgeInsets.only(left: pad, right: pad)); - - if ((RecorderState.RECORDING == recorderStore.state)) { - children.add(Expanded(child: Text(state))); - children.add( - Padding(child: timeWidget, padding: EdgeInsets.only(right: pad))); + final timeWidget = Padding( + padding: EdgeInsets.only(left: pad, right: pad), + child: Text(timeString), + ); + + if (recorderStore.state == RecorderState.recording) { + children.add(const Expanded(child: Text('Recording'))); + children.add(Padding( + padding: EdgeInsets.only(right: pad), + child: timeWidget, + )); } else { - children.add( - Padding(child: timeWidget, padding: EdgeInsets.only(right: pad))); - - /* - if (length != null) { - children.add(Expanded(flex: 1, child: _getSlider())); - } else { - */ + children.add(Padding( + padding: EdgeInsets.only(right: pad), + child: timeWidget, + )); - if (recorderStore.state == RecorderState.PAUSING) { + if (recorderStore.state == RecorderState.pausing) { children.add(Padding( - padding: EdgeInsets.only(right: pad), - child: IconButton( - icon: Icon(Icons.play_arrow), - onPressed: () => resumeAction()))); + padding: EdgeInsets.only(right: pad), + child: IconButton( + icon: const Icon(Icons.play_arrow), + onPressed: () => resumeAction(), + ), + )); } else { children.add(Padding( - padding: EdgeInsets.only(right: pad), - child: IconButton( - icon: Icon(Icons.pause), onPressed: () => pauseAction()))); + padding: EdgeInsets.only(right: pad), + child: IconButton( + icon: const Icon(Icons.pause), + onPressed: () => pauseAction(), + ), + )); } } return Container( - color: widget.color, - height: widget.height, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: children)); + color: color, + height: height, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: children, + ), + ); } } -class PlayerSlider extends StatefulWidget { - PlayerSlider({Key key}) : super(key: key); - - @override - _PlayerSliderState createState() => _PlayerSliderState(); -} - -class _PlayerSliderState extends State - with StoreWatcherMixin { - RecorderBottomSheetStore store; - PlayerPositionStore playerPositionStore; - - @override - void initState() { - super.initState(); - store = listenToStore(recorderBottomSheetStoreToken); - playerPositionStore = listenToStore(playerPositionStoreToken); - } +class PlayerSlider extends StatelessWidget { + const PlayerSlider({super.key}); @override Widget build(BuildContext context) { - return Container( - height: 50, - child: Column(children: [ - Expanded( - child: Slider( - min: 0.0, - max: store.currentLength == null - ? 0.0 - : (store.currentLength.inMilliseconds / 1000).toDouble(), - value: - (playerPositionStore.position.inMilliseconds / 1000).toDouble(), - onChanged: (value) { - print("on changed to $value"); - skipTo(Duration(milliseconds: (value * 1000).floor())); - }, - //activeColor: Colors.yellow, - )) - ])); + final store = context.watch(); + final playerPositionStore = context.watch(); + + final max = store.currentLength == null + ? 0.0 + : (store.currentLength!.inMilliseconds / 1000).toDouble(); + final value = (playerPositionStore.position.inMilliseconds / 1000).toDouble(); + + return SizedBox( + height: 50, + child: Slider( + min: 0.0, + max: max, + value: value.clamp(0.0, max == 0.0 ? 0.0 : max), + onChanged: (raw) { + skipTo(Duration(milliseconds: (raw * 1000).floor())); + }, + ), + ); } } -class RecorderBottomSheet extends StatefulWidget { - RecorderBottomSheet({Key key}) : super(key: key); - - @override - _RecorderBottomSheetState createState() => _RecorderBottomSheetState(); -} - -class _RecorderBottomSheetState extends State - with StoreWatcherMixin { - RecorderBottomSheetStore store; - - @override - void initState() { - super.initState(); - store = listenToStore(recorderBottomSheetStoreToken); - print("INIT STATE...."); - } - - @override - void dispose() { - super.dispose(); - } +class RecorderBottomSheet extends StatelessWidget { + const RecorderBottomSheet({super.key}); @override Widget build(BuildContext context) { - if (store.state == RecorderState.STOP) - return Container(height: 0, width: 0); - - var showLooper = ((store.state == RecorderState.PLAYING || - store.state == RecorderState.PAUSING)); - Color color; - - if (store.state == RecorderState.PLAYING || - store.state == RecorderState.PAUSING) { - color = Theme.of(context).bottomAppBarColor; - } else if (store.state == RecorderState.RECORDING) { - color = Theme.of(context).primaryColor; - } + final store = context.watch(); - double width = MediaQuery.of(context).size.width; + if (store.state == RecorderState.stop) { + return const SizedBox.shrink(); + } - Looper looper = Looper(color); - BottomInfo info = BottomInfo(color); + final showLooper = store.state == RecorderState.playing || + store.state == RecorderState.pausing; + final color = showLooper + ? Theme.of(context).colorScheme.surfaceContainerHighest + : Theme.of(context).primaryColor; + final width = MediaQuery.of(context).size.width; if (showLooper) { return Container( - decoration: BoxDecoration( - color: Theme.of(context).bottomAppBarColor, - borderRadius: BorderRadius.all(Radius.circular(5)), - boxShadow: [ - BoxShadow( - color: Theme.of(context).appBarTheme.color, - spreadRadius: 1, - blurRadius: 15, - ), - ]), - height: 300, - width: width, - child: Column(children: [ - SizedBox(height: 10), - looper, - SizedBox(height: 50), - Text("Player:"), - PlayerSlider(), - Expanded(child: Container()), - info - ])); - } else { - return info; + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.all(Radius.circular(5)), + boxShadow: [ + BoxShadow( + color: Theme.of(context).appBarTheme.backgroundColor ?? Colors.black, + spreadRadius: 1, + blurRadius: 15, + ), + ], + ), + height: 300, + width: width, + child: Column(children: [ + const SizedBox(height: 10), + Looper(color), + const SizedBox(height: 50), + const Text('Player:'), + const PlayerSlider(), + const Expanded(child: SizedBox()), + BottomInfo(color), + ]), + ); } + + return BottomInfo(color); } } diff --git a/lib/recorder_store.dart b/lib/recorder_store.dart index 9dad814..423a538 100644 --- a/lib/recorder_store.dart +++ b/lib/recorder_store.dart @@ -1,353 +1,293 @@ +import 'dart:async'; +import 'dart:io'; + import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' show RangeValues; -import 'package:flutter_audio_recorder/flutter_audio_recorder.dart'; -import 'package:flutter_flux/flutter_flux.dart' show Store, Action, StoreToken; -// import 'package:video_player/video_player.dart'; -// import 'package:flutter_sound/flutter_sound.dart'; +import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:sound/editor_store.dart'; -import 'package:sound/settings_store.dart'; +import 'package:record/record.dart'; import 'package:tuple/tuple.dart'; -import 'dart:async'; -import 'model.dart'; -import 'dart:io'; -import 'package:path/path.dart' as p; -//import 'package:audio_recorder/audio_recorder.dart'; - -// https://github.com/ZaraclaJ/audio_recorder -enum RecorderState { STOP, RECORDING, PLAYING, PAUSING } +import 'editor_store.dart'; +import 'model.dart'; -class PlayerPositionStore extends Store { - Duration _position = Duration(seconds: 0); +enum RecorderState { stop, recording, playing, pausing } +class PlayerPositionStore extends ChangeNotifier { + Duration _position = Duration.zero; Duration get position => _position; - PlayerPositionStore() { - changePlayerPosition.listen((event) { - _position = event; - trigger(); - }); + void changePlayerPosition(Duration value) { + _position = value; + notifyListeners(); } } -Action changePlayerPosition = Action(); -StoreToken playerPositionStoreToken = StoreToken(PlayerPositionStore()); - -class RecorderPositionStore extends Store { - Duration _position = Duration(seconds: 0); - +class RecorderPositionStore extends ChangeNotifier { + Duration _position = Duration.zero; Duration get position => _position; - RecorderPositionStore() { - changeRecorderPosition.listen((event) { - _position = event; - trigger(); - }); + void changeRecorderPosition(Duration value) { + _position = value; + notifyListeners(); } } -Action changeRecorderPosition = Action(); -StoreToken recorderPositionStoreToken = StoreToken(RecorderPositionStore()); +class RecorderBottomSheetStore extends ChangeNotifier { + final AudioPlayer _player = AudioPlayer(); + final AudioRecorder _recorder = AudioRecorder(); + final StreamController _recordingFinishedController = + StreamController.broadcast(); -class RecorderBottomSheetStore extends Store { - //VideoPlayerController _controller; - RecordingStatus _currentStatus = RecordingStatus.Unset; - AudioPlayer _player = AudioPlayer(); - AudioFormat _audioFormat = AudioFormat.WAV; + Timer? _recordTicker; + DateTime? _recordStartedAt; + StreamSubscription? _positionSub; + StreamSubscription? _durationSub; + StreamSubscription? _completeSub; - Recording _current; - FlutterAudioRecorder _recorder; - Duration _currentLength; // length of the current audio file + AudioFormat _audioFormat = AudioFormat.wav; + AudioFormat get audioFormat => _audioFormat; - // recorder - RecorderState _state = RecorderState.STOP; - String _currentPath; + RecorderState _state = RecorderState.stop; + RecorderState get state => _state; + String? _currentPath; + String? get currentPath => _currentPath; + Duration? _currentLength; + Duration? get currentLength => _currentLength; + Duration? _recordTime; + Duration? get recordTime => _recordTime; - Duration _recordTime; - Duration get recordTime => _recordTime; + RangeValues? _loopRange; + RangeValues? get loopRange => _loopRange; - RangeValues _loopRange; - RangeValues get loopRange => _loopRange; + AudioFile? _audioFile; + AudioFile? get currentAudioFile => _audioFile; - // getters - RecorderState get state => _state; - RecordingStatus get status => _currentStatus; - Duration get currentLength => _currentLength; - String get stateString => _state.toString(); - String get currentPath => _currentPath; + Stream get onRecordingFinished => _recordingFinishedController.stream; - AudioFile _audioFile; - AudioFile get currentAudioFile => _audioFile; - AudioFormat get audioFormat => _audioFormat; - AudioPlayer get player => _player; - - getDurationLoopEnd() { + Duration? getDurationLoopEnd() { if (_loopRange == null) return null; - return Duration(milliseconds: (_loopRange.end * 1000).floor()); + return Duration(milliseconds: (_loopRange!.end * 1000).floor()); } - getDurationLoopStart() { + Duration? getDurationLoopStart() { if (_loopRange == null) return null; - return Duration(milliseconds: (_loopRange.start * 1000).floor()); + return Duration(milliseconds: (_loopRange!.start * 1000).floor()); } - Future stopPlayer() async { - int res = await _player.stop(); - changePlayerPosition(Duration(seconds: 0)); - return res; + Future _stopPlayerInternal() async { + await _player.stop(); + playerPositionStore.changePlayerPosition(Duration.zero); } - Future startPlayer(String path) async { - print("playing $path"); - // set length not yet available - - _player.onAudioPositionChanged.listen((pos) async { - if (_loopRange != null && pos >= getDurationLoopEnd()) { - pos = getDurationLoopStart(); - await _player.seek(pos); + Future _bindPlayerStreams() async { + await _positionSub?.cancel(); + await _durationSub?.cancel(); + await _completeSub?.cancel(); + + _positionSub = _player.onPositionChanged.listen((pos) async { + final loopEnd = getDurationLoopEnd(); + final loopStart = getDurationLoopStart(); + if (loopEnd != null && loopStart != null && pos >= loopEnd) { + await _player.seek(loopStart); + pos = loopStart; } - changePlayerPosition(pos); + playerPositionStore.changePlayerPosition(pos); }); - _player.onDurationChanged.listen((event) { - if (_currentLength != event) { - setDuration(Tuple2(_audioFile, event)); - _currentLength = event; - trigger(); + _durationSub = _player.onDurationChanged.listen((event) { + if (_currentLength == event) return; + _currentLength = event; + if (_audioFile != null) { + setDuration(Tuple2(_audioFile!, event)); } + notifyListeners(); }); - _state = RecorderState.PLAYING; - _player.onPlayerStateChanged.listen((AudioPlayerState event) { - print("player state change $event"); - }); - - _player.onPlayerCompletion.listen((event) { - print("player completed"); + _completeSub = _player.onPlayerComplete.listen((_) { stopAction(); }); + } - print("play me"); - int result = await _player.play(path, isLocal: true); - trigger(); - return result; + Future startPlayer(String path) async { + await _bindPlayerStreams(); + _state = RecorderState.playing; + await _player.play(DeviceFileSource(path)); + notifyListeners(); } - Future init(String path) async { + Future _initRecorder() async { try { - if (await Permission.microphone.request().isGranted) { - _recorder = FlutterAudioRecorder(path, audioFormat: _audioFormat); - await _recorder.initialized; - - // after initialization - _current = await _recorder.current(channel: 0); - return true; - } else { - return false; - } - } catch (e) { - print("ERRROR!"); - print(e); + return await _recorder.hasPermission(); + } catch (_) { return false; } } - Future startRecorder(String path) async { - // Check permissions before starting - print("init..."); - print("starting recorder $path"); - - // Check permissions before starting - bool hasPermissions = await init(path); - print("has permissions: $hasPermissions"); - - if (!hasPermissions) { - return false; + RecordConfig _recordConfig() { + // Prefer AAC for iOS compatibility. Use WAV elsewhere if selected. + if (_audioFormat == AudioFormat.wav && !Platform.isIOS) { + return const RecordConfig(encoder: AudioEncoder.wav); } + return const RecordConfig(encoder: AudioEncoder.aacLc); + } - await _recorder.start(); - _current = await _recorder.current(channel: 0); - _currentStatus = _current.status; - - const tick = const Duration(milliseconds: 50); - - new Timer.periodic(tick, (Timer t) async { - if (_currentStatus == RecordingStatus.Stopped) { - t.cancel(); - } - - var current = await _recorder.current(channel: 0); - // print(current.status); - if (_currentStatus != current.status) { - _currentStatus = current.status; - _current = current; - - trigger(); + Future startRecorder(String path) async { + if (!await _initRecorder()) return false; + await _recorder.start(_recordConfig(), path: path); + _recordStartedAt = DateTime.now(); + _recordTicker?.cancel(); + _recordTicker = Timer.periodic(const Duration(milliseconds: 100), (_) async { + final current = await _recorder.getAmplitude(); + final elapsed = _recordStartedAt == null + ? Duration.zero + : DateTime.now().difference(_recordStartedAt!); + recorderPositionStore.changeRecorderPosition(elapsed); + if (current.current.isNaN) { + // keep ticker alive while recording; no-op } - - changeRecorderPosition(current.duration); }); - return true; } - Future stopRecorder() async { - print("stopping..."); - if (_currentStatus != RecordingStatus.Unset) { - var result = await _recorder.stop(); - // reuslt.path, result.duration - print("Stop recording: ${result.path}"); - print("Stop recording: ${result.duration}"); - _recordTime = result.duration; - _current = result; - changeRecorderPosition(Duration(seconds: 0)); + Future stopRecorder() async { + final path = await _recorder.stop(); + _recordTicker?.cancel(); + _recordStartedAt = null; + final elapsed = recorderPositionStore.position; + _recordTime = elapsed; + recorderPositionStore.changeRecorderPosition(Duration.zero); + if (path != null) { + _currentPath = path; + _recordingFinishedController + .add(AudioFile(duration: elapsed, path: path)); } - - return ""; } Future getFilename() async { - var d = (await getApplicationDocumentsDirectory()).parent; + var d = await getApplicationDocumentsDirectory(); d = Directory(p.join(d.path, 'files')); - - String date = DateTime.now().toString(); - String ext = _audioFormat == AudioFormat.WAV ? "wav" : "aac"; - return d.path + - '/' + - DateTime.now() - .toString() - .substring(0, date.length - 7) - .replaceAll(":", "-") + - ".$ext"; + if (!d.existsSync()) { + d.createSync(recursive: true); + } + final date = DateTime.now().toIso8601String().replaceAll(':', '-'); + final ext = (_audioFormat == AudioFormat.wav && !Platform.isIOS) + ? 'wav' + : 'm4a'; + return p.join(d.path, '$date.$ext'); } - RecorderBottomSheetStore() { - // sound = FlutterSound(); - startPlaybackAction.listen((AudioFile f) { - if (_state == RecorderState.STOP || _state == RecorderState.PAUSING) { - changePlayerPosition(Duration(seconds: 0)); - _audioFile = f; - _currentPath = f.path; - _loopRange = f.loopRange; - print("Loop Range: $_loopRange"); - - startPlayer(f.path).then((t) { - // _state = RecorderState.PLAYING; - // trigger(); - }); - } - }); + Future startPlaybackAction(AudioFile f) async { + if (_state == RecorderState.stop || _state == RecorderState.pausing) { + playerPositionStore.changePlayerPosition(Duration.zero); + _audioFile = f; + _currentPath = f.path; + _loopRange = f.loopRange; + await startPlayer(f.path); + } + } - stopAction.listen((_) { - _loopRange = null; - if (_state == RecorderState.RECORDING || - _state == RecorderState.PLAYING || - _state == RecorderState.PAUSING) { - if (_state == RecorderState.PLAYING || - _state == RecorderState.PAUSING) { - stopPlayer(); - _state = RecorderState.STOP; - trigger(); - } else { - stopRecorder().then((_) { - _state = RecorderState.STOP; - recordingFinished( - AudioFile(duration: _recordTime, path: currentPath)); - }); - } - } - }); + Future stopAction([dynamic _]) async { + _loopRange = null; + if (_state == RecorderState.playing || _state == RecorderState.pausing) { + await _stopPlayerInternal(); + _state = RecorderState.stop; + notifyListeners(); + return; + } + if (_state == RecorderState.recording) { + await stopRecorder(); + _state = RecorderState.stop; + notifyListeners(); + } + } - startRecordingAction.listen((_) { - getFilename().then((path) { - _currentPath = path; - - void start() { - startRecorder(path).then((hasPermissions) { - if (hasPermissions) { - _state = RecorderState.RECORDING; - trigger(); - } else { - //start(); - } - }); - } - - start(); - }); - }); + Future startRecordingAction([dynamic _]) async { + final path = await getFilename(); + _currentPath = path; + final hasPermissions = await startRecorder(path); + if (hasPermissions) { + _state = RecorderState.recording; + notifyListeners(); + } + } - skipTo.listen((d) async { - print("seeking to $d"); - await _player.seek(d); - trigger(); - }); + Future skipTo(Duration d) async { + await _player.seek(d); + notifyListeners(); + } - pauseAction.listen((_) async { - await _player.pause(); - _state = RecorderState.PAUSING; - trigger(); - }); - resumeAction.listen((_) async { - await _player.resume(); - _state = RecorderState.PLAYING; - trigger(); - }); + Future pauseAction([dynamic _]) async { + await _player.pause(); + _state = RecorderState.pausing; + notifyListeners(); + } - resetRecorderState.listen((_) { - //_currentPath = null; - _state = RecorderState.STOP; - trigger(); - }); + Future resumeAction([dynamic _]) async { + await _player.resume(); + _state = RecorderState.playing; + notifyListeners(); + } - setRecorderState.listen((s) { - _state = s; - trigger(); - }); + void resetRecorderState([dynamic _]) { + _state = RecorderState.stop; + notifyListeners(); + } - setAudioFormat.listen((format) { - _audioFormat = format; - print("setting audio format to $_audioFormat"); - trigger(); - }); + void setRecorderState(RecorderState s) { + _state = s; + notifyListeners(); + } - setLoopRange.listen((range) async { - print("$range, $_loopRange"); + void setAudioFormat(AudioFormat format) { + _audioFormat = format; + notifyListeners(); + } - if (_loopRange == null || - (_loopRange != null && range.start != _loopRange.start)) { - var start = Duration(milliseconds: (range.start * 1000).floor()); - await _player.seek(start); - } - _loopRange = range; - trigger(); - }); + Future setLoopRange(RangeValues range) async { + if (_loopRange == null || range.start != _loopRange!.start) { + final start = Duration(milliseconds: (range.start * 1000).floor()); + await _player.seek(start); + } + _loopRange = range; + notifyListeners(); + } - setDefaultAudioFormat.listen((format) { - _audioFormat = format; - print("ping"); - trigger(); - }); - print("editor store created"); + @override + void dispose() { + _recordTicker?.cancel(); + _positionSub?.cancel(); + _durationSub?.cancel(); + _completeSub?.cancel(); + _recordingFinishedController.close(); + _player.dispose(); + super.dispose(); } } -Action startRecordingAction = Action(); -Action startPlaybackAction = Action(); - -Action setRecorderState = Action(); -Action setPath = Action(); -Action stopAction = Action(); -Action pauseAction = Action(); -Action resumeAction = Action(); -Action setElapsed = Action(); -Action skipTo = Action(); -Action recordingFinished = Action(); -Action resetRecorderState = Action(); -Action setLoopRange = Action(); -Action setAudioFormat = Action(); - -StoreToken recorderBottomSheetStoreToken = - StoreToken(RecorderBottomSheetStore()); +final PlayerPositionStore playerPositionStore = PlayerPositionStore(); +final RecorderPositionStore recorderPositionStore = RecorderPositionStore(); +final RecorderBottomSheetStore recorderBottomSheetStore = RecorderBottomSheetStore(); + +void changePlayerPosition(Duration event) => + playerPositionStore.changePlayerPosition(event); +void changeRecorderPosition(Duration event) => + recorderPositionStore.changeRecorderPosition(event); +Future startRecordingAction([dynamic _]) => + recorderBottomSheetStore.startRecordingAction(); +Future startPlaybackAction(AudioFile f) => + recorderBottomSheetStore.startPlaybackAction(f); +void setRecorderState(RecorderState s) => recorderBottomSheetStore.setRecorderState(s); +void setPath(String path) {} +Future stopAction([dynamic _]) => recorderBottomSheetStore.stopAction(); +Future pauseAction([dynamic _]) => recorderBottomSheetStore.pauseAction(); +Future resumeAction([dynamic _]) => recorderBottomSheetStore.resumeAction(); +void setElapsed(Duration d) {} +Future skipTo(Duration d) => recorderBottomSheetStore.skipTo(d); +void recordingFinished(AudioFile f) => + recorderBottomSheetStore.onRecordingFinished.listen((_) {}).cancel(); +void resetRecorderState([dynamic _]) => recorderBottomSheetStore.resetRecorderState(); +Future setLoopRange(RangeValues range) => + recorderBottomSheetStore.setLoopRange(range); +void setAudioFormat(AudioFormat format) => recorderBottomSheetStore.setAudioFormat(format); diff --git a/lib/settings.dart b/lib/settings.dart index 4b133ad..a16597c 100644 --- a/lib/settings.dart +++ b/lib/settings.dart @@ -1,143 +1,91 @@ -import 'package:flutter_audio_recorder/flutter_audio_recorder.dart'; -import 'package:flutter_flux/flutter_flux.dart'; import 'package:flutter/material.dart'; -import 'package:sound/dialogs/initial_import_dialog.dart'; -import 'package:sound/local_storage.dart'; -import 'package:sound/model.dart'; -import 'package:sound/recorder_store.dart'; -import 'package:sound/utils.dart'; +import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:uuid/uuid.dart'; + +import 'backup.dart'; +import 'dialogs/initial_import_dialog.dart'; +import 'local_storage.dart'; +import 'model.dart'; +import 'recorder_store.dart'; import 'settings_store.dart'; -import "backup.dart"; -import 'db.dart'; -import 'package:flutter_share/flutter_share.dart'; -import 'package:path/path.dart' as p; +import 'sync_debug_panel.dart'; +import 'sync_network.dart'; +import 'utils.dart'; class Settings extends StatefulWidget { - final Function onMenuPressed; - Settings(this.onMenuPressed); + final VoidCallback onMenuPressed; + const Settings(this.onMenuPressed, {super.key}); @override - State createState() { - return SettingsState(); - } + State createState() => SettingsState(); } -class SettingsState extends State with StoreWatcherMixin { - SettingsStore store; - - GlobalKey _globalKey = GlobalKey(); +class SettingsState extends State { + final GlobalKey _globalKey = GlobalKey(); + bool _syncEnabled = true; + String _syncBackendUrl = 'http://192.168.178.52:8009'; @override void initState() { super.initState(); - store = listenToStore(settingsToken); + _loadSyncConfig(); } - _themeAsString() { - if (store.theme == SettingsTheme.dark) { - return "Dark"; - } else { - return "Light"; - } + Future _loadSyncConfig() async { + final enabled = await LocalStorage().getSyncEnabled(); + final backendUrl = await LocalStorage().getSyncBackendUrl(); + if (!mounted) return; + setState(() { + _syncEnabled = enabled; + _syncBackendUrl = backendUrl; + }); } - _wrapItem(item) { - return Padding( - padding: EdgeInsets.only(left: 48, bottom: 8, top: 8, right: 48), - child: item); - } + String _themeAsString(SettingsStore store) => + store.theme == SettingsTheme.dark ? 'Dark' : 'Light'; - _themeItem() { - return _wrapItem(Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Expanded(child: Text("Theme: ")), - RaisedButton( - child: Text(_themeAsString()), - onPressed: toggleTheme, - ), - ], - )); - } - - _audioFormatAsString() { - return store.audioFormat == AudioFormat.AAC ? "AAC" : "WAV"; + Widget _wrapItem(Widget item) { + return Padding( + padding: const EdgeInsets.only(left: 48, bottom: 8, top: 8, right: 48), + child: item, + ); } - _toggleAudioFormat() { - AudioFormat newAudioFormat; - if (store.audioFormat == AudioFormat.AAC) { - newAudioFormat = AudioFormat.WAV; - } else { - newAudioFormat = AudioFormat.AAC; - } - setDefaultAudioFormat(newAudioFormat); - } + String _audioFormatAsString(SettingsStore store) => + store.audioFormat == AudioFormat.aac ? 'AAC' : 'WAV'; - _audioFormatItem() { - return _wrapItem(Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Expanded(child: Text("AudioFormat: ")), - RaisedButton( - child: Text(_audioFormatAsString()), onPressed: _toggleAudioFormat), - ], - )); + Future _toggleAudioFormat(SettingsStore store) async { + final newAudioFormat = store.audioFormat == AudioFormat.aac + ? AudioFormat.wav + : AudioFormat.aac; + await setDefaultAudioFormat(newAudioFormat); + setAudioFormat(newAudioFormat); } - _setName() { - return _wrapItem(Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Expanded( - child: - Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text("Name:"), - Text( - "Used for copyright in exported files", - textScaleFactor: 0.4, - ) - ])), - RaisedButton( - child: Text(store.name == null ? "Edit" : store.name), - onPressed: _showEditNameDialog), - ], - )); - } - - _showEditNameDialog() { - print(store.name); - TextEditingController _controller = TextEditingController.fromValue( - TextEditingValue(text: (store.name == null ? "Edit" : store.name))); - showDialog( + Future _showEditNameDialog(SettingsStore store) async { + final controller = TextEditingController(text: store.name ?? ''); + await showDialog( context: context, builder: (BuildContext context) { - // return object of type Dialog return AlertDialog( - title: new Text("Set Name"), - content: new TextField( + title: const Text('Set Name'), + content: TextField( autofocus: true, maxLines: 1, minLines: 1, - onSubmitted: (s) => print("submit $s"), - controller: _controller, + controller: controller, ), actions: [ - new FlatButton( - child: Text("Cancel"), - onPressed: () { - Navigator.of(context).pop(); - }, + TextButton( + child: const Text('Cancel'), + onPressed: () => Navigator.of(context).pop(), ), - // usually buttons at the bottom of the dialog - new FlatButton( - child: new Text("Apply"), + TextButton( + child: const Text('Apply'), onPressed: () { - setName(_controller.value.text); + setName(controller.value.text); Navigator.of(context).pop(); }, ), @@ -147,65 +95,219 @@ class SettingsState extends State with StoreWatcherMixin { ); } - _onExport() async { - String path = await Backup().exportZip(await LocalStorage().getNotes()); - showSnack(_globalKey.currentState, "Exported zip to $path"); - String filename = p.basename(path); - await FlutterShare.shareFile( - title: filename, text: 'Share backup zip', filePath: path); + Future _onExport() async { + final path = await Backup().exportZip(await LocalStorage().getNotes()); + showSnack(_globalKey.currentState, 'Exported zip to $path'); + final filename = p.basename(path); + await SharePlus.instance.share(ShareParams( + text: 'Share backup zip', + title: filename, + files: [XFile(path)], + )); } - _onImport() async { + Future _onImport() async { try { - List notes = await Backup().import(); - for (Note note in notes) { - // update id - note.id = Uuid().v4(); - //await LocalStorage().syncNote(note); + final notes = await Backup().import(); + for (final note in notes) { + note.id = const Uuid().v4(); } - showSelectNotesImportDialog(context, (List restoredNotes) { - showSnack(_globalKey.currentState, - "Successfully restored ${restoredNotes.length} notes"); - }, notes, title: "Which songs would you like to restore?"); + showSelectNotesImportDialog( + context, + (List restoredNotes) { + showSnack( + _globalKey.currentState, + 'Successfully restored ${restoredNotes.length} notes', + ); + }, + notes, + title: 'Which songs would you like to restore?', + ); } on ImportException { - showSnack(_globalKey.currentState, "Error while importing zip"); + showSnack(_globalKey.currentState, 'Error while importing zip'); + } + } + + Future _showBackendUrlDialog() async { + final controller = TextEditingController(text: _syncBackendUrl); + await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Sync Backend URL'), + content: TextField( + autofocus: true, + maxLines: 1, + minLines: 1, + controller: controller, + decoration: const InputDecoration( + hintText: 'http://127.0.0.1:8009', + ), + ), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () => Navigator.of(context).pop(), + ), + TextButton( + child: const Text('Apply'), + onPressed: () async { + final next = controller.text.trim(); + if (next.isNotEmpty) { + await LocalStorage().setSyncBackendUrl(next); + if (mounted) { + setState(() { + _syncBackendUrl = next; + }); + } + } + if (mounted) Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } + + Future _setLanBackendUrl() async { + final ip = await SyncNetwork.firstPrivateIpv4(); + if (ip == null) { + showSnack(_globalKey.currentState, 'No private IPv4 interface found'); + return; } + final url = 'http://$ip:8009'; + await LocalStorage().setSyncBackendUrl(url); + if (!mounted) return; + setState(() { + _syncBackendUrl = url; + }); + showSnack(_globalKey.currentState, 'Sync backend set to $url'); } - _list() { - var items = [ - _setName(), - _themeItem(), - _audioFormatItem(), - SizedBox(height: 10), - RaisedButton(child: Text("Backup"), onPressed: _onExport), - SizedBox(height: 10), - RaisedButton(child: Text("Restore"), onPressed: _onImport), - SizedBox(height: 10), + Widget _list(SettingsStore store) { + final items = [ + _wrapItem(Row( + children: [ + const Expanded(child: Text('Name:')), + ElevatedButton( + child: Text((store.name == null || store.name!.isEmpty) + ? 'Edit' + : store.name!), + onPressed: () => _showEditNameDialog(store), + ), + ], + )), + _wrapItem(Row( + children: [ + const Expanded(child: Text('Theme:')), + ElevatedButton( + child: Text(_themeAsString(store)), + onPressed: () => toggleTheme(), + ), + ], + )), + _wrapItem(Row( + children: [ + const Expanded(child: Text('Audio Format:')), + ElevatedButton( + child: Text(_audioFormatAsString(store)), + onPressed: () => _toggleAudioFormat(store), + ), + ], + )), + _wrapItem(Row( + children: [ + const Expanded(child: Text('Sync Enabled:')), + Switch( + value: _syncEnabled, + onChanged: (v) async { + await LocalStorage().setSyncEnabled(v); + if (!mounted) return; + setState(() { + _syncEnabled = v; + }); + }, + ), + ], + )), + _wrapItem(Row( + children: [ + const Expanded(child: Text('Sync Backend:')), + Flexible( + child: ElevatedButton( + onPressed: _showBackendUrlDialog, + child: Align( + alignment: Alignment.centerRight, + child: Text( + _syncBackendUrl, + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.right, + ), + ), + ), + ), + ], + )), + _wrapItem( + Row( + children: [ + const Expanded(child: Text('Use LAN URL:')), + ElevatedButton( + onPressed: _setLanBackendUrl, + child: const Text('Auto-detect'), + ), + ], + ), + ), + _wrapItem( + Row( + children: [ + const Expanded(child: Text('Sync Debug:')), + ElevatedButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const SyncDebugPanel(), + ), + ); + }, + child: const Text('Open Panel'), + ), + ], + ), + ), + const SizedBox(height: 10), + ElevatedButton(onPressed: _onExport, child: const Text('Backup')), + const SizedBox(height: 10), + ElevatedButton(onPressed: _onImport, child: const Text('Restore')), ]; return ListView.builder( - padding: EdgeInsets.all(10), - itemBuilder: (context, index) { - return items[index]; - }, - itemCount: items.length); + padding: const EdgeInsets.all(10), + itemBuilder: (context, index) => items[index], + itemCount: items.length, + ); } @override Widget build(BuildContext context) { - // items.add(_title()); - List stackChildren = []; - - stackChildren.add(Container(padding: EdgeInsets.all(16), child: _list())); - + final store = context.watch(); return Scaffold( - key: _globalKey, - appBar: AppBar( - title: Text("Settings"), - leading: IconButton( - icon: Icon(Icons.menu), onPressed: widget.onMenuPressed)), - floatingActionButtonLocation: FloatingActionButtonLocation.endTop, - body: Stack(children: stackChildren)); + key: _globalKey, + appBar: AppBar( + title: const Text('Settings'), + leading: IconButton( + icon: const Icon(Icons.menu), + onPressed: widget.onMenuPressed, + ), + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: _list(store), + ), + ); } } diff --git a/lib/settings_store.dart b/lib/settings_store.dart index bb20077..f26f508 100644 --- a/lib/settings_store.dart +++ b/lib/settings_store.dart @@ -1,74 +1,59 @@ -import 'package:flutter_audio_recorder/flutter_audio_recorder.dart'; -import 'package:flutter_flux/flutter_flux.dart' show Action, Store, StoreToken; +import 'package:flutter/foundation.dart'; import 'package:sound/local_storage.dart'; import 'package:sound/model.dart'; -import 'package:sound/recorder_store.dart'; - -class SettingsStore extends Store { - // default values +class SettingsStore extends ChangeNotifier { Settings _settings = Settings( - audioFormat: AudioFormat.WAV, - theme: SettingsTheme.dark, - name: null, - view: EditorView.single); + audioFormat: AudioFormat.wav, + theme: SettingsTheme.dark, + name: '', + view: EditorView.single, + ); - // getter SettingsTheme get theme => _settings.theme; - EditorView get view => _settings.view; - AudioFormat get audioFormat => _settings.audioFormat; - String get name => _settings.name; - + String? get name => _settings.name; Settings get settings => _settings; - SettingsStore() { - // init listener - toggleTheme.listen((_) async { - if (theme == SettingsTheme.dark) { - _settings.theme = SettingsTheme.light; - } else { - _settings.theme = SettingsTheme.dark; - } - await LocalStorage().syncSettings(settings); - trigger(); - }); + Future toggleTheme() async { + _settings.theme = _settings.theme == SettingsTheme.dark + ? SettingsTheme.light + : SettingsTheme.dark; + await LocalStorage().syncSettings(_settings); + notifyListeners(); + } - setDefaultAudioFormat.listen((format) async { - _settings.audioFormat = format; - await LocalStorage().syncSettings(_settings); - trigger(); - }); + Future setDefaultAudioFormat(AudioFormat format) async { + _settings.audioFormat = format; + await LocalStorage().syncSettings(_settings); + notifyListeners(); + } - setDefaultView.listen((view) async { - _settings.view = view; - await LocalStorage().syncSettings(_settings); - trigger(); - }); + Future setDefaultView(EditorView view) async { + _settings.view = view; + await LocalStorage().syncSettings(_settings); + notifyListeners(); + } - setName.listen((name) async { - _settings.name = name; - await LocalStorage().syncSettings(_settings); - trigger(); - }); + Future setName(String name) async { + _settings.name = name; + await LocalStorage().syncSettings(_settings); + notifyListeners(); + } - // this will be called when the app initializes - updateSettings.listen((s) { - if (s != null) { - _settings = s; - print("settings audio format"); - trigger(); - } - }); + void updateSettings(Settings? settings) { + if (settings == null) return; + _settings = settings; + notifyListeners(); } } -Action toggleTheme = Action(); -Action setName = Action(); - -Action setDefaultView = Action(); -Action setDefaultAudioFormat = Action(); -Action updateSettings = Action(); +final SettingsStore settingsStore = SettingsStore(); -StoreToken settingsToken = StoreToken(SettingsStore()); +Future toggleTheme([dynamic _]) => settingsStore.toggleTheme(); +Future setName(String name) => settingsStore.setName(name); +Future setDefaultView(EditorView view) => settingsStore.setDefaultView(view); +Future setDefaultAudioFormat(AudioFormat format) => + settingsStore.setDefaultAudioFormat(format); +void updateSettings(Settings? settings) => settingsStore.updateSettings(settings); diff --git a/lib/share.dart b/lib/share.dart index f67ed44..4b78844 100644 --- a/lib/share.dart +++ b/lib/share.dart @@ -1,10 +1,13 @@ -import 'package:flutter_share/flutter_share.dart'; import 'package:path/path.dart' as p; +import 'package:share_plus/share_plus.dart'; -Future shareFile(String path, {String filename, String text}) async { +Future shareFile(String path, {String? filename, String? text}) async { if (filename == null) filename = p.basename(path); if (text == null) text = 'Sharing file $filename'; - return await FlutterShare.shareFile( - title: filename, text: text, filePath: path); + await SharePlus.instance.share(ShareParams( + title: filename, + text: text, + files: [XFile(path)], + )); } diff --git a/lib/storage.dart b/lib/storage.dart index 30c4682..746a867 100644 --- a/lib/storage.dart +++ b/lib/storage.dart @@ -1,4 +1,4 @@ -import 'package:flutter_flux/flutter_flux.dart'; +import 'package:flutter/foundation.dart'; import 'local_storage.dart'; import 'file_manager.dart'; import 'model.dart'; @@ -112,7 +112,7 @@ class Filter { FilterBy by; String content; - Filter({this.by, this.content}); + Filter({required this.by, required this.content}); @override int get hashCode => (by.index.toString() + content).hashCode; @@ -120,16 +120,16 @@ class Filter { bool operator ==(o) => (o is Filter && o.by == by && o.content == content); } -class StaticStorage extends Store { - List _filters; - Map _showMore; - bool _twoPerRow; +class StaticStorage extends ChangeNotifier { + List _filters = []; + Map _showMore = {}; + bool _twoPerRow = false; bool get view => _twoPerRow; List get filters => _filters; - List _selectedNotes; + List _selectedNotes = []; List get selectedNotes => _selectedNotes; String _search = ""; @@ -138,7 +138,7 @@ class StaticStorage extends Store { bool mustShowMore(FilterBy by) { Map> f = _getFiltersByCategory(); if (f.keys.contains(by)) { - return f[by].length > 3; + return (f[by]?.length ?? 0) > 3; } else return false; } @@ -150,156 +150,139 @@ class StaticStorage extends Store { bool isAnyNoteStarred() => filteredNotes.any((n) => n.starred); bool showMore(FilterBy by) => - _showMore.containsKey(by) ? _showMore[by] : false; + _showMore.containsKey(by) ? (_showMore[by] ?? false) : false; bool isFilterApplied(Filter filter) => _filters.contains(filter); StaticStorage() { _twoPerRow = false; + } - toggleChangeView.listen((_) { - _twoPerRow = !_twoPerRow; - trigger(); - }); + void toggleChangeView() { + _twoPerRow = !_twoPerRow; + notifyListeners(); + } - _filters = []; - _selectedNotes = []; - _showMore = Map(); + void toggleShowMore(FilterBy by) { + _showMore[by] = !(_showMore[by] ?? false); + notifyListeners(); + } - toggleShowMore.listen((by) { - if (_showMore.containsKey(by)) { - _showMore[by] = !_showMore[by]; - } else { - _showMore[by] = true; - } - trigger(); - }); - - addNote.listen((note) { - DB().addNote(note); - trigger(); - }); - - addFilter.listen((f) { - if (!_filters.contains(f)) { - _filters.add(f); - trigger(); - } - }); - - removeFilter.listen((f) { - _filters.remove(f); - trigger(); - }); - searchNotes.listen((s) { - _search = s; - trigger(); - }); - - triggerSelectNote.listen((Note note) { - if (!_selectedNotes.contains(note)) { - _selectedNotes.add(note); - trigger(); - } else { - _selectedNotes.remove(note); - trigger(); - } - }); + void addNote(Note note) { + DB().addNote(note); + notifyListeners(); + } - removeAllSelectedNotes.listen((_) { - for (Note note in _selectedNotes) { - for (var audio in note.audioFiles) { - FileManager().delete(audio); - } + void addFilter(Filter f) { + if (_filters.contains(f)) return; + _filters.add(f); + notifyListeners(); + } - LocalStorage().deleteNote(note); - } - _selectedNotes.clear(); - trigger(); - }); + void removeFilter(Filter f) { + _filters.remove(f); + notifyListeners(); + } - discardAllSelectedNotes.listen((_) { - for (Note note in _selectedNotes) { - LocalStorage().discardNote(note); - } - _selectedNotes.clear(); - trigger(); - }); - - starAllSelectedNotes.listen((_) { - for (Note note in _selectedNotes) { - note.starred = true; - LocalStorage().syncNoteAttr(note, 'starred'); - } - _selectedNotes.clear(); - trigger(); - }); - unstarAllSelectedNotes.listen((_) { - for (Note note in _selectedNotes) { - note.starred = false; - LocalStorage().syncNoteAttr(note, 'starred'); - } - _selectedNotes.clear(); - trigger(); - }); - - colorAllSelectedNotes.listen((Color color) { - for (Note note in _selectedNotes) { - note.color = color; - LocalStorage().syncNoteAttr(note, 'color'); - } - _selectedNotes.clear(); - trigger(); - }); + void searchNotes(String s) { + _search = s; + notifyListeners(); + } - restoreNotes.listen((_notes) { - for (Note note in _notes) { - LocalStorage().restoreNote(note); + void triggerSelectNote(Note note) { + if (_selectedNotes.contains(note)) { + _selectedNotes.remove(note); + } else { + _selectedNotes.add(note); + } + notifyListeners(); + } + + Future removeAllSelectedNotes() async { + for (final note in _selectedNotes) { + for (final audio in note.audioFiles) { + FileManager().delete(audio); } - trigger(); - }); - - clearSelection.listen((_) { - _selectedNotes.clear(); - trigger(); - }); - - updateView.listen((_) { - // only update the view, data is stored in file_manager - trigger(); - }); + await LocalStorage().deleteNote(note); + } + _selectedNotes.clear(); + notifyListeners(); } - bool _isSearchValid(Note note) { - if (_search != null) { - var search = _search.toLowerCase(); - if (note.label != null && note.label.toLowerCase().contains(search)) - return true; - if (note.capo != null && - note.capo.toString().toLowerCase().contains(search)) return true; - if (note.title != null && note.title.toLowerCase().contains(search)) - return true; - - if (note.artist != null && note.artist.toLowerCase().contains(search)) - return true; - - if (note.tuning != null && note.tuning.toLowerCase().contains(search)) - return true; - if (note.sections.any((s) => - s.content.toLowerCase().contains(search) || - s.title.toLowerCase().contains(search))) return true; + Future discardAllSelectedNotes() async { + for (final note in _selectedNotes) { + await LocalStorage().discardNote(note); } - return false; + _selectedNotes.clear(); + notifyListeners(); + } + + Future starAllSelectedNotes() async { + for (final note in _selectedNotes) { + note.starred = true; + await LocalStorage().syncNoteAttr(note, 'starred'); + } + _selectedNotes.clear(); + notifyListeners(); + } + + Future unstarAllSelectedNotes() async { + for (final note in _selectedNotes) { + note.starred = false; + await LocalStorage().syncNoteAttr(note, 'starred'); + } + _selectedNotes.clear(); + notifyListeners(); + } + + Future colorAllSelectedNotes(Color color) async { + for (final note in _selectedNotes) { + note.color = color; + await LocalStorage().syncNoteAttr(note, 'color'); + } + _selectedNotes.clear(); + notifyListeners(); + } + + Future restoreNotes(List notes) async { + for (final note in notes) { + await LocalStorage().restoreNote(note); + } + notifyListeners(); + } + + void clearSelection() { + _selectedNotes.clear(); + notifyListeners(); + } + + void updateView() { + notifyListeners(); + } + + bool _isSearchValid(Note note) { + var search = _search.toLowerCase(); + if ((note.label ?? '').toLowerCase().contains(search)) + return true; + if (note.capo.toString().toLowerCase().contains(search)) return true; + if ((note.title).toLowerCase().contains(search)) + return true; + + if ((note.artist ?? '').toLowerCase().contains(search)) + return true; + + if ((note.tuning ?? '').toLowerCase().contains(search)) + return true; + if (note.sections.any((s) => + s.content.toLowerCase().contains(search) || + s.title.toLowerCase().contains(search))) return true; + return false; } Map> _getFiltersByCategory() { - Map> m = Map(); + final m = >{}; for (Filter f in _filters) { - if (m.keys.contains(f.by)) { - m[f.by].add(f); - } else { - m[f.by] = [f]; - } + m.putIfAbsent(f.by, () => []).add(f); } return m; } @@ -328,10 +311,10 @@ class StaticStorage extends Store { } List get filteredNotes => DB().notes.where((Note note) { - if (_filters.length == 0 && (_search == null || _search == "")) + if (_filters.length == 0 && (_search == "")) return true; - if (_search != null && search != "") { + if (search != "") { if (_filters.length == 0) { return _isSearchValid(note); } else { @@ -343,26 +326,27 @@ class StaticStorage extends Store { }).toList(); } -Action> setNotes = Action(); -Action addNote = Action(); -Action addFilter = Action(); -Action removeFilter = Action(); - -Action searchNotes = Action(); -Action toggleShowMore = Action(); -Action toggleChangeView = Action(); -Action openSettings = Action(); -//Action setUser = Action(); - -Action triggerSelectNote = Action(); -Action removeAllSelectedNotes = Action(); -Action discardAllSelectedNotes = Action(); -Action starAllSelectedNotes = Action(); -Action unstarAllSelectedNotes = Action(); -Action colorAllSelectedNotes = Action(); - -Action> restoreNotes = Action(); -Action clearSelection = Action(); -Action updateView = Action(); - -StoreToken storageToken = StoreToken(StaticStorage()); +final StaticStorage storageStore = StaticStorage(); + +void setNotes(List notes) => DB().setNotes(notes); +void addNote(Note note) => storageStore.addNote(note); +void addFilter(Filter filter) => storageStore.addFilter(filter); +void removeFilter(Filter filter) => storageStore.removeFilter(filter); +void searchNotes(String search) => storageStore.searchNotes(search); +void toggleShowMore(FilterBy by) => storageStore.toggleShowMore(by); +void toggleChangeView([dynamic _]) => storageStore.toggleChangeView(); +void openSettings([dynamic _]) {} +void triggerSelectNote(Note note) => storageStore.triggerSelectNote(note); +Future removeAllSelectedNotes([dynamic _]) => + storageStore.removeAllSelectedNotes(); +Future discardAllSelectedNotes([dynamic _]) => + storageStore.discardAllSelectedNotes(); +Future starAllSelectedNotes([dynamic _]) => + storageStore.starAllSelectedNotes(); +Future unstarAllSelectedNotes([dynamic _]) => + storageStore.unstarAllSelectedNotes(); +Future colorAllSelectedNotes(Color color) => + storageStore.colorAllSelectedNotes(color); +Future restoreNotes(List notes) => storageStore.restoreNotes(notes); +void clearSelection([dynamic _]) => storageStore.clearSelection(); +void updateView([dynamic _]) => storageStore.updateView(); diff --git a/lib/sync_conflicts_page.dart b/lib/sync_conflicts_page.dart new file mode 100644 index 0000000..50b896e --- /dev/null +++ b/lib/sync_conflicts_page.dart @@ -0,0 +1,135 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'model.dart'; +import 'sync_status_store.dart'; + +class SyncConflictsPage extends StatefulWidget { + const SyncConflictsPage({super.key}); + + @override + State createState() => _SyncConflictsPageState(); +} + +class _SyncConflictsPageState extends State { + @override + void initState() { + super.initState(); + syncStatusStore.refresh(); + } + + String _prettyJson(String rawJson) { + try { + final decoded = jsonDecode(rawJson); + return const JsonEncoder.withIndent(' ').convert(decoded); + } catch (_) { + return rawJson; + } + } + + Widget _statusHeader(SyncStatusStore store) { + final queueText = store.queuedChanges == 1 + ? '1 queued change' + : '${store.queuedChanges} queued changes'; + final conflictText = store.unresolvedConflicts == 1 + ? '1 unresolved conflict' + : '${store.unresolvedConflicts} unresolved conflicts'; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Row( + children: [ + const Icon(Icons.sync_problem_outlined), + const SizedBox(width: 8), + Text('$queueText, $conflictText'), + ], + ), + ); + } + + Widget _conflictTile(SyncConflict conflict, SyncStatusStore store) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${conflict.entityType.name} ${conflict.operation.name}', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + conflict.reason, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 6), + Text( + 'Entity: ${conflict.entityId}', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 10), + ExpansionTile( + tilePadding: EdgeInsets.zero, + title: const Text('Queued payload'), + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + color: Theme.of(context).cardColor, + child: SelectableText(_prettyJson(conflict.localPayload)), + ), + ], + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: ElevatedButton.icon( + onPressed: () => store.resolveConflict(conflict.id), + icon: const Icon(Icons.check), + label: const Text('Mark resolved'), + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final store = context.watch(); + final conflicts = store.conflicts; + + return Scaffold( + appBar: AppBar( + title: const Text('Sync Conflicts'), + actions: [ + if (conflicts.isNotEmpty) + IconButton( + icon: const Icon(Icons.done_all), + tooltip: 'Resolve all', + onPressed: () => store.resolveAllConflicts(), + ), + IconButton(icon: const Icon(Icons.refresh), onPressed: store.refresh), + ], + ), + body: Column( + children: [ + _statusHeader(store), + Expanded( + child: conflicts.isEmpty + ? const Center(child: Text('No unresolved conflicts')) + : ListView.builder( + itemCount: conflicts.length, + itemBuilder: (context, index) => + _conflictTile(conflicts[index], store), + ), + ), + ], + ), + ); + } +} diff --git a/lib/sync_debug_panel.dart b/lib/sync_debug_panel.dart new file mode 100644 index 0000000..4c79829 --- /dev/null +++ b/lib/sync_debug_panel.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'local_storage.dart'; +import 'sync_debug_store.dart'; +import 'sync_status_store.dart'; + +class SyncDebugPanel extends StatefulWidget { + const SyncDebugPanel({super.key}); + + @override + State createState() => _SyncDebugPanelState(); +} + +class _SyncDebugPanelState extends State { + String _backendUrl = ''; + bool _syncEnabled = true; + + @override + void initState() { + super.initState(); + _loadConfig(); + } + + Future _loadConfig() async { + final url = await LocalStorage().getSyncBackendUrl(); + final enabled = await LocalStorage().getSyncEnabled(); + if (!mounted) return; + setState(() { + _backendUrl = url; + _syncEnabled = enabled; + }); + } + + String _fmt(DateTime? dt) { + if (dt == null) return '-'; + return dt.toIso8601String(); + } + + Widget _row(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + SizedBox(width: 170, child: Text(label)), + Expanded( + child: Text( + value, + style: const TextStyle(fontFamily: 'monospace'), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final debugStore = context.watch(); + final syncStatus = context.watch(); + + return Scaffold( + appBar: AppBar( + title: const Text('Sync Debug'), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text('Runtime', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + _row('Sync enabled', _syncEnabled ? 'true' : 'false'), + _row('Backend URL', _backendUrl), + _row('Currently syncing', debugStore.isSyncing ? 'true' : 'false'), + _row('Queued changes', '${syncStatus.queuedChanges}'), + _row('Unresolved conflicts', '${syncStatus.unresolvedConflicts}'), + _row('Last upload count', '${debugStore.lastUploadCount}'), + _row('Last pull count', '${debugStore.lastPullCount}'), + _row('Last attempt', _fmt(debugStore.lastAttemptAt)), + _row('Last success', _fmt(debugStore.lastSuccessAt)), + _row('Last error', debugStore.lastError ?? '-'), + const SizedBox(height: 16), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + ElevatedButton.icon( + onPressed: () async { + await debugStore.syncNow(); + await syncStatus.refresh(); + await _loadConfig(); + }, + icon: const Icon(Icons.sync), + label: const Text('Sync now'), + ), + OutlinedButton.icon( + onPressed: () async { + await syncStatus.refresh(); + await _loadConfig(); + }, + icon: const Icon(Icons.refresh), + label: const Text('Refresh panel'), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/sync_debug_store.dart b/lib/sync_debug_store.dart new file mode 100644 index 0000000..130300d --- /dev/null +++ b/lib/sync_debug_store.dart @@ -0,0 +1,50 @@ +import 'package:flutter/foundation.dart'; + +class SyncDebugStore extends ChangeNotifier { + DateTime? _lastAttemptAt; + DateTime? _lastSuccessAt; + String? _lastError; + bool _isSyncing = false; + int _lastUploadCount = 0; + int _lastPullCount = 0; + Future Function()? _syncTrigger; + + DateTime? get lastAttemptAt => _lastAttemptAt; + DateTime? get lastSuccessAt => _lastSuccessAt; + String? get lastError => _lastError; + bool get isSyncing => _isSyncing; + int get lastUploadCount => _lastUploadCount; + int get lastPullCount => _lastPullCount; + + void setSyncTrigger(Future Function() trigger) { + _syncTrigger = trigger; + } + + void markStarted() { + _isSyncing = true; + _lastAttemptAt = DateTime.now(); + notifyListeners(); + } + + void markSucceeded({required int uploaded, required int pulled}) { + _isSyncing = false; + _lastSuccessAt = DateTime.now(); + _lastError = null; + _lastUploadCount = uploaded; + _lastPullCount = pulled; + notifyListeners(); + } + + void markFailed(String error) { + _isSyncing = false; + _lastError = error; + notifyListeners(); + } + + Future syncNow() async { + if (_syncTrigger == null) return; + await _syncTrigger!.call(); + } +} + +final SyncDebugStore syncDebugStore = SyncDebugStore(); diff --git a/lib/sync_engine.dart b/lib/sync_engine.dart new file mode 100644 index 0000000..12b839d --- /dev/null +++ b/lib/sync_engine.dart @@ -0,0 +1,224 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'local_storage.dart'; +import 'model.dart'; +import 'sync_debug_store.dart'; +import 'sync_network.dart'; + +class SyncEngine { + Timer? _timer; + bool _isSyncing = false; + + Future start() async { + syncDebugStore.setSyncTrigger(runOnce); + await runOnce(); + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 6), (_) async { + await runOnce(); + }); + } + + Future runOnce() async { + if (_isSyncing) return; + _isSyncing = true; + syncDebugStore.markStarted(); + int uploaded = 0; + int pulled = 0; + try { + final enabled = await LocalStorage().getSyncEnabled(); + if (!enabled) { + syncDebugStore.markSucceeded(uploaded: 0, pulled: 0); + return; + } + + final baseUrl = await _resolvedBackendUrl(); + uploaded = await _uploadQueued(baseUrl); + pulled = await _pullChanges(baseUrl); + syncDebugStore.markSucceeded(uploaded: uploaded, pulled: pulled); + } catch (e) { + syncDebugStore.markFailed(e.toString()); + // Keep sync loop alive; queue items stay queued for next attempt. + } finally { + _isSyncing = false; + } + } + + Future _resolvedBackendUrl() async { + final configured = await LocalStorage().getSyncBackendUrl(); + Uri uri; + try { + uri = Uri.parse(configured); + } catch (_) { + throw Exception('Invalid sync backend URL: $configured'); + } + + if (uri.host != '0.0.0.0') { + return configured; + } + + // 0.0.0.0 is a bind address, not a client destination. + if (Platform.isAndroid || Platform.isIOS) { + throw Exception( + 'Sync backend URL uses 0.0.0.0. On mobile use your desktop LAN IP like http://192.168.x.x:8009', + ); + } + + final loopback = uri.replace(host: '127.0.0.1').toString(); + final lanIp = await SyncNetwork.firstPrivateIpv4(); + final resolved = + lanIp == null ? loopback : uri.replace(host: lanIp).toString(); + await LocalStorage().setSyncBackendUrl(resolved); + return resolved; + } + + Future stop() async { + _timer?.cancel(); + _timer = null; + } + + Future _uploadQueued(String baseUrl) async { + final queued = await LocalStorage().getQueuedSyncChanges(); + if (queued.isEmpty) return 0; + final source = await _clientSource(); + + final mutations = queued + .map( + (item) => { + 'op_id': item.id, + 'entity_type': item.entityType.name, + 'entity_id': item.entityId, + 'operation': item.operation.name, + 'base_version': item.baseVersion, + 'payload': jsonDecode(item.payload), + 'client_ts': serializeDateTime(item.createdAt), + 'source': source, + }, + ) + .toList(); + + final response = await _postJson( + '$baseUrl/v1/sync/upload', + {'mutations': mutations}, + ); + final results = (response['results'] as List? ?? []) + .map((e) => Map.from(e as Map)) + .toList(); + + for (final result in results) { + final opId = result['op_id']?.toString() ?? ''; + final status = result['status']?.toString() ?? ''; + final entityTypeRaw = result['entity_type']?.toString() ?? ''; + final entityId = result['entity_id']?.toString() ?? ''; + final serverVersion = (result['server_version'] as num?)?.toInt(); + + if (status == 'applied' || status == 'merged' || status == 'duplicate') { + await LocalStorage().markQueueItemSynced( + opId, + newServerVersion: serverVersion, + entityType: deserializeSyncEntityType(entityTypeRaw), + entityId: entityId, + ); + } else if (status == 'rejected') { + final message = + result['message']?.toString() ?? 'Mutation rejected by backend'; + final remotePayload = + (result['remote_payload'] as Map?)?.cast(); + await LocalStorage().markQueueItemRejected( + queueId: opId, + reason: message, + remotePayload: remotePayload, + ); + } + } + return results.length; + } + + Future _pullChanges(String baseUrl) async { + final sinceSeq = await LocalStorage().getSyncPullCursor(); + + final response = + await _getJson('$baseUrl/v1/sync/pull?since_seq=$sinceSeq'); + final changes = (response['changes'] as List? ?? []) + .map((e) => Map.from(e as Map)) + .toList(); + + for (final change in changes) { + final entityType = deserializeSyncEntityType( + change['entity_type']?.toString() ?? '', + ); + final entityId = change['entity_id']?.toString() ?? ''; + final operation = deserializeSyncOperationType( + change['operation']?.toString() ?? '', + ); + final version = (change['version'] as num?)?.toInt() ?? 0; + final payload = + (change['payload'] as Map?)?.cast() ?? {}; + await LocalStorage().applyRemoteChange( + entityType: entityType, + entityId: entityId, + operation: operation, + payload: payload, + serverVersion: version, + ); + } + final nextSeq = (response['next_seq'] as num?)?.toInt() ?? sinceSeq; + if (nextSeq > sinceSeq) { + await LocalStorage().setSyncPullCursor(nextSeq); + } + return changes.length; + } + + Future> _postJson( + String url, + Map body, + ) async { + final client = HttpClient(); + try { + final request = await client.postUrl(Uri.parse(url)); + request.headers.contentType = ContentType.json; + request.write(jsonEncode(body)); + final response = await request.close(); + final payload = await utf8.decoder.bind(response).join(); + if (response.statusCode >= 400) { + throw HttpException('Sync upload failed (${response.statusCode})'); + } + return jsonDecode(payload) as Map; + } finally { + client.close(force: true); + } + } + + Future> _getJson(String url) async { + final client = HttpClient(); + try { + final request = await client.getUrl(Uri.parse(url)); + final response = await request.close(); + final payload = await utf8.decoder.bind(response).join(); + if (response.statusCode >= 400) { + throw HttpException('Sync pull failed (${response.statusCode})'); + } + return jsonDecode(payload) as Map; + } finally { + client.close(force: true); + } + } + + Future _clientSource() async { + final settings = await LocalStorage().getSettings(); + final userName = (settings.name ?? '').trim(); + String appLabel; + if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { + appLabel = 'desktop-${Platform.operatingSystem}'; + } else if (Platform.isAndroid || Platform.isIOS) { + appLabel = 'mobile-${Platform.operatingSystem}'; + } else { + appLabel = Platform.operatingSystem; + } + if (userName.isEmpty) return appLabel; + return '$appLabel:$userName'; + } +} + +final SyncEngine syncEngine = SyncEngine(); diff --git a/lib/sync_network.dart b/lib/sync_network.dart new file mode 100644 index 0000000..f6b580d --- /dev/null +++ b/lib/sync_network.dart @@ -0,0 +1,28 @@ +import 'dart:io'; + +class SyncNetwork { + static Future firstPrivateIpv4() async { + final interfaces = await NetworkInterface.list( + includeLoopback: false, + type: InternetAddressType.IPv4, + ); + for (final iface in interfaces) { + for (final addr in iface.addresses) { + final ip = addr.address; + if (_isPrivateIpv4(ip)) return ip; + } + } + return null; + } + + static bool _isPrivateIpv4(String ip) { + final parts = ip.split('.'); + if (parts.length != 4) return false; + final a = int.tryParse(parts[0]) ?? -1; + final b = int.tryParse(parts[1]) ?? -1; + if (a == 10) return true; + if (a == 172 && b >= 16 && b <= 31) return true; + if (a == 192 && b == 168) return true; + return false; + } +} diff --git a/lib/sync_status_store.dart b/lib/sync_status_store.dart new file mode 100644 index 0000000..4f7459c --- /dev/null +++ b/lib/sync_status_store.dart @@ -0,0 +1,59 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'local_storage.dart'; +import 'model.dart'; + +class SyncStatusStore extends ChangeNotifier { + SyncStatusSummary _summary = const SyncStatusSummary( + queuedChanges: 0, + unresolvedConflicts: 0, + ); + List _conflicts = []; + StreamSubscription? _subscription; + bool _started = false; + + SyncStatusSummary get summary => _summary; + int get queuedChanges => _summary.queuedChanges; + int get unresolvedConflicts => _summary.unresolvedConflicts; + bool get isFullySynced => _summary.isFullySynced; + List get conflicts => _conflicts; + + Future start() async { + if (_started) return; + _started = true; + + _subscription = LocalStorage().syncStatusStream.listen((summary) { + _summary = summary; + notifyListeners(); + }); + + await refresh(); + } + + Future refresh() async { + _summary = await LocalStorage().getSyncStatusSummary(); + _conflicts = await LocalStorage().getSyncConflicts(unresolvedOnly: true); + notifyListeners(); + } + + Future resolveConflict(String conflictId) async { + await LocalStorage().resolveSyncConflict(conflictId); + await refresh(); + } + + Future resolveAllConflicts() async { + final count = await LocalStorage().resolveAllSyncConflicts(); + await refresh(); + return count; + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } +} + +final SyncStatusStore syncStatusStore = SyncStatusStore(); diff --git a/lib/trash.dart b/lib/trash.dart index 7c9e5d3..e0e973a 100644 --- a/lib/trash.dart +++ b/lib/trash.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:sound/dialogs/confirmation_dialogs.dart'; import 'package:sound/local_storage.dart'; import 'package:sound/model.dart'; import 'package:sound/note_list.dart'; @@ -6,9 +7,9 @@ import 'package:sound/note_viewer.dart'; import 'package:sound/storage.dart'; class Trash extends StatefulWidget { - final Function onMenuPressed; + final VoidCallback onMenuPressed; - Trash(this.onMenuPressed, {Key key}) : super(key: key); + const Trash(this.onMenuPressed, {super.key}); @override State createState() { @@ -55,19 +56,25 @@ class _TrashState extends State { _runPopupAction(String action) { print("action: $action"); if (action == "delete") { - for (Note note in selectedNotes) { - LocalStorage().deleteNote(note); - } - setState(() { - notes.removeWhere((n) => isSelected(n)); - }); + // TODO: + showDeleteNotesForeverDialog( + context: context, + notes: selectedNotes, + onDelete: () { + setState(() { + notes.removeWhere((n) => isSelected(n)); + selectedNotes = []; + }); + }); } else if (action == 'delete_all') { - for (Note note in notes) { - LocalStorage().deleteNote(note); - } - setState(() { - notes = []; - }); + showDeleteNotesForeverDialog( + context: context, + notes: notes, + onDelete: () { + setState(() { + notes = []; + }); + }); } } @@ -139,12 +146,16 @@ class _TrashState extends State { } _deleteForever(Note note) { - LocalStorage().deleteNote(note); - - setState(() { - notes.removeWhere((n) => n.id == note.id); - }); - Navigator.of(context).pop(); + showDeleteForeverDialog( + context: context, + note: note, + onDelete: () { + setState(() { + notes.removeWhere((n) => n.id == note.id); + }); + + Navigator.of(context).pop(); + }); } onTap(Note note) { @@ -153,7 +164,7 @@ class _TrashState extends State { } else { Navigator.push( context, - new MaterialPageRoute( + MaterialPageRoute( builder: (context) => NoteViewer( note, actions: [ @@ -166,6 +177,7 @@ class _TrashState extends State { ) ], showZoomPlayback: false, + showAudioFiles: true, ))); } } diff --git a/lib/ultimate.dart b/lib/ultimate.dart index d40a051..e6306f5 100644 --- a/lib/ultimate.dart +++ b/lib/ultimate.dart @@ -1,8 +1,4 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:sound/model.dart'; /** import 'package:html/parser.dart' show parse; diff --git a/lib/utils.dart b/lib/utils.dart index 86c31c6..8a4269e 100644 --- a/lib/utils.dart +++ b/lib/utils.dart @@ -1,24 +1,35 @@ import 'package:flutter/material.dart'; const defaultDuration = Duration(seconds: 2); -showUndoSnackbar(ScaffoldState state, String dataString, dynamic data, + +ScaffoldMessengerState? _messengerFor(dynamic state) { + if (state == null) return null; + if (state is ScaffoldMessengerState) return state; + if (state is ScaffoldState) return ScaffoldMessenger.of(state.context); + if (state is BuildContext) return ScaffoldMessenger.of(state); + return null; +} + +void showUndoSnackbar(dynamic state, String dataString, dynamic data, ValueChanged onUndo) { - var snackbar = SnackBar( + final snackbar = SnackBar( content: Text("Deleted $dataString sucessfully"), duration: Duration(seconds: 3), action: SnackBarAction(label: "Undo", onPressed: () => onUndo(data))); - state.showSnackBar(snackbar); + _messengerFor(state)?.showSnackBar(snackbar); } -showSnack(var state, String message, {Duration duration = defaultDuration}) { - var snackbar = SnackBar(content: Text(message), duration: duration); +void showSnack(dynamic state, String message, + {Duration duration = defaultDuration}) { + final snackbar = SnackBar(content: Text(message), duration: duration); - state.showSnackBar(snackbar); + _messengerFor(state)?.showSnackBar(snackbar); } Color getSelectedCardColor(BuildContext context) { - return Theme.of(context).textTheme.bodyText1.color.withOpacity(0.4); + return (Theme.of(context).textTheme.bodyMedium?.color ?? Colors.black) + .withValues(alpha: 0.4); } BoxDecoration getSelectedDecoration(BuildContext context) { diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..e18abaf --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,24 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import audioplayers_darwin +import file_picker +import path_provider_foundation +import record_macos +import share_plus +import shared_preferences_foundation +import sqflite_darwin + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) +} diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..c26af22 --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,62 @@ +PODS: + - audioplayers_darwin (0.0.1): + - Flutter + - FlutterMacOS + - file_picker (0.0.1): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - record_macos (1.2.0): + - FlutterMacOS + - share_plus (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - audioplayers_darwin (from `Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin`) + - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`) + - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) + +EXTERNAL SOURCES: + audioplayers_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin + file_picker: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos + FlutterMacOS: + :path: Flutter/ephemeral + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + record_macos: + :path: Flutter/ephemeral/.symlinks/plugins/record_macos/macos + share_plus: + :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + sqflite_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin + +SPEC CHECKSUMS: + audioplayers_darwin: 4027b33a8f471d996c13f71cb77f0b1583b5d923 + file_picker: e716a70a9fe5fd9e09ebc922d7541464289443af + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba + record_macos: 4680f37daeebf52162e715efb7ff682f0a5ce4fd + share_plus: 1fa619de8392a4398bfaf176d441853922614e89 + shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.15.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..f80d168 --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,801 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 6CD114C3A2CD9B5E067BC84A /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F55C7DAC252543D15EF87F75 /* Pods_Runner.framework */; }; + EA4CC2CD51A09AB27F893F6D /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 174FA5BB45A5DB96CE3D9FA5 /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 085B23CD291D1769AF4908CF /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 174FA5BB45A5DB96CE3D9FA5 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 205830810A775A4458305FCC /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* sound.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = sound.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 66F7124D9C16077ACF19C1A4 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 98BCA275C43EBDCBC2673E90 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + AFB2576FE0265E66D61ABDBE /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + E58B0CCEA8BF88E4E13E2D74 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + F55C7DAC252543D15EF87F75 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + EA4CC2CD51A09AB27F893F6D /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 6CD114C3A2CD9B5E067BC84A /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + A465C8E8DEB1088168BFB87E /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* sound.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + A465C8E8DEB1088168BFB87E /* Pods */ = { + isa = PBXGroup; + children = ( + 66F7124D9C16077ACF19C1A4 /* Pods-Runner.debug.xcconfig */, + 085B23CD291D1769AF4908CF /* Pods-Runner.release.xcconfig */, + AFB2576FE0265E66D61ABDBE /* Pods-Runner.profile.xcconfig */, + 98BCA275C43EBDCBC2673E90 /* Pods-RunnerTests.debug.xcconfig */, + 205830810A775A4458305FCC /* Pods-RunnerTests.release.xcconfig */, + E58B0CCEA8BF88E4E13E2D74 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + F55C7DAC252543D15EF87F75 /* Pods_Runner.framework */, + 174FA5BB45A5DB96CE3D9FA5 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + D1A9605CFB5DD6C916A6BD7E /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + D1FEE4341BFEB29FFEF13802 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 131A3D744E455864D02F77D8 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* sound.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 131A3D744E455864D02F77D8 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + D1A9605CFB5DD6C916A6BD7E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + D1FEE4341BFEB29FFEF13802 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 98BCA275C43EBDCBC2673E90 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.onenightproductions.sound.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/sound.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/sound"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 205830810A775A4458305FCC /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.onenightproductions.sound.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/sound.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/sound"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E58B0CCEA8BF88E4E13E2D74 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.onenightproductions.sound.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/sound.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/sound"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e90758b --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..c963acf --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = sound + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = de.onenightproductions.sound + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 de.onenightproductions. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..6b346ea --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.device.audio-input + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.network.client + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..3556f7e --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSMicrophoneUsageDescription + SketChord needs microphone access to record audio clips for your notes. + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..afafa97 --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.device.audio-input + + com.apple.security.network.client + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/pubspec.lock b/pubspec.lock index 7973518..b3d05da 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,172 +1,259 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + another_flushbar: + dependency: "direct main" + description: + name: another_flushbar + sha256: "2b99671c010a7d5770acf5cb24c9f508b919c3a7948b6af9646e773e7da7b757" + url: "https://pub.dev" + source: hosted + version: "1.12.32" archive: dependency: "direct main" description: name: archive - url: "https://pub.dartlang.org" + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" source: hosted - version: "2.0.13" + version: "4.0.9" args: dependency: transitive description: name: args - url: "https://pub.dartlang.org" + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" source: hosted - version: "1.6.0" + version: "2.7.0" async: dependency: transitive description: name: async - url: "https://pub.dartlang.org" + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.13.0" audioplayers: dependency: "direct main" description: name: audioplayers - url: "https://pub.dartlang.org" + sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4" + url: "https://pub.dev" + source: hosted + version: "6.5.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333" + url: "https://pub.dev" source: hosted - version: "0.13.7" + version: "6.3.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.dev" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7" + url: "https://pub.dev" + source: hosted + version: "4.2.1" barcode: dependency: transitive description: name: barcode - url: "https://pub.dartlang.org" + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "2.0.13" boolean_selector: dependency: transitive description: name: boolean_selector - url: "https://pub.dartlang.org" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.2" characters: dependency: transitive description: name: characters - url: "https://pub.dartlang.org" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" source: hosted - version: "1.0.0" - charcode: + version: "1.4.0" + checked_yaml: dependency: transitive description: - name: charcode - url: "https://pub.dartlang.org" + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" source: hosted - version: "1.1.3" - clipboard_manager: - dependency: "direct main" + version: "2.0.4" + cli_util: + dependency: transitive description: - name: clipboard_manager - url: "https://pub.dartlang.org" + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" source: hosted - version: "0.0.4" + version: "0.4.2" clock: dependency: transitive description: name: clock - url: "https://pub.dartlang.org" + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.1.2" collection: dependency: transitive description: name: collection - url: "https://pub.dartlang.org" + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" source: hosted - version: "1.14.13" - convert: + version: "1.19.1" + cross_file: dependency: transitive description: - name: convert - url: "https://pub.dartlang.org" + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "0.3.5+2" crypto: dependency: transitive description: name: crypto - url: "https://pub.dartlang.org" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "3.0.7" cupertino_icons: dependency: "direct main" description: name: cupertino_icons - url: "https://pub.dartlang.org" + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" source: hosted - version: "0.1.3" + version: "0.7.12" fake_async: dependency: transitive description: name: fake_async - url: "https://pub.dartlang.org" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" file: dependency: transitive description: name: file - url: "https://pub.dartlang.org" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" source: hosted - version: "5.2.1" + version: "7.0.1" file_picker: dependency: "direct main" description: name: file_picker - url: "https://pub.dartlang.org" + sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" + url: "https://pub.dev" source: hosted - version: "1.13.3" - file_picker_platform_interface: + version: "10.3.10" + fixnum: dependency: transitive description: - name: file_picker_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" - flushbar: - dependency: "direct main" - description: - name: flushbar - url: "https://pub.dartlang.org" + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" source: hosted - version: "1.10.4" + version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" - flutter_audio_recorder: - dependency: "direct main" + flutter_launcher_icons: + dependency: "direct dev" description: - name: flutter_audio_recorder - url: "https://pub.dartlang.org" + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" source: hosted - version: "0.5.5" - flutter_flux: - dependency: "direct main" + version: "0.14.4" + flutter_lints: + dependency: "direct dev" description: - name: flutter_flux - url: "https://pub.dartlang.org" + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" source: hosted - version: "4.1.3" + version: "5.0.0" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.8" - flutter_share: - dependency: "direct main" - description: - name: flutter_share - url: "https://pub.dartlang.org" + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" source: hosted - version: "1.0.2+1" + version: "2.0.33" flutter_test: dependency: "direct dev" description: flutter @@ -177,277 +264,643 @@ packages: description: flutter source: sdk version: "0.0.0" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" image: dependency: transitive description: name: image - url: "https://pub.dartlang.org" + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + url: "https://pub.dev" + source: hosted + version: "4.5.4" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + url: "https://pub.dev" source: hosted - version: "2.1.14" - intl: + version: "4.11.0" + leak_tracker: dependency: transitive description: - name: intl - url: "https://pub.dartlang.org" + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" source: hosted - version: "0.16.1" + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" matcher: dependency: transitive description: name: matcher - url: "https://pub.dartlang.org" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" source: hosted - version: "0.12.8" + version: "0.11.1" meta: dependency: transitive description: name: meta - url: "https://pub.dartlang.org" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" source: hosted - version: "1.1.8" + version: "2.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" path: dependency: "direct main" description: name: path - url: "https://pub.dartlang.org" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" source: hosted - version: "1.7.0" + version: "1.9.1" path_parsing: dependency: transitive description: name: path_parsing - url: "https://pub.dartlang.org" + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" source: hosted - version: "0.1.4" + version: "1.1.0" path_provider: dependency: "direct main" description: name: path_provider - url: "https://pub.dartlang.org" + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" source: hosted - version: "1.6.14" - path_provider_linux: + version: "2.1.5" + path_provider_android: dependency: transitive description: - name: path_provider_linux - url: "https://pub.dartlang.org" + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" source: hosted - version: "0.0.1+2" - path_provider_macos: + version: "2.2.22" + path_provider_foundation: dependency: transitive description: - name: path_provider_macos - url: "https://pub.dartlang.org" + name: path_provider_foundation + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + url: "https://pub.dev" source: hosted - version: "0.0.4+3" + version: "2.5.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - url: "https://pub.dartlang.org" + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" pdf: dependency: "direct main" description: name: pdf - url: "https://pub.dartlang.org" + sha256: "28eacad99bffcce2e05bba24e50153890ad0255294f4dd78a17075a2ba5c8416" + url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "3.11.3" permission_handler: dependency: "direct main" description: name: permission_handler - url: "https://pub.dartlang.org" + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + url: "https://pub.dev" + source: hosted + version: "12.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" source: hosted - version: "5.0.1+1" + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface - url: "https://pub.dartlang.org" + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: name: petitparser - url: "https://pub.dartlang.org" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" source: hosted - version: "3.0.4" + version: "7.0.2" platform: dependency: transitive description: name: platform - url: "https://pub.dartlang.org" + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "3.1.6" plugin_platform_interface: dependency: transitive description: name: plugin_platform_interface - url: "https://pub.dartlang.org" + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" source: hosted - version: "1.0.2" - process: + version: "2.1.8" + posix: dependency: transitive description: - name: process - url: "https://pub.dartlang.org" + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" source: hosted - version: "3.0.13" + version: "6.1.5+1" qr: dependency: transitive description: name: qr - url: "https://pub.dartlang.org" + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + receive_sharing_intent: + dependency: "direct main" + description: + name: receive_sharing_intent + sha256: ec76056e4d258ad708e76d85591d933678625318e411564dcb9059048ca3a593 + url: "https://pub.dev" + source: hosted + version: "1.8.1" + record: + dependency: "direct main" + description: + name: record + sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277 + url: "https://pub.dev" + source: hosted + version: "6.2.0" + record_android: + dependency: transitive + description: + name: record_android + sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7 + url: "https://pub.dev" source: hosted version: "1.3.0" - quiver: + record_macos: dependency: transitive description: - name: quiver - url: "https://pub.dartlang.org" + name: record_macos + sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488" + url: "https://pub.dev" source: hosted - version: "2.1.3" - receive_sharing_intent: + version: "1.2.1" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + share_plus: dependency: "direct main" description: - name: receive_sharing_intent - url: "https://pub.dartlang.org" + name: share_plus + sha256: d7dc0630a923883c6328ca31b89aa682bacbf2f8304162d29f7c6aaff03a27a1 + url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "11.1.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + url: "https://pub.dev" + source: hosted + version: "6.1.0" shared_preferences: dependency: "direct main" description: name: shared_preferences - url: "https://pub.dartlang.org" + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" source: hosted - version: "0.5.10" - shared_preferences_linux: + version: "2.5.4" + shared_preferences_android: dependency: transitive description: - name: shared_preferences_linux - url: "https://pub.dartlang.org" + name: shared_preferences_android + sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" + url: "https://pub.dev" source: hosted - version: "0.0.2+2" - shared_preferences_macos: + version: "2.4.21" + shared_preferences_foundation: dependency: transitive description: - name: shared_preferences_macos - url: "https://pub.dartlang.org" + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" source: hosted - version: "0.0.1+10" + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shared_preferences_platform_interface: dependency: transitive description: name: shared_preferences_platform_interface - url: "https://pub.dartlang.org" + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "2.4.1" shared_preferences_web: dependency: transitive description: name: shared_preferences_web - url: "https://pub.dartlang.org" + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" source: hosted - version: "0.1.2+7" + version: "2.4.1" sky_engine: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_span: dependency: transitive description: name: source_span - url: "https://pub.dartlang.org" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + url: "https://pub.dev" + source: hosted + version: "2.4.2+2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" source: hosted - version: "1.7.0" + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: name: stack_trace - url: "https://pub.dartlang.org" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" source: hosted - version: "1.9.5" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - url: "https://pub.dartlang.org" + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.4" string_scanner: dependency: transitive description: name: string_scanner - url: "https://pub.dartlang.org" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" term_glyph: dependency: transitive description: name: term_glyph - url: "https://pub.dartlang.org" + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - url: "https://pub.dartlang.org" + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" source: hosted - version: "0.2.17" + version: "0.7.6" tuple: dependency: "direct main" description: name: tuple - url: "https://pub.dartlang.org" + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 + url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "2.0.2" typed_data: dependency: transitive description: name: typed_data - url: "https://pub.dartlang.org" + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" source: hosted - version: "1.2.0" - utf: + version: "1.4.0" + url_launcher_linux: dependency: transitive description: - name: utf - url: "https://pub.dartlang.org" + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" source: hosted - version: "0.9.0+5" + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: "direct main" description: name: uuid - url: "https://pub.dartlang.org" + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "4.5.3" vector_math: dependency: transitive description: name: vector_math - url: "https://pub.dartlang.org" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" source: hosted - version: "2.0.8" + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: name: xdg_directories - url: "https://pub.dartlang.org" + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "1.1.0" xml: dependency: transitive description: name: xml - url: "https://pub.dartlang.org" + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "3.1.3" sdks: - dart: ">=2.9.0-14.0.dev <3.0.0" - flutter: ">=1.12.13+hotfix.5 <2.0.0" + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 9669caa..0574db5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,35 +18,33 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - sdk: ">=2.7.0 <3.0.0" + sdk: ">=3.3.0 <4.0.0" dependencies: flutter: sdk: flutter - uuid: ^2.0.0 - shared_preferences: ^0.5.10 - tuple: ^1.0.3 + uuid: ^4.5.1 + shared_preferences: ^2.5.3 + tuple: ^2.0.2 + provider: ^6.1.5 - flutter_flux: ^4.1.3 - archive: ^2.0.13 - path_provider: ^1.6.14 - path: ^1.7.0 - permission_handler: ^5.0.1+1 - flutter_share: ^1.0.2+1 - file_picker: ^1.13.3 - audioplayers: ^0.13.2 - flutter_audio_recorder: ^0.5.5 - flushbar: ^1.10.4 - pdf: ^1.11.1 - google_fonts: ^1.1.2 - receive_sharing_intent: ^1.4.1 - clipboard_manager: ^0.0.4 + archive: ^4.0.7 + path_provider: ^2.1.5 + path: ^1.9.1 + permission_handler: ^12.0.1 + share_plus: ^11.1.0 + file_picker: ^10.3.2 + audioplayers: ^6.5.0 + record: ^6.1.2 + another_flushbar: ^1.12.30 + pdf: ^3.11.3 + google_fonts: ^6.3.2 + receive_sharing_intent: ^1.8.1 # html: ^0.14.0+3 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^0.1.3 - flutter_launcher_icons: ^0.8.0 - sqflite: ^1.3.0 + cupertino_icons: ^1.0.8 + sqflite: ^2.4.2 flutter_icons: android: true @@ -56,6 +54,8 @@ flutter_icons: dev_dependencies: flutter_test: sdk: flutter + flutter_launcher_icons: ^0.14.4 + flutter_lints: ^5.0.0 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/widget_test.dart b/test/widget_test.dart index 767b2e8..05e975b 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,30 +1,16 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sound/main.dart'; - void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(App()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); + testWidgets('basic widget smoke test', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Text('SketChord'), + ), + ), + ); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + expect(find.text('SketChord'), findsOneWidget); }); }