diff --git a/.bumpversion.cfg b/.bumpversion.cfg index ca34235..84b9f17 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.2.1+0 +current_version = 0.4.8+0 commit = False tag = False parse = (?P\d+)\.(?P\d+)\.(?P\d+)\+(?P\d+) diff --git a/.gitignore b/.gitignore index 46dd51f..79e7a9c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,11 @@ *.swp .DS_Store .atom/ +.build/ .buildlog/ .history .svn/ +.swiftpm/ migrate_working_dir/ # IntelliJ related diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4476fbd..a29048e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,75 +3,122 @@ stages: - build - - build_flatpak -# - test -.macos_saas_runners: - tags: - - saas-macos-medium-m1 - image: macos-12-xcode-14 - before_script: - - echo "started by ${GITLAB_USER_NAME}" +#.macos_saas_runners: +# tags: +# - saas-macos-medium-m1 +# image: macos-12-xcode-14 +# before_script: +# - echo "started by ${GITLAB_USER_NAME}" -build_macos: - extends: - - .macos_saas_runners - stage: build - script: - - echo "place holder for build" - - sudo softwareupdate --install-rosetta --agree-to-license - - git clone https://gitlab.com/veilid/veilid.git ../veilid +#build_macos: +# extends: +# - .macos_saas_runners +# stage: build +# script: +# - echo "place holder for build" +# - sudo softwareupdate --install-rosetta --agree-to-license +# - git clone https://gitlab.com/veilid/veilid.git ../veilid #- curl –proto ‘=https’ –tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y #- source "$HOME/.cargo/env" #- brew install capnp cmake wabt llvm protobuf openjdk@17 jq cocoapods #- cargo install wasm-bindgen-cli wasm-pack cargo-edit - - wget https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.13.5-stable.zip - - unzip flutter_macos_arm64_3.13.5-stable.zip && export PATH="$PATH:`pwd`/flutter/bin" - - flutter upgrade - - yes | flutter doctor --android-licenses - - flutter config --enable-macos-desktop --enable-ios - - flutter config --no-analytics - - dart --disable-analytics - - flutter doctor -v +# - wget https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.13.5-stable.zip +# - unzip flutter_macos_arm64_3.13.5-stable.zip && export PATH="$PATH:`pwd`/flutter/bin" +# - flutter upgrade +# - yes | flutter doctor --android-licenses +# - flutter config --enable-macos-desktop --enable-ios +# - flutter config --no-analytics +# - dart --disable-analytics +# - flutter doctor -v #- flutter build ipa #- flutter build appbundle - when: manual +# only: +# - schedules build_linux_amd64_bundle: + stage: build tags: - saas-linux-medium-amd64 - image: ghcr.io/cirruslabs/flutter:3.19.4 - stage: build + image: ghcr.io/cirruslabs/flutter:3.29.2 script: - apt-get update - - apt-get install -y --no-install-recommends cmake ninja-build clang build-essential pkg-config libgtk-3-dev liblzma-dev lcov rustc cargo + - apt-get install -y --no-install-recommends cmake ninja-build clang build-essential pkg-config libgtk-3-dev liblzma-dev lcov rustup + - rustup toolchain install 1.81 --profile minimal --no-self-update - flutter config --enable-linux-desktop - git clone https://gitlab.com/veilid/veilid.git ../veilid - flutter build linux artifacts: paths: - build/linux/x64/release/bundle/ - when: manual + rules: + - if: '$CI_COMMIT_TAG =~ /v\d.+/' build_linux_amd64_flatpak: tags: - saas-linux-small-amd64 - image: ubuntu:23.04 - stage: build_flatpak + image: ubuntu:24.04 + stage: build dependencies: [build_linux_amd64_bundle] + needs: + - job: build_linux_amd64_bundle + artifacts: true script: - apt-get update - apt-get install -y --no-install-recommends flatpak flatpak-builder gnupg2 elfutils ca-certificates - flatpak remote-add --no-gpg-verify --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo - - flatpak install -y --noninteractive org.gnome.Sdk/x86_64/45 org.gnome.Platform/x86_64/45 app/org.flathub.flatpak-external-data-checker/x86_64/stable org.freedesktop.appstream-glib + - flatpak install -y --noninteractive org.gnome.Sdk/x86_64/46 org.gnome.Platform/x86_64/46 app/org.flathub.flatpak-external-data-checker/x86_64/stable org.freedesktop.appstream-glib - pushd flatpak/ - flatpak-builder --force-clean build-dir com.veilid.veilidchat.yml --repo=repo - flatpak build-bundle repo com.veilid.veilidchat.flatpak com.veilid.veilidchat - popd artifacts: - paths: + paths: - flatpak/com.veilid.veilidchat.flatpak - when: manual + rules: + - if: '$CI_COMMIT_TAG =~ /v\d.+/' + +build_linux_arm64_bundle: + stage: build + tags: + - saas-linux-small-arm64 + image: ghcr.io/cirruslabs/flutter:3.29.2 + script: + - apt-get update + - apt-get install -y --no-install-recommends cmake ninja-build clang build-essential pkg-config libgtk-3-dev liblzma-dev lcov rustup + - rustup toolchain install 1.81 --profile minimal --no-self-update + - flutter config --enable-linux-desktop + - git clone https://gitlab.com/veilid/veilid.git ../veilid + - flutter build linux + artifacts: + paths: + - build/linux/arm64/release/bundle/ + rules: + - if: '$CI_COMMIT_TAG =~ /v\d.+/' + +build_linux_arm64_flatpak: + tags: + - saas-linux-small-arm64 + image: ubuntu:24.04 + stage: build + dependencies: [build_linux_arm64_bundle] + needs: + - job: build_linux_arm64_bundle + artifacts: true + script: + - apt-get update + - apt-get install -y --no-install-recommends flatpak flatpak-builder gnupg2 elfutils ca-certificates + - flatpak remote-add --no-gpg-verify --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo + - flatpak install -y --noninteractive org.gnome.Sdk/aarch64/46 org.gnome.Platform/aarch64/46 app/org.flathub.flatpak-external-data-checker/aarch64/stable org.freedesktop.appstream-glib + - pushd flatpak/ + - flatpak-builder --force-clean build-dir com.veilid.veilidchat.arm64.yml --repo=repo + - flatpak build-bundle repo com.veilid.veilidchat.flatpak com.veilid.veilidchat + - popd + artifacts: + paths: + - flatpak/com.veilid.veilidchat.flatpak + rules: + - if: '$CI_COMMIT_TAG =~ /v\d.+/' #test: # extends: diff --git a/.gitlab/issue_templates/mytemplate.md b/.gitlab/issue_templates/mytemplate.md new file mode 100644 index 0000000..759ecc2 --- /dev/null +++ b/.gitlab/issue_templates/mytemplate.md @@ -0,0 +1,21 @@ +First, please search through the existing issues on GitLab ***and*** read our [known issues](https://veilid.com/chat/knownissues) page before opening a new issue. + +Please provide the following information to the best of your ability: + +## Platform in use (Apple or Android) + + +## Network type (Wifi or Cell) +### If you know it, what type of NAT? + + +## Paste in relevant logs +1. Long press the signal meter in VeilidChat to open the console logs +2. Switch the logs to debug +3. Make the issue happen again +4. Go back into the logs and hit the copy all button +5. Paste the logs somewhere you can make edits -- remove all IPs (v4 and v6) +6. Paste or attach that redacted log here + + +## Description of the issue \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..21576d9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,97 @@ +## v0.4.8 ## + +- Fix reconciliation `advance()` +- Add `pool stats` command +- Fixed issue with Android 'back' button exiting the app (#331) +- Deprecated accounts no longer crash application at startup +- Simplify SingleContactMessagesCubit and MessageReconciliation +- Ensure first messages get received when opening a new chat +- Update flutter_chat_ui to 2.0.0 +- Accessibility improvements + - Text scaling + - Keyboard shortcuts Ctrl + / Ctrl - to change font size + - Keyboard shortcut Ctrl+Alt+C - to change color scheme + - Keyboard shortcut Ctrl+Alt+B - to change theme brightness + +- _Community Contributions_ + - Refactor README.md for clarity and structure; enhance setup instructions and Flutter installation guidance @iKranium-Labs // @iKranium + +## v0.4.7 ## +- _Community Contributions_ + - Fix getting stuck on splash screen when veilid is already started @bmv437 / @bgrift + - Fix routing to home after initial account creation @bmv437 / @bgrift + - edit_account_form visual improvements @bmv437 / @bgrift + - Flatpak CI Update @sajattack + - Build Arm64 flatpaks @sajattack +- Dependency updates + +## v0.4.6 ## +- Updated veilid-core to v0.4.4 + - See Veilid changelog for specifics +- UI improvements: Theme fixes, wallpaper option added +- Responsiveness improved +- Contacts workflow more consistent +- Safe-area fixes +- Make layout more mobile-friendly +- Improved contact invitation menus +- Deadlock fixes in veilid_support +- _pollWatch was degenerate and only watched first subkey + +## v0.4.5 ## +- Updated veilid-core to v0.4.1 + - See Veilid changelog for specifics +- DHT speed and reliability improvements + +## v0.4.4 ## +- Update beta dialog with expectations page +- Temporarily disable relay selection aggressiveness + +## v0.4.3 ## +- Flutter upgrade to 3.24.0 +- Contacts UI cleanup +- Incorporate symmetric NAT fix from veilid-core + +## v0.4.2 ## +- Dialogs cleanup +- Incremental chat state work +- Exception handling work +- Cancellable waiting page +- Fix DHTRecordCubit open() retry bug +- Log fix for iOS +- Add ability to delete orphaned chats +- Fix deadlock + +## v0.4.1 ## +- Fix creating new accounts +- Switch to non-bounce scroll physics because a lot of views want 'stick to bottom' scroll behavior + +## v0.4.0 ## +- Long conversation support +- Account and consistency update + - Account and identity system upgrades + - Eventual consistency works better now + - Speed improvements + - High speed messaging torture test passes +- Multiple accounts support + - start of recovery key UI + - multiple accounts support + - many bugfixes + - improved watches + - ui improvements +- UI work + - Support away/busy/free state + - Support away/busy/free messages + - Start of UI for auto-away feature (incomplete) +- _Community Contributions Shoutouts_ + - @ethnh + - @sajattack + - @jasikpark + - @kyanha + - @lpmi-13 + - @rivkasegan + - @SalvatoreT + - @hweslin + +## v0.3.0 ## +- Beginning of changelog +- See commits/merges for history prior to v0.3.0 diff --git a/README.md b/README.md index dc02697..c20c08e 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,174 @@ # VeilidChat -VeilidChat is a chat application written for the Veilid (https://www.veilid.com) distributed application platform. It has a familiar and simple interface and is designed for private, and secure person-to-person communications. +## Overview -For more information about VeilidChat: https://veilid.com/chat/ +VeilidChat is a decentralized, secure, and private chat application built upon the [Veilid](https://www.veilid.com) distributed application platform. It offers a familiar messaging interface while leveraging Veilid's underlying end-to-end encrypted and peer-to-peer communication capabilities to ensure privacy and security for person-to-person communications without relying on centralized servers. -For more information about the Veilid network protocol and app development platform: https://veilid.com +For more information about VeilidChat: + +For more information about the Veilid network protocol and app development platform: ## Setup -While this is still in development, you must have a clone of the Veilid source checked out at `../veilid` relative to the working directory of this repository. +### Prerequisites -### For Linux Systems: -``` -./dev-setup/setup_linux.sh +VeilidChat is a Flutter application that interacts with the core Veilid library via Foreign Function Interface (FFI). While this is still in development, you **must** have a clone of the Veilid source checked out at `../veilid` relative to the working directory of this repository for the setup scripts to work. This is because the veilid-core source and build setup (including FFI components), which Veilidchat relies on, reside there. + +```shell +your_workspace/ +├── veilid/ <-- Veilid core repository +└── veilidchat/ <-- This repository ``` -### For Mac Systems: -``` -./dev-setup/setup_macos.sh +Refer to the main [Veilid repository](https://gitlab.com/veilid/veilid) for instructions. + +### Flutter Installation + +VeilidChat requires the Flutter SDK to build and run. Ensure Flutter is installed and available in your system's PATH. Choose one of the methods below: + +**Option 1: Standard Installation (Recommended)** + +Follow the official Flutter documentation for your operating system to install the SDK directly. + +* **Official Flutter Install Guides:** + * [Windows](https://docs.flutter.dev/get-started/install/windows) + * [macOS](https://docs.flutter.dev/get-started/install/macos) + * [Linux](https://docs.flutter.dev/get-started/install/linux) + * [ChromeOS](https://docs.flutter.dev/get-started/install/chromeos) + +**Option 2: Installation via IDE Extension (Beginner-friendly for VS Code)** + +Various IDEs may offer Flutter extensions that can assist with setup. For VS Code, the official Flutter extension can assist you through the SDK installation process. + +1. Open VS Code. +2. Go to the Extensions view (`Ctrl+Shift+X` or `Cmd+Shift+X`). +3. Search for "Flutter" and install the official extension published by Dart Code. +4. Follow the prompts from the extension to install the Flutter SDK. + +**Running Veilid Core Setup Scripts:** + +In order to run the VeilidChat application, you will need the [Veilid repository](https://gitlab.com/veilid/veilid) repository set up correctly as mentioned in [Prerequisites](#prerequisites) above. The veilidchat setup scripts in [`./dev-setup`](./dev-setup) handle building the necessary Veilid components and checking for or installing other required tools like `protoc`. + +**Note:** These scripts require Flutter to be **already installed and accessible in your system's PATH** before they are run. + +To run these setup scripts (from the [`veilidchat`](veilidchat) directory): + +* For Linux Systems: Run [./dev-setup/setup_linux.sh](./dev-setup/setup_linux.sh) (Installs protoc and protoc-gen-dart) +* For Mac Systems: Run [./dev-setup/setup_macos.sh](./dev-setup/setup_macos.sh) (Installs protoc and protoc-gen-dart) +* For Windows Systems: Run [./dev-setup/setup_windows.bat](./dev-setup/setup_windows.bat) (**Requires manual installation of protoc beforehand**) + (check [`./dev-setup`](./dev-setup) for other platforms) + +These scripts will check for required dependencies and set up the environment. For Windows users, please ensure you have manually downloaded and installed the protoc compiler (version 25.3 or higher is recommended) and added its directory to your system's PATH *before* running the setup script. The Windows script will verify its presence. + +**Note on Python Environments:** The dev-setup scripts in the main Veilid repository may utilize Python virtual environments (`venv`) for managing dependencies needed for build processes (like FFI generation scripts). If your system uses a system-managed Python environment or you encounter permission issues, ensure you follow any instructions provided by the setup scripts regarding environment activation or configuration. + +## Verifying Installation + +After installing Flutter and running the ./dev-setup scripts, verify that everything is set up correctly. Open a terminal and run the following command from anywhere accessible by your PATH: + +`$ flutter doctor` + +This command checks your environment and displays a report of the status of your Flutter installation and connected devices. It will list any missing dependencies or configuration issues for common development platforms (Android, iOS, Web, Desktop). + +**Example Output (Partial):** + +```shell + +$ flutter doctor +Doctor summary (to see all details, run flutter doctor -v): +[√] Flutter (Channel stable, 3.x.x, on macOS 13.x.x 22Gxxx darwin-x64, locale en-US) +[√] Android toolchain - develop for Android devices (Android SDK version 3x.x.x) +[!] Xcode - develop for iOS and macOS + ✗ Xcode installation is incomplete. + Install Xcode from the App Store. +[√] Chrome - develop for the web +[√] Linux toolchain - develop for Linux desktop +[√] VS Code (version 1.xx.x) +[√] Connected device (1 available) + +! Doctor found issues in 1 category. + +Address any issues reported by `flutter doctor` before proceeding. ``` -## Updating Code +## Building and Launching -### To update the WASM binary from `veilid-wasm`: -* Debug WASM: run `./dev-setup/wasm_update.sh` -* Release WASM: run `./dev-setup/wasm_update.sh release` +VeilidChat is a Flutter application and can be built and launched on various platforms supported by Flutter, provided you have the necessary SDKs and devices/emulators configured as verified by `flutter doctor`. +1. **Ensure Flutter dependencies are installed**: + From the `veilidchat` directory, run: + + ```bash + flutter pub get + ``` + +2. **List available devices**: + To see which devices (simulators, emulators, connected physical devices, desktop targets, web browsers) are available to run the application on, use the command: + + ```bash + flutter devices + ``` + +3. **Run on a specific device**: + Use the `flutter run` command followed by the `-d` flag and the device ID from the `flutter devices` list. + + * **Example (Android emulator/device):** + Assuming an Android device ID like `emulator-5554`: + + ```bash + flutter run -d emulator-5554 + ``` + + If only one device is connected, you can often omit the `-d` flag. + + * **Example (iOS simulator/device):** + Assuming an iOS simulator ID like `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`: + + ```bash + flutter run -d xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` (replace with actual ID) + ``` + + Or, to target the default iOS simulator: + + ```bash + flutter run -d simulator + ``` + + * **Example (Linux Desktop):** + + ```bash + flutter run -d linux + ``` + + * **Example (macOS Desktop):** + + ```bash + flutter run -d macos + ``` + + * **Example (Windows Desktop):** + + ```bash + flutter run -d windows + ``` + + * **Example (Web):** + + ```bash + flutter run -d web # Or a specific browser like 'chrome' or 'firefox' if listed by `flutter devices` + ``` + + This will typically launch the application in your selected web browser. + +## Updating the WASM Binary + +### To update the WASM binary + +[from the `veilid-wasm` package located in [`../veilid/veilid-wasm`](../veilid/veilid-wasm)] + +From the VeilidChat repository working directory ([`./veilidchat`](./veilidchat)), run the appropriate script: + +* Debug WASM: [`./dev-setup/wasm_update.sh`](./dev-setup/wasm_update.sh) +* Release WASM: [`./dev-setup/wasm_update.sh release`](./dev-setup/wasm_update.sh) + +Refer to the official [Flutter documentation](https://docs.flutter.dev/) for more detailed information on building and deployment with Flutter. \ No newline at end of file diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 0000000..0e60033 --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1 @@ +.cxx diff --git a/android/app/build.gradle b/android/app/build.gradle index f5e4e3d..9d84346 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,11 +12,6 @@ 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' @@ -21,10 +22,6 @@ 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 buildConfig = 'debug' def keystoreProperties = new Properties() @@ -35,16 +32,16 @@ if (keystorePropertiesFile.exists()) { } android { - ndkVersion "26.3.11579264" + ndkVersion "27.0.12077973" compileSdkVersion flutter.compileSdkVersion - + compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = '17' } sourceSets { @@ -70,7 +67,7 @@ android { storePassword keystoreProperties['storePassword'] } } - + buildTypes { release { shrinkResources false @@ -82,7 +79,7 @@ android { } } } - + namespace 'com.veilid.veilidchat' } @@ -90,6 +87,4 @@ flutter { source '../..' } -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" -} +dependencies {} diff --git a/android/build.gradle b/android/build.gradle index dbb249e..bc157bd 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,16 +1,3 @@ -buildscript { - ext.kotlin_version = '1.9.10' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.4.2' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - allprojects { repositories { google() diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index e1ca574..afa1e8e 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 44e62bc..b1ae36a 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.8.0" apply false + id "org.jetbrains.kotlin.android" version "1.9.25" apply false +} + +include ":app" \ No newline at end of file diff --git a/assets/i18n/en.json b/assets/i18n/en.json index d2dcd8c..f3348b5 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -2,37 +2,106 @@ "app": { "title": "VeilidChat" }, - "app_bar": { - "settings_tooltip": "Settings" + "menu": { + "accounts_menu_tooltip": "Accounts Menu", + "settings_tooltip": "Settings", + "contacts_tooltip": "Contacts List", + "new_chat_tooltip": "Start New Chat", + "add_account_tooltip": "Add Account", + "accounts": "Accounts", + "version": "Version" }, - "pager": { - "chats": "Chats", - "contacts": "Contacts" + "splash": { + "beta_title": "VeilidChat is BETA SOFTWARE", + "beta_text": "DO NOT USE THIS FOR ANYTHING IMPORTANT\n\nUntil 1.0 is released:\n\n• You should have no expectations of actual privacy, or guarantees of security.\n• You will likely lose accounts, contacts, and messages and need to recreate them.\n\nTo know what to expect, review our known issues located here:\n\n" }, "account": { "form_name": "Name", - "form_pronouns": "Pronouns (optional)", + "empty_name": "Your name (required)", + "form_pronouns": "Pronouns", + "empty_pronouns": "(optional pronouns)", + "form_about": "About Me", + "empty_about": "Tell your contacts about yourself", + "form_free_message": "Free Message", + "empty_free_message": "Status when availability is 'Free'", + "form_away_message": "Away Message", + "empty_away_message": "Status when availability is 'Away'", + "form_busy_message": "Busy Message", + "empty_busy_message": "Status when availability is 'Busy'", + "form_availability": "Availability", + "form_avatar": "Avatar", + "form_auto_away": "Automatic 'away' detection", + "form_auto_away_timeout": "Auto-away timeout (in minutes)", "form_lock_type": "Lock Type", "lock_type_none": "none", "lock_type_pin": "pin", "lock_type_password": "password" }, "new_account_page": { - "titlebar": "Create a new account", + "titlebar": "Create A New Account", "header": "Account Profile", "create": "Create", "instructions": "This information will be shared with the people you invite to connect with you on VeilidChat.", "error": "Account creation error", + "network_is_offline": "Network is offline, try again when you're connected", "name": "Name", "pronouns": "Pronouns" }, + "edit_account_page": { + "titlebar": "Edit Account", + "header": "Account Profile", + "instructions": "This information will be shared with the people you invite to connect with you on VeilidChat.", + "error": "Account modification error", + "name": "Name", + "pronouns": "Pronouns", + "remove_account": "Remove Account", + "destroy_account": "Destroy Account", + "remove_account_confirm": "Confirm Account Removal", + "remove_account_description": "Remove account from this device only", + "remove_account_confirm_message": " • Your account will be removed from this device ONLY\n • Your identity will remain recoverable with the recovery key\n • Your messages and contacts will remain available on other devices\n", + "destroy_account_confirm": "Confirm Account Destruction", + "destroy_account_description": "Destroy account, removing it completely from all devices everywhere", + "destroy_account_confirm_message": "This action is PERMANENT, and your VeilidChat account will no longer be recoverable with the recovery key. Restoring from backups will not recover your account!", + "destroy_account_confirm_message_details": "You will lose access to:\n • Your entire message history\n • Your contacts\n • This will not remove your messages you have sent from other people's devices\n", + "failed_to_remove_title": "Failed to remove account", + "try_again_network": "Try again when you have a more stable network connection", + "failed_to_destroy_title": "Failed to destroy account", + "account_removed": "Account removed successfully", + "account_destroyed": "Account destroyed successfully" + }, + "show_recovery_key_page": { + "titlebar": "Save Recovery Key", + "recovery_key": "Recovery Key", + "instructions": "Your recovery key is important!", + "instructions_details": "This key is the ONLY way to recover your VeilidChat account in the event of a forgotton password or a lost, stolen, or compromised device.", + "instructions_options": "Here are some options for your recovery key:", + "instructions_print": "Print the recovery key and keep it somewhere safe", + "instructions_view": "View the recovery key and take a screenshot", + "instructions_share": "Share the recovery key to another app to save it", + "print": "Print", + "view": "View", + "share": "Share" + }, + "confirmation": { + "confirm": "Confirm", + "discard_changes": "Discard changes?", + "are_you_sure_discard": "Are you sure you want to discard your changes?", + "are_you_sure": "Are you sure you want to do this?" + }, "button": { "ok": "Ok", "cancel": "Cancel", + "edit": "Edit", "delete": "Delete", "accept": "Accept", "reject": "Reject", - "waiting_for_network": "Waiting For Network" + "finish": "Finish", + "close": "Close", + "yes": "Yes", + "no": "No", + "update": "Update", + "waiting_for_network": "Waiting For Network", + "chat": "Chat" }, "toast": { "error": "Error", @@ -55,35 +124,71 @@ "invalid_account_title": "Invalid Account", "invalid_account_text": "Account is invalid, removing from list" }, - "account_page": { - "contact_invitations": "Contact Invitations" + "contacts_dialog": { + "contacts": "Contacts", + "edit_contact": "Edit Contact", + "invitations": "Invitations", + "no_contact_selected": "Select a contact to view or edit", + "new_chat": "Open Chat", + "close_contact": "Close Contact" + }, + "contact_list": { + "contacts": "Contacts", + "invite_people": "No contacts\n\nPress 'Create Invitation' to invite a contact to VeilidChat", + "search": "Search contacts", + "invitation": "Invitation", + "loading_contacts": "Loading contacts..." + }, + "contact_form": { + "form_name": "Name", + "form_pronouns": "Pronouns", + "form_about": "About", + "form_status": "Status", + "form_nickname": "Nickname", + "form_notes": "Notes", + "form_fingerprint": "Fingerprint", + "form_show_availability": "Show availability" + }, + "availability": { + "unspecified": "Unspecified", + "offline": "Offline", + "always_show_offline": "Always Show Offline", + "free": "Free", + "busy": "Busy", + "away": "Away" }, "add_contact_sheet": { - "new_contact": "New Contact", - "create_invite": "Create Invitation", - "scan_invite": "Scan Invitation", - "paste_invite": "Paste Invitation" + "add_contact": "Add Contact", + "create_invite": "Create\nInvitation", + "scan_invite": "Scan\nInvitation", + "paste_invite": "Paste\nInvitation" }, "add_chat_sheet": { "new_chat": "New Chat" }, "chat": { "start_a_conversation": "Start A Conversation", - "say_something": "Say Something" + "say_something": "Say Something", + "message_too_long": "Message too long" }, "create_invitation_dialog": { "title": "Create Contact Invitation", - "connect_with_me": "Connect with me on VeilidChat!", - "enter_message_hint": "Enter message for contact (optional)", - "message_to_contact": "Message to send with invitation (not encrypted)", + "me": "me", + "recipient_name": "Contact Name", + "recipient_hint": "Enter the recipient's name", + "recipient_helper": "Name of the person you are inviting to chat", + "message_hint": "Enter message for contact (optional)", + "message_label": "Message", + "message_helper": "Message to send with invitation", + "fingerprint": "Fingerprint", + "connect_with_me": "Connect with {name} on VeilidChat!", "generate": "Generate Invitation", - "message": "Message", "unlocked": "Unlocked", "pin": "PIN", "password": "Password", "protect_this_invitation": "Protect this invitation:", "note": "Note:", - "note_text": "Contact invitations can be used by anyone. Make sure you send the invitation to your contact over a secure medium, and preferably use a password or pin to ensure that they are the only ones who can unlock the invitation and accept it.", + "note_text": "Do not post contact invitations publicly.\n\nContact invitations can be used by anyone. Make sure you send the invitation to your contact over a secure medium, and preferably use a password or pin to ensure that they are the only ones who can unlock the invitation and accept it.", "pin_description": "Choose a PIN to protect the contact invite.\n\nThis level of security is appropriate only for casual connections in public environments for 'shoulder surfing' protection.", "password_description": "Choose a strong password to protect the contact invite.\n\nThis level of security is appropriate when you must be sure the contact invitation is only accepted by its intended recipient. Share this password over a different medium than the invite itself.", "pin_does_not_match": "PIN does not match", @@ -93,16 +198,25 @@ "invitation_copied": "Invitation Copied" }, "invitation_dialog": { + "to": "To", "message_from_contact": "Message from contact", "validating": "Validating...", "failed_to_accept": "Failed to accept contact invitation", "failed_to_reject": "Failed to reject contact invitation", "invalid_invitation": "Invalid invitation", "try_again_online": "Invitation could not be reached, try again when online", + "key_not_found": "Invitation could not be found, it may not be on the network yet", "protected_with_pin": "Contact invitation is protected with a PIN", "protected_with_password": "Contact invitation is protected with a password", "invalid_pin": "Invalid PIN", - "invalid_password": "Invalid password" + "invalid_password": "Invalid password", + "invalid_identity": "Contact invitation is for an missing or unavailable identity" + }, + "waiting_invitation": { + "accepted": "Contact invitation accepted from {name}", + "rejected": "Contact invitation was rejected", + "invalid": "Contact invitation was not valid", + "init_failed": "Contact initialization failed" }, "paste_invitation_dialog": { "title": "Paste Contact Invite", @@ -112,15 +226,16 @@ "scan_invitation_dialog": { "title": "Scan Contact Invite", "instructions": "Position the contact invite QR code in the frame", - "scan_qr_here": "Click here to scan a contact invite QR code:", - "paste_qr_here": "Camera scanning is only available on mobile devices. You can copy a QR code image and paste it here:", + "scan_qr_here": "Click here to scan a contact invite QR code with your device's camera:", + "paste_qr_here": "You can copy a QR code image and paste it by clicking here:", "scan": "Scan", "paste": "Paste", "not_an_image": "Pasted data is not an image", "could_not_decode_image": "Could not decode pasted image", "not_a_valid_qr_code": "Not a valid QR code", "error": "Failed to capture QR code", - "permission_error": "Capturing QR codes requires camera permisions. Allow camera permissions for VeilidChat in your settings." + "permission_error": "Capturing QR codes requires camera permisions. Allow camera permissions for VeilidChat in your settings.", + "camera_error": "Camera error" }, "enter_pin_dialog": { "enter_pin": "Enter PIN", @@ -132,15 +247,10 @@ "reenter_password": "Re-Enter Password To Confirm", "password_does_not_match": "Password does not match" }, - "contact_list": { - "title": "Contacts", - "invite_people": "Invite people to VeilidChat", - "search": "Search contacts", - "invitation": "Invitation" - }, "chat_list": { + "deleted_contact": "Deleted Contact", "search": "Search chats", - "start_a_conversation": "Start a conversation", + "start_a_conversation": "Start A Conversation", "chats": "Chats", "groups": "Groups" }, @@ -156,6 +266,7 @@ "eggplant": "Eggplant", "lime": "Lime", "grim": "Grim", + "elite": "31337", "contrast": "Contrast" }, "brightness": { @@ -166,12 +277,38 @@ "settings_page": { "titlebar": "Settings", "color_theme": "Color Theme", - "brightness_mode": "Brightness Mode" + "brightness_mode": "Brightness Mode", + "display_scale": "Display Scale", + "display_beta_warning": "Display beta warning on startup", + "none": "None", + "in_app": "In-app", + "push": "Push", + "in_app_or_push": "In-app or Push", + "notifications": "Notifications", + "event": "Event", + "sound": "Sound", + "delivery": "Delivery", + "enable_badge": "Enable icon 'badge' bubble", + "enable_notifications": "Enable notifications", + "enable_wallpaper": "Enable wallpaper", + "message_notification_content": "Message notification content", + "invitation_accepted": "On invitation accept/reject", + "message_received": "On message received", + "message_sent": "On message sent", + "name_and_content": "Name and content", + "name_only": "Name only", + "nothing": "Nothing", + "bonk": "Bonk", + "boop": "Boop", + "badeep": "Badeep", + "beep_badeep": "Beep-Badeep", + "custom": "Custom" }, "developer": { "title": "Developer Logs", "command": "Command", "copied": "Selection copied", + "copied_all": "Copied all content", "cleared": "Logs cleared", "are_you_sure_clear": "Are you sure you want to clear the logs?" }, @@ -181,5 +318,9 @@ "info": "Info", "debug": "Debug", "trace": "Trace" + }, + "date_formatter": { + "just_now": "Just now", + "yesterday": "Yesterday" } } \ No newline at end of file diff --git a/assets/images/grid.svg b/assets/images/grid.svg new file mode 100644 index 0000000..f30d577 --- /dev/null +++ b/assets/images/grid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/toilet.svg b/assets/images/toilet.svg new file mode 100644 index 0000000..0984274 --- /dev/null +++ b/assets/images/toilet.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/images/wallpaper/arctic.svg b/assets/images/wallpaper/arctic.svg new file mode 100644 index 0000000..ebcaee4 --- /dev/null +++ b/assets/images/wallpaper/arctic.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/images/wallpaper/babydoll.svg b/assets/images/wallpaper/babydoll.svg new file mode 100644 index 0000000..55f28a3 --- /dev/null +++ b/assets/images/wallpaper/babydoll.svg @@ -0,0 +1,663 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/wallpaper/eggplant.svg b/assets/images/wallpaper/eggplant.svg new file mode 100644 index 0000000..48e6ad3 --- /dev/null +++ b/assets/images/wallpaper/eggplant.svg @@ -0,0 +1,495 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/wallpaper/elite.svg b/assets/images/wallpaper/elite.svg new file mode 100644 index 0000000..606d21f --- /dev/null +++ b/assets/images/wallpaper/elite.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/images/wallpaper/forest.svg b/assets/images/wallpaper/forest.svg new file mode 100644 index 0000000..fb61069 --- /dev/null +++ b/assets/images/wallpaper/forest.svg @@ -0,0 +1,6888 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/wallpaper/garden.svg b/assets/images/wallpaper/garden.svg new file mode 100644 index 0000000..f4e6372 --- /dev/null +++ b/assets/images/wallpaper/garden.svg @@ -0,0 +1,8182 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/wallpaper/gold.svg b/assets/images/wallpaper/gold.svg new file mode 100644 index 0000000..16e5ab5 --- /dev/null +++ b/assets/images/wallpaper/gold.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/wallpaper/grim.svg b/assets/images/wallpaper/grim.svg new file mode 100644 index 0000000..7c3968f --- /dev/null +++ b/assets/images/wallpaper/grim.svg @@ -0,0 +1,928 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/wallpaper/lapis.svg b/assets/images/wallpaper/lapis.svg new file mode 100644 index 0000000..c78fa58 --- /dev/null +++ b/assets/images/wallpaper/lapis.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/wallpaper/lime.svg b/assets/images/wallpaper/lime.svg new file mode 100644 index 0000000..d65222e --- /dev/null +++ b/assets/images/wallpaper/lime.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/wallpaper/scarlet.svg b/assets/images/wallpaper/scarlet.svg new file mode 100644 index 0000000..7047ca7 --- /dev/null +++ b/assets/images/wallpaper/scarlet.svg @@ -0,0 +1,349 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/wallpaper/vapor.svg b/assets/images/wallpaper/vapor.svg new file mode 100644 index 0000000..34bfe59 --- /dev/null +++ b/assets/images/wallpaper/vapor.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/js/pdf/3.2.146/pdf.min.js b/assets/js/pdf/3.2.146/pdf.min.js new file mode 100644 index 0000000..4d43020 --- /dev/null +++ b/assets/js/pdf/3.2.146/pdf.min.js @@ -0,0 +1,22 @@ +/** + * @licstart The following is the entire license notice for the + * JavaScript code in this page + * + * Copyright 2022 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @licend The above is the entire license notice for the + * JavaScript code in this page + */ +!function webpackUniversalModuleDefinition(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define("pdfjs-dist/build/pdf",[],e):"object"==typeof exports?exports["pdfjs-dist/build/pdf"]=e():t["pdfjs-dist/build/pdf"]=t.pdfjsLib=e()}(globalThis,(()=>(()=>{"use strict";var __webpack_modules__=[,(t,e)=>{Object.defineProperty(e,"__esModule",{value:!0});e.VerbosityLevel=e.Util=e.UnknownErrorException=e.UnexpectedResponseException=e.UNSUPPORTED_FEATURES=e.TextRenderingMode=e.StreamType=e.RenderingIntentFlag=e.PermissionFlag=e.PasswordResponses=e.PasswordException=e.PageActionEventType=e.OPS=e.MissingPDFException=e.LINE_FACTOR=e.LINE_DESCENT_FACTOR=e.InvalidPDFException=e.ImageKind=e.IDENTITY_MATRIX=e.FormatError=e.FontType=e.FeatureTest=e.FONT_IDENTITY_MATRIX=e.DocumentActionEventType=e.CMapCompressionType=e.BaseException=e.BASELINE_FACTOR=e.AnnotationType=e.AnnotationStateModelType=e.AnnotationReviewState=e.AnnotationReplyType=e.AnnotationMode=e.AnnotationMarkedState=e.AnnotationFlag=e.AnnotationFieldFlag=e.AnnotationEditorType=e.AnnotationEditorPrefix=e.AnnotationEditorParamsType=e.AnnotationBorderStyleType=e.AnnotationActionEventType=e.AbortException=void 0;e.arrayByteLength=arrayByteLength;e.arraysToBytes=function arraysToBytes(t){const e=t.length;if(1===e&&t[0]instanceof Uint8Array)return t[0];let s=0;for(let n=0;ne});t.promise=new Promise((function(s,n){t.resolve=function(t){e=!0;s(t)};t.reject=function(t){e=!0;n(t)}}));return t};e.createValidAbsoluteUrl=function createValidAbsoluteUrl(t,e=null,s=null){if(!t)return null;try{if(s&&"string"==typeof t){if(s.addDefaultProtocol&&t.startsWith("www.")){const e=t.match(/\./g);e&&e.length>=2&&(t=`http://${t}`)}if(s.tryConvertEncoding)try{t=stringToUTF8String(t)}catch(t){}}const n=e?new URL(t,e):new URL(t);if(function _isValidProtocol(t){if(!t)return!1;switch(t.protocol){case"http:":case"https:":case"ftp:":case"mailto:":case"tel:":return!0;default:return!1}}(n))return n}catch(t){}return null};e.getModificationDate=function getModificationDate(t=new Date){return[t.getUTCFullYear().toString(),(t.getUTCMonth()+1).toString().padStart(2,"0"),t.getUTCDate().toString().padStart(2,"0"),t.getUTCHours().toString().padStart(2,"0"),t.getUTCMinutes().toString().padStart(2,"0"),t.getUTCSeconds().toString().padStart(2,"0")].join("")};e.getVerbosityLevel=function getVerbosityLevel(){return n};e.info=function info(t){n>=s.INFOS&&console.log(`Info: ${t}`)};e.isArrayBuffer=function isArrayBuffer(t){return"object"==typeof t&&null!==t&&void 0!==t.byteLength};e.isArrayEqual=function isArrayEqual(t,e){if(t.length!==e.length)return!1;for(let s=0,n=t.length;s>24&255,t>>16&255,t>>8&255,255&t)};e.stringToBytes=stringToBytes;e.stringToPDFString=function stringToPDFString(t){if(t[0]>="ï"){let e;"þ"===t[0]&&"ÿ"===t[1]?e="utf-16be":"ÿ"===t[0]&&"þ"===t[1]?e="utf-16le":"ï"===t[0]&&"»"===t[1]&&"¿"===t[2]&&(e="utf-8");if(e)try{const s=new TextDecoder(e,{fatal:!0}),n=stringToBytes(t);return s.decode(n)}catch(t){warn(`stringToPDFString: "${t}".`)}}const e=[];for(let s=0,n=t.length;s=s.WARNINGS&&console.log(`Warning: ${t}`)}function unreachable(t){throw new Error(t)}function shadow(t,e,s,n=!1){Object.defineProperty(t,e,{value:s,enumerable:!n,configurable:!0,writable:!1});return s}const i=function BaseExceptionClosure(){function BaseException(t,e){this.constructor===BaseException&&unreachable("Cannot initialize BaseException.");this.message=t;this.name=e}BaseException.prototype=new Error;BaseException.constructor=BaseException;return BaseException}();e.BaseException=i;e.PasswordException=class PasswordException extends i{constructor(t,e){super(t,"PasswordException");this.code=e}};e.UnknownErrorException=class UnknownErrorException extends i{constructor(t,e){super(t,"UnknownErrorException");this.details=e}};e.InvalidPDFException=class InvalidPDFException extends i{constructor(t){super(t,"InvalidPDFException")}};e.MissingPDFException=class MissingPDFException extends i{constructor(t){super(t,"MissingPDFException")}};e.UnexpectedResponseException=class UnexpectedResponseException extends i{constructor(t,e){super(t,"UnexpectedResponseException");this.status=e}};e.FormatError=class FormatError extends i{constructor(t){super(t,"FormatError")}};e.AbortException=class AbortException extends i{constructor(t){super(t,"AbortException")}};function stringToBytes(t){"string"!=typeof t&&unreachable("Invalid argument for stringToBytes");const e=t.length,s=new Uint8Array(e);for(let n=0;nt.toString(16).padStart(2,"0")));class Util{static makeHexColor(t,e,s){return`#${a[t]}${a[e]}${a[s]}`}static scaleMinMax(t,e){let s;if(t[0]){if(t[0]<0){s=e[0];e[0]=e[1];e[1]=s}e[0]*=t[0];e[1]*=t[0];if(t[3]<0){s=e[2];e[2]=e[3];e[3]=s}e[2]*=t[3];e[3]*=t[3]}else{s=e[0];e[0]=e[2];e[2]=s;s=e[1];e[1]=e[3];e[3]=s;if(t[1]<0){s=e[2];e[2]=e[3];e[3]=s}e[2]*=t[1];e[3]*=t[1];if(t[2]<0){s=e[0];e[0]=e[1];e[1]=s}e[0]*=t[2];e[1]*=t[2]}e[0]+=t[4];e[1]+=t[4];e[2]+=t[5];e[3]+=t[5]}static transform(t,e){return[t[0]*e[0]+t[2]*e[1],t[1]*e[0]+t[3]*e[1],t[0]*e[2]+t[2]*e[3],t[1]*e[2]+t[3]*e[3],t[0]*e[4]+t[2]*e[5]+t[4],t[1]*e[4]+t[3]*e[5]+t[5]]}static applyTransform(t,e){return[t[0]*e[0]+t[1]*e[2]+e[4],t[0]*e[1]+t[1]*e[3]+e[5]]}static applyInverseTransform(t,e){const s=e[0]*e[3]-e[1]*e[2];return[(t[0]*e[3]-t[1]*e[2]+e[2]*e[5]-e[4]*e[3])/s,(-t[0]*e[1]+t[1]*e[0]+e[4]*e[1]-e[5]*e[0])/s]}static getAxialAlignedBoundingBox(t,e){const s=Util.applyTransform(t,e),n=Util.applyTransform(t.slice(2,4),e),i=Util.applyTransform([t[0],t[3]],e),a=Util.applyTransform([t[2],t[1]],e);return[Math.min(s[0],n[0],i[0],a[0]),Math.min(s[1],n[1],i[1],a[1]),Math.max(s[0],n[0],i[0],a[0]),Math.max(s[1],n[1],i[1],a[1])]}static inverseTransform(t){const e=t[0]*t[3]-t[1]*t[2];return[t[3]/e,-t[1]/e,-t[2]/e,t[0]/e,(t[2]*t[5]-t[4]*t[3])/e,(t[4]*t[1]-t[5]*t[0])/e]}static singularValueDecompose2dScale(t){const e=[t[0],t[2],t[1],t[3]],s=t[0]*e[0]+t[1]*e[2],n=t[0]*e[1]+t[1]*e[3],i=t[2]*e[0]+t[3]*e[2],a=t[2]*e[1]+t[3]*e[3],r=(s+a)/2,o=Math.sqrt((s+a)**2-4*(s*a-i*n))/2,l=r+o||1,c=r-o||1;return[Math.sqrt(l),Math.sqrt(c)]}static normalizeRect(t){const e=t.slice(0);if(t[0]>t[2]){e[0]=t[2];e[2]=t[0]}if(t[1]>t[3]){e[1]=t[3];e[3]=t[1]}return e}static intersect(t,e){const s=Math.max(Math.min(t[0],t[2]),Math.min(e[0],e[2])),n=Math.min(Math.max(t[0],t[2]),Math.max(e[0],e[2]));if(s>n)return null;const i=Math.max(Math.min(t[1],t[3]),Math.min(e[1],e[3])),a=Math.min(Math.max(t[1],t[3]),Math.max(e[1],e[3]));return i>a?null:[s,i,n,a]}static bezierBoundingBox(t,e,s,n,i,a,r,o){const l=[],c=[[],[]];let h,d,u,p,g,m,f,b;for(let c=0;c<2;++c){if(0===c){d=6*t-12*s+6*i;h=-3*t+9*s-9*i+3*r;u=3*s-3*t}else{d=6*e-12*n+6*a;h=-3*e+9*n-9*a+3*o;u=3*n-3*e}if(Math.abs(h)<1e-12){if(Math.abs(d)<1e-12)continue;p=-u/d;0{Object.defineProperty(exports,"__esModule",{value:!0});exports.build=exports.RenderTask=exports.PDFWorkerUtil=exports.PDFWorker=exports.PDFPageProxy=exports.PDFDocumentProxy=exports.PDFDocumentLoadingTask=exports.PDFDataRangeTransport=exports.LoopbackPort=exports.DefaultStandardFontDataFactory=exports.DefaultCanvasFactory=exports.DefaultCMapReaderFactory=void 0;exports.getDocument=getDocument;exports.setPDFNetworkStreamFactory=setPDFNetworkStreamFactory;exports.version=void 0;var _util=__w_pdfjs_require__(1),_annotation_storage=__w_pdfjs_require__(3),_display_utils=__w_pdfjs_require__(6),_font_loader=__w_pdfjs_require__(9),_canvas=__w_pdfjs_require__(11),_worker_options=__w_pdfjs_require__(14),_is_node=__w_pdfjs_require__(10),_message_handler=__w_pdfjs_require__(15),_metadata=__w_pdfjs_require__(16),_optional_content_config=__w_pdfjs_require__(17),_transport_stream=__w_pdfjs_require__(18),_xfa_text=__w_pdfjs_require__(19);const DEFAULT_RANGE_CHUNK_SIZE=65536,RENDERING_CANCELLED_TIMEOUT=100;let DefaultCanvasFactory=_display_utils.DOMCanvasFactory;exports.DefaultCanvasFactory=DefaultCanvasFactory;let DefaultCMapReaderFactory=_display_utils.DOMCMapReaderFactory;exports.DefaultCMapReaderFactory=DefaultCMapReaderFactory;let DefaultStandardFontDataFactory=_display_utils.DOMStandardFontDataFactory,createPDFNetworkStream;exports.DefaultStandardFontDataFactory=DefaultStandardFontDataFactory;if(_is_node.isNodeJS){const{NodeCanvasFactory:t,NodeCMapReaderFactory:e,NodeStandardFontDataFactory:s}=__w_pdfjs_require__(20);exports.DefaultCanvasFactory=DefaultCanvasFactory=t;exports.DefaultCMapReaderFactory=DefaultCMapReaderFactory=e;exports.DefaultStandardFontDataFactory=DefaultStandardFontDataFactory=s}function setPDFNetworkStreamFactory(t){createPDFNetworkStream=t}function getDocument(t){const e=new PDFDocumentLoadingTask;let s;if("string"==typeof t||t instanceof URL)s={url:t};else if((0,_util.isArrayBuffer)(t))s={data:t};else if(t instanceof PDFDataRangeTransport)s={range:t};else{if("object"!=typeof t)throw new Error("Invalid parameter in getDocument, need either string, URL, TypedArray, or parameter object.");if(!t.url&&!t.data&&!t.range)throw new Error("Invalid parameter object: need either .data, .range or .url");s=t}const n=Object.create(null);let i=null,a=null;for(const t in s){const e=s[t];switch(t){case"url":if("undefined"!=typeof window)try{n[t]=new URL(e,window.location).href;continue}catch(t){(0,_util.warn)(`Cannot create valid URL: "${t}".`)}else if("string"==typeof e||e instanceof URL){n[t]=e.toString();continue}throw new Error("Invalid PDF url data: either string or URL-object is expected in the url property.");case"range":i=e;continue;case"worker":a=e;continue;case"data":if(_is_node.isNodeJS&&"undefined"!=typeof Buffer&&e instanceof Buffer)n[t]=new Uint8Array(e);else{if(e instanceof Uint8Array)break;if("string"==typeof e)n[t]=(0,_util.stringToBytes)(e);else if("object"!=typeof e||null===e||isNaN(e.length)){if(!(0,_util.isArrayBuffer)(e))throw new Error("Invalid PDF binary data: either TypedArray, string, or array-like object is expected in the data property.");n[t]=new Uint8Array(e)}else n[t]=new Uint8Array(e)}continue}n[t]=e}n.CMapReaderFactory=n.CMapReaderFactory||DefaultCMapReaderFactory;n.StandardFontDataFactory=n.StandardFontDataFactory||DefaultStandardFontDataFactory;n.ignoreErrors=!0!==n.stopAtErrors;n.fontExtraProperties=!0===n.fontExtraProperties;n.pdfBug=!0===n.pdfBug;n.enableXfa=!0===n.enableXfa;(!Number.isInteger(n.rangeChunkSize)||n.rangeChunkSize<1)&&(n.rangeChunkSize=DEFAULT_RANGE_CHUNK_SIZE);("string"!=typeof n.docBaseUrl||(0,_display_utils.isDataScheme)(n.docBaseUrl))&&(n.docBaseUrl=null);(!Number.isInteger(n.maxImageSize)||n.maxImageSize<-1)&&(n.maxImageSize=-1);"string"!=typeof n.cMapUrl&&(n.cMapUrl=null);"string"!=typeof n.standardFontDataUrl&&(n.standardFontDataUrl=null);"boolean"!=typeof n.useWorkerFetch&&(n.useWorkerFetch=n.CMapReaderFactory===_display_utils.DOMCMapReaderFactory&&n.StandardFontDataFactory===_display_utils.DOMStandardFontDataFactory);"boolean"!=typeof n.isEvalSupported&&(n.isEvalSupported=!0);"boolean"!=typeof n.isOffscreenCanvasSupported&&(n.isOffscreenCanvasSupported=!_is_node.isNodeJS);"boolean"!=typeof n.disableFontFace&&(n.disableFontFace=_is_node.isNodeJS);"boolean"!=typeof n.useSystemFonts&&(n.useSystemFonts=!_is_node.isNodeJS&&!n.disableFontFace);"object"==typeof n.ownerDocument&&null!==n.ownerDocument||(n.ownerDocument=globalThis.document);"boolean"!=typeof n.disableRange&&(n.disableRange=!1);"boolean"!=typeof n.disableStream&&(n.disableStream=!1);"boolean"!=typeof n.disableAutoFetch&&(n.disableAutoFetch=!1);(0,_util.setVerbosityLevel)(n.verbosity);if(!a){const t={verbosity:n.verbosity,port:_worker_options.GlobalWorkerOptions.workerPort};a=t.port?PDFWorker.fromPort(t):new PDFWorker(t);e._worker=a}const r=e.docId;a.promise.then((function(){if(e.destroyed)throw new Error("Loading aborted");const t=_fetchDocument(a,n,i,r),s=new Promise((function(t){let e;i?e=new _transport_stream.PDFDataTransportStream({length:n.length,initialData:n.initialData,progressiveDone:n.progressiveDone,contentDispositionFilename:n.contentDispositionFilename,disableRange:n.disableRange,disableStream:n.disableStream},i):n.data||(e=createPDFNetworkStream({url:n.url,length:n.length,httpHeaders:n.httpHeaders,withCredentials:n.withCredentials,rangeChunkSize:n.rangeChunkSize,disableRange:n.disableRange,disableStream:n.disableStream}));t(e)}));return Promise.all([t,s]).then((function([t,s]){if(e.destroyed)throw new Error("Loading aborted");const i=new _message_handler.MessageHandler(r,t,a.port),o=new WorkerTransport(i,e,s,n);e._transport=o;i.send("Ready",null)}))})).catch(e._capability.reject);return e}async function _fetchDocument(t,e,s,n){if(t.destroyed)throw new Error("Worker was destroyed");if(s){e.length=s.length;e.initialData=s.initialData;e.progressiveDone=s.progressiveDone;e.contentDispositionFilename=s.contentDispositionFilename}const i=await t.messageHandler.sendWithPromise("GetDocRequest",{docId:n,apiVersion:"3.2.146",data:e.data,password:e.password,disableAutoFetch:e.disableAutoFetch,rangeChunkSize:e.rangeChunkSize,length:e.length,docBaseUrl:e.docBaseUrl,enableXfa:e.enableXfa,evaluatorOptions:{maxImageSize:e.maxImageSize,disableFontFace:e.disableFontFace,ignoreErrors:e.ignoreErrors,isEvalSupported:e.isEvalSupported,isOffscreenCanvasSupported:e.isOffscreenCanvasSupported,fontExtraProperties:e.fontExtraProperties,useSystemFonts:e.useSystemFonts,cMapUrl:e.useWorkerFetch?e.cMapUrl:null,standardFontDataUrl:e.useWorkerFetch?e.standardFontDataUrl:null}});e.data&&(e.data=null);if(t.destroyed)throw new Error("Worker was destroyed");return i}class PDFDocumentLoadingTask{static#t=0;#e=null;constructor(){this._capability=(0,_util.createPromiseCapability)();this._transport=null;this._worker=null;this.docId="d"+PDFDocumentLoadingTask.#t++;this.destroyed=!1;this.onPassword=null;this.onProgress=null}get onUnsupportedFeature(){return this.#e}set onUnsupportedFeature(t){(0,_display_utils.deprecated)("The PDFDocumentLoadingTask onUnsupportedFeature property will be removed in the future.");this.#e=t}get promise(){return this._capability.promise}async destroy(){this.destroyed=!0;await(this._transport?.destroy());this._transport=null;if(this._worker){this._worker.destroy();this._worker=null}}}exports.PDFDocumentLoadingTask=PDFDocumentLoadingTask;class PDFDataRangeTransport{constructor(t,e,s=!1,n=null){this.length=t;this.initialData=e;this.progressiveDone=s;this.contentDispositionFilename=n;this._rangeListeners=[];this._progressListeners=[];this._progressiveReadListeners=[];this._progressiveDoneListeners=[];this._readyCapability=(0,_util.createPromiseCapability)()}addRangeListener(t){this._rangeListeners.push(t)}addProgressListener(t){this._progressListeners.push(t)}addProgressiveReadListener(t){this._progressiveReadListeners.push(t)}addProgressiveDoneListener(t){this._progressiveDoneListeners.push(t)}onDataRange(t,e){for(const s of this._rangeListeners)s(t,e)}onDataProgress(t,e){this._readyCapability.promise.then((()=>{for(const s of this._progressListeners)s(t,e)}))}onDataProgressiveRead(t){this._readyCapability.promise.then((()=>{for(const e of this._progressiveReadListeners)e(t)}))}onDataProgressiveDone(){this._readyCapability.promise.then((()=>{for(const t of this._progressiveDoneListeners)t()}))}transportReady(){this._readyCapability.resolve()}requestDataRange(t,e){(0,_util.unreachable)("Abstract method PDFDataRangeTransport.requestDataRange")}abort(){}}exports.PDFDataRangeTransport=PDFDataRangeTransport;class PDFDocumentProxy{constructor(t,e){this._pdfInfo=t;this._transport=e}get annotationStorage(){return this._transport.annotationStorage}get numPages(){return this._pdfInfo.numPages}get fingerprints(){return this._pdfInfo.fingerprints}get stats(){(0,_display_utils.deprecated)("The PDFDocumentProxy stats property will be removed in the future.");return this._transport.stats}get isPureXfa(){return(0,_util.shadow)(this,"isPureXfa",!!this._transport._htmlForXfa)}get allXfaHtml(){return this._transport._htmlForXfa}getPage(t){return this._transport.getPage(t)}getPageIndex(t){return this._transport.getPageIndex(t)}getDestinations(){return this._transport.getDestinations()}getDestination(t){return this._transport.getDestination(t)}getPageLabels(){return this._transport.getPageLabels()}getPageLayout(){return this._transport.getPageLayout()}getPageMode(){return this._transport.getPageMode()}getViewerPreferences(){return this._transport.getViewerPreferences()}getOpenAction(){return this._transport.getOpenAction()}getAttachments(){return this._transport.getAttachments()}getJavaScript(){return this._transport.getJavaScript()}getJSActions(){return this._transport.getDocJSActions()}getOutline(){return this._transport.getOutline()}getOptionalContentConfig(){return this._transport.getOptionalContentConfig()}getPermissions(){return this._transport.getPermissions()}getMetadata(){return this._transport.getMetadata()}getMarkInfo(){return this._transport.getMarkInfo()}getData(){return this._transport.getData()}saveDocument(){return this._transport.saveDocument()}getDownloadInfo(){return this._transport.downloadInfoCapability.promise}cleanup(t=!1){return this._transport.startCleanup(t||this.isPureXfa)}destroy(){return this.loadingTask.destroy()}get loadingParams(){return this._transport.loadingParams}get loadingTask(){return this._transport.loadingTask}getFieldObjects(){return this._transport.getFieldObjects()}hasJSActions(){return this._transport.hasJSActions()}getCalculationOrderIds(){return this._transport.getCalculationOrderIds()}}exports.PDFDocumentProxy=PDFDocumentProxy;class PDFPageProxy{constructor(t,e,s,n,i=!1){this._pageIndex=t;this._pageInfo=e;this._ownerDocument=n;this._transport=s;this._stats=i?new _display_utils.StatTimer:null;this._pdfBug=i;this.commonObjs=s.commonObjs;this.objs=new PDFObjects;this._bitmaps=new Set;this.cleanupAfterRender=!1;this.pendingCleanup=!1;this._intentStates=new Map;this.destroyed=!1}get pageNumber(){return this._pageIndex+1}get rotate(){return this._pageInfo.rotate}get ref(){return this._pageInfo.ref}get userUnit(){return this._pageInfo.userUnit}get view(){return this._pageInfo.view}getViewport({scale:t,rotation:e=this.rotate,offsetX:s=0,offsetY:n=0,dontFlip:i=!1}={}){return new _display_utils.PageViewport({viewBox:this.view,scale:t,rotation:e,offsetX:s,offsetY:n,dontFlip:i})}getAnnotations({intent:t="display"}={}){const e=this._transport.getRenderingIntent(t);return this._transport.getAnnotations(this._pageIndex,e.renderingIntent)}getJSActions(){return this._transport.getPageJSActions(this._pageIndex)}get isPureXfa(){return(0,_util.shadow)(this,"isPureXfa",!!this._transport._htmlForXfa)}async getXfa(){return this._transport._htmlForXfa?.children[this._pageIndex]||null}render({canvasContext:t,viewport:e,intent:s="display",annotationMode:n=_util.AnnotationMode.ENABLE,transform:i=null,canvasFactory:a=null,background:r=null,optionalContentConfigPromise:o=null,annotationCanvasMap:l=null,pageColors:c=null,printAnnotationStorage:h=null}){this._stats?.time("Overall");const d=this._transport.getRenderingIntent(s,n,h);this.pendingCleanup=!1;o||(o=this._transport.getOptionalContentConfig());let u=this._intentStates.get(d.cacheKey);if(!u){u=Object.create(null);this._intentStates.set(d.cacheKey,u)}if(u.streamReaderCancelTimeout){clearTimeout(u.streamReaderCancelTimeout);u.streamReaderCancelTimeout=null}const p=a||new DefaultCanvasFactory({ownerDocument:this._ownerDocument}),g=!!(d.renderingIntent&_util.RenderingIntentFlag.PRINT);if(!u.displayReadyCapability){u.displayReadyCapability=(0,_util.createPromiseCapability)();u.operatorList={fnArray:[],argsArray:[],lastChunk:!1,separateAnnots:null};this._stats?.time("Page Request");this._pumpOperatorList(d)}const complete=t=>{u.renderTasks.delete(m);(this.cleanupAfterRender||g)&&(this.pendingCleanup=!0);this._tryCleanup();if(t){m.capability.reject(t);this._abortOperatorList({intentState:u,reason:t instanceof Error?t:new Error(t)})}else m.capability.resolve();this._stats?.timeEnd("Rendering");this._stats?.timeEnd("Overall")},m=new InternalRenderTask({callback:complete,params:{canvasContext:t,viewport:e,transform:i,background:r},objs:this.objs,commonObjs:this.commonObjs,annotationCanvasMap:l,operatorList:u.operatorList,pageIndex:this._pageIndex,canvasFactory:p,useRequestAnimationFrame:!g,pdfBug:this._pdfBug,pageColors:c});(u.renderTasks||=new Set).add(m);const f=m.task;Promise.all([u.displayReadyCapability.promise,o]).then((([t,e])=>{if(this.pendingCleanup)complete();else{this._stats?.time("Rendering");m.initializeGraphics({transparency:t,optionalContentConfig:e});m.operatorListChanged()}})).catch(complete);return f}getOperatorList({intent:t="display",annotationMode:e=_util.AnnotationMode.ENABLE,printAnnotationStorage:s=null}={}){const n=this._transport.getRenderingIntent(t,e,s,!0);let i,a=this._intentStates.get(n.cacheKey);if(!a){a=Object.create(null);this._intentStates.set(n.cacheKey,a)}if(!a.opListReadCapability){i=Object.create(null);i.operatorListChanged=function operatorListChanged(){if(a.operatorList.lastChunk){a.opListReadCapability.resolve(a.operatorList);a.renderTasks.delete(i)}};a.opListReadCapability=(0,_util.createPromiseCapability)();(a.renderTasks||=new Set).add(i);a.operatorList={fnArray:[],argsArray:[],lastChunk:!1,separateAnnots:null};this._stats?.time("Page Request");this._pumpOperatorList(n)}return a.opListReadCapability.promise}streamTextContent({disableCombineTextItems:t=!1,includeMarkedContent:e=!1}={}){return this._transport.messageHandler.sendWithStream("GetTextContent",{pageIndex:this._pageIndex,combineTextItems:!0!==t,includeMarkedContent:!0===e},{highWaterMark:100,size:t=>t.items.length})}getTextContent(t={}){if(this._transport._htmlForXfa)return this.getXfa().then((t=>_xfa_text.XfaText.textContent(t)));const e=this.streamTextContent(t);return new Promise((function(t,s){const n=e.getReader(),i={items:[],styles:Object.create(null)};!function pump(){n.read().then((function({value:e,done:s}){if(s)t(i);else{Object.assign(i.styles,e.styles);i.items.push(...e.items);pump()}}),s)}()}))}getStructTree(){return this._transport.getStructTree(this._pageIndex)}_destroy(){this.destroyed=!0;const t=[];for(const e of this._intentStates.values()){this._abortOperatorList({intentState:e,reason:new Error("Page was destroyed."),force:!0});if(!e.opListReadCapability)for(const s of e.renderTasks){t.push(s.completed);s.cancel()}}this.objs.clear();for(const t of this._bitmaps)t.close();this._bitmaps.clear();this.pendingCleanup=!1;return Promise.all(t)}cleanup(t=!1){this.pendingCleanup=!0;return this._tryCleanup(t)}_tryCleanup(t=!1){if(!this.pendingCleanup)return!1;for(const{renderTasks:t,operatorList:e}of this._intentStates.values())if(t.size>0||!e.lastChunk)return!1;this._intentStates.clear();this.objs.clear();t&&this._stats&&(this._stats=new _display_utils.StatTimer);for(const t of this._bitmaps)t.close();this._bitmaps.clear();this.pendingCleanup=!1;return!0}_startRenderPage(t,e){const s=this._intentStates.get(e);if(s){this._stats?.timeEnd("Page Request");s.displayReadyCapability?.resolve(t)}}_renderPageChunk(t,e){for(let s=0,n=t.length;s{n.read().then((({value:t,done:e})=>{if(e)i.streamReader=null;else if(!this._transport.destroyed){this._renderPageChunk(t,i);pump()}}),(t=>{i.streamReader=null;if(!this._transport.destroyed){if(i.operatorList){i.operatorList.lastChunk=!0;for(const t of i.renderTasks)t.operatorListChanged();this._tryCleanup()}if(i.displayReadyCapability)i.displayReadyCapability.reject(t);else{if(!i.opListReadCapability)throw t;i.opListReadCapability.reject(t)}}}))};pump()}_abortOperatorList({intentState:t,reason:e,force:s=!1}){if(t.streamReader){if(t.streamReaderCancelTimeout){clearTimeout(t.streamReaderCancelTimeout);t.streamReaderCancelTimeout=null}if(!s){if(t.renderTasks.size>0)return;if(e instanceof _display_utils.RenderingCancelledException){let s=RENDERING_CANCELLED_TIMEOUT;e.extraDelay>0&&e.extraDelay<1e3&&(s+=e.extraDelay);t.streamReaderCancelTimeout=setTimeout((()=>{t.streamReaderCancelTimeout=null;this._abortOperatorList({intentState:t,reason:e,force:!0})}),s);return}}t.streamReader.cancel(new _util.AbortException(e.message)).catch((()=>{}));t.streamReader=null;if(!this._transport.destroyed){for(const[e,s]of this._intentStates)if(s===t){this._intentStates.delete(e);break}this.cleanup()}}}get stats(){return this._stats}}exports.PDFPageProxy=PDFPageProxy;class LoopbackPort{#s=[];#n=Promise.resolve();postMessage(t,e){const s={data:structuredClone(t,e)};this.#n.then((()=>{for(const t of this.#s)t.call(this,s)}))}addEventListener(t,e){this.#s.push(e)}removeEventListener(t,e){const s=this.#s.indexOf(e);this.#s.splice(s,1)}terminate(){this.#s.length=0}}exports.LoopbackPort=LoopbackPort;const PDFWorkerUtil={isWorkerDisabled:!1,fallbackWorkerSrc:null,fakeWorkerId:0};exports.PDFWorkerUtil=PDFWorkerUtil;if(_is_node.isNodeJS&&"function"==typeof require){PDFWorkerUtil.isWorkerDisabled=!0;PDFWorkerUtil.fallbackWorkerSrc="./pdf.worker.js"}else if("object"==typeof document){const t=document?.currentScript?.src;t&&(PDFWorkerUtil.fallbackWorkerSrc=t.replace(/(\.(?:min\.)?js)(\?.*)?$/i,".worker$1$2"))}PDFWorkerUtil.isSameOrigin=function(t,e){let s;try{s=new URL(t);if(!s.origin||"null"===s.origin)return!1}catch(t){return!1}const n=new URL(e,s);return s.origin===n.origin};PDFWorkerUtil.createCDNWrapper=function(t){const e=`importScripts("${t}");`;return URL.createObjectURL(new Blob([e]))};class PDFWorker{static#i=new WeakMap;constructor({name:t=null,port:e=null,verbosity:s=(0,_util.getVerbosityLevel)()}={}){if(e&&PDFWorker.#i.has(e))throw new Error("Cannot use more than one PDFWorker per port.");this.name=t;this.destroyed=!1;this.verbosity=s;this._readyCapability=(0,_util.createPromiseCapability)();this._port=null;this._webWorker=null;this._messageHandler=null;if(e){PDFWorker.#i.set(e,this);this._initializeFromPort(e)}else this._initialize()}get promise(){return this._readyCapability.promise}get port(){return this._port}get messageHandler(){return this._messageHandler}_initializeFromPort(t){this._port=t;this._messageHandler=new _message_handler.MessageHandler("main","worker",t);this._messageHandler.on("ready",(function(){}));this._readyCapability.resolve();this._messageHandler.send("configure",{verbosity:this.verbosity})}_initialize(){if(!PDFWorkerUtil.isWorkerDisabled&&!PDFWorker._mainThreadWorkerMessageHandler){let{workerSrc:t}=PDFWorker;try{PDFWorkerUtil.isSameOrigin(window.location.href,t)||(t=PDFWorkerUtil.createCDNWrapper(new URL(t,window.location).href));const e=new Worker(t),s=new _message_handler.MessageHandler("main","worker",e),terminateEarly=()=>{e.removeEventListener("error",onWorkerError);s.destroy();e.terminate();this.destroyed?this._readyCapability.reject(new Error("Worker was destroyed")):this._setupFakeWorker()},onWorkerError=()=>{this._webWorker||terminateEarly()};e.addEventListener("error",onWorkerError);s.on("test",(t=>{e.removeEventListener("error",onWorkerError);if(this.destroyed)terminateEarly();else if(t){this._messageHandler=s;this._port=e;this._webWorker=e;this._readyCapability.resolve();s.send("configure",{verbosity:this.verbosity})}else{this._setupFakeWorker();s.destroy();e.terminate()}}));s.on("ready",(t=>{e.removeEventListener("error",onWorkerError);if(this.destroyed)terminateEarly();else try{sendTest()}catch(t){this._setupFakeWorker()}}));const sendTest=()=>{const t=new Uint8Array;s.send("test",t,[t.buffer])};sendTest();return}catch(t){(0,_util.info)("The worker has been disabled.")}}this._setupFakeWorker()}_setupFakeWorker(){if(!PDFWorkerUtil.isWorkerDisabled){(0,_util.warn)("Setting up fake worker.");PDFWorkerUtil.isWorkerDisabled=!0}PDFWorker._setupFakeWorkerGlobal.then((t=>{if(this.destroyed){this._readyCapability.reject(new Error("Worker was destroyed"));return}const e=new LoopbackPort;this._port=e;const s="fake"+PDFWorkerUtil.fakeWorkerId++,n=new _message_handler.MessageHandler(s+"_worker",s,e);t.setup(n,e);const i=new _message_handler.MessageHandler(s,s+"_worker",e);this._messageHandler=i;this._readyCapability.resolve();i.send("configure",{verbosity:this.verbosity})})).catch((t=>{this._readyCapability.reject(new Error(`Setting up fake worker failed: "${t.message}".`))}))}destroy(){this.destroyed=!0;if(this._webWorker){this._webWorker.terminate();this._webWorker=null}PDFWorker.#i.delete(this._port);this._port=null;if(this._messageHandler){this._messageHandler.destroy();this._messageHandler=null}}static fromPort(t){if(!t?.port)throw new Error("PDFWorker.fromPort - invalid method signature.");return this.#i.has(t.port)?this.#i.get(t.port):new PDFWorker(t)}static get workerSrc(){if(_worker_options.GlobalWorkerOptions.workerSrc)return _worker_options.GlobalWorkerOptions.workerSrc;if(null!==PDFWorkerUtil.fallbackWorkerSrc){_is_node.isNodeJS||(0,_display_utils.deprecated)('No "GlobalWorkerOptions.workerSrc" specified.');return PDFWorkerUtil.fallbackWorkerSrc}throw new Error('No "GlobalWorkerOptions.workerSrc" specified.')}static get _mainThreadWorkerMessageHandler(){try{return globalThis.pdfjsWorker?.WorkerMessageHandler||null}catch(t){return null}}static get _setupFakeWorkerGlobal(){const loader=async()=>{const mainWorkerMessageHandler=this._mainThreadWorkerMessageHandler;if(mainWorkerMessageHandler)return mainWorkerMessageHandler;if(_is_node.isNodeJS&&"function"==typeof require){const worker=eval("require")(this.workerSrc);return worker.WorkerMessageHandler}await(0,_display_utils.loadScript)(this.workerSrc);return window.pdfjsWorker.WorkerMessageHandler};return(0,_util.shadow)(this,"_setupFakeWorkerGlobal",loader())}}exports.PDFWorker=PDFWorker;class WorkerTransport{#a=null;#r=new Map;#o=new Map;#l=null;constructor(t,e,s,n){this.messageHandler=t;this.loadingTask=e;this.commonObjs=new PDFObjects;this.fontLoader=new _font_loader.FontLoader({onUnsupportedFeature:this._onUnsupportedFeature.bind(this),ownerDocument:n.ownerDocument,styleElement:n.styleElement});this._params=n;if(!n.useWorkerFetch){this.CMapReaderFactory=new n.CMapReaderFactory({baseUrl:n.cMapUrl,isCompressed:n.cMapPacked});this.StandardFontDataFactory=new n.StandardFontDataFactory({baseUrl:n.standardFontDataUrl})}this.destroyed=!1;this.destroyCapability=null;this._passwordCapability=null;this._networkStream=s;this._fullReader=null;this._lastProgress=null;this.downloadInfoCapability=(0,_util.createPromiseCapability)();this.setupMessageHandler()}get annotationStorage(){return(0,_util.shadow)(this,"annotationStorage",new _annotation_storage.AnnotationStorage)}get stats(){return this.#a}getRenderingIntent(t,e=_util.AnnotationMode.ENABLE,s=null,n=!1){let i=_util.RenderingIntentFlag.DISPLAY,a=null;switch(t){case"any":i=_util.RenderingIntentFlag.ANY;break;case"display":break;case"print":i=_util.RenderingIntentFlag.PRINT;break;default:(0,_util.warn)(`getRenderingIntent - invalid intent: ${t}`)}switch(e){case _util.AnnotationMode.DISABLE:i+=_util.RenderingIntentFlag.ANNOTATIONS_DISABLE;break;case _util.AnnotationMode.ENABLE:break;case _util.AnnotationMode.ENABLE_FORMS:i+=_util.RenderingIntentFlag.ANNOTATIONS_FORMS;break;case _util.AnnotationMode.ENABLE_STORAGE:i+=_util.RenderingIntentFlag.ANNOTATIONS_STORAGE;a=(i&_util.RenderingIntentFlag.PRINT&&s instanceof _annotation_storage.PrintAnnotationStorage?s:this.annotationStorage).serializable;break;default:(0,_util.warn)(`getRenderingIntent - invalid annotationMode: ${e}`)}n&&(i+=_util.RenderingIntentFlag.OPLIST);return{renderingIntent:i,cacheKey:`${i}_${_annotation_storage.AnnotationStorage.getHash(a)}`,annotationStorageMap:a}}destroy(){if(this.destroyCapability)return this.destroyCapability.promise;this.destroyed=!0;this.destroyCapability=(0,_util.createPromiseCapability)();this._passwordCapability&&this._passwordCapability.reject(new Error("Worker was destroyed during onPassword callback"));const t=[];for(const e of this.#r.values())t.push(e._destroy());this.#r.clear();this.#o.clear();this.hasOwnProperty("annotationStorage")&&this.annotationStorage.resetModified();const e=this.messageHandler.sendWithPromise("Terminate",null);t.push(e);Promise.all(t).then((()=>{this.commonObjs.clear();this.fontLoader.clear();this.#l=null;this._getFieldObjectsPromise=null;this._hasJSActionsPromise=null;this._networkStream&&this._networkStream.cancelAllRequests(new _util.AbortException("Worker was terminated."));if(this.messageHandler){this.messageHandler.destroy();this.messageHandler=null}this.destroyCapability.resolve()}),this.destroyCapability.reject);return this.destroyCapability.promise}setupMessageHandler(){const{messageHandler:t,loadingTask:e}=this;t.on("GetReader",((t,e)=>{(0,_util.assert)(this._networkStream,"GetReader - no `IPDFStream` instance available.");this._fullReader=this._networkStream.getFullReader();this._fullReader.onProgress=t=>{this._lastProgress={loaded:t.loaded,total:t.total}};e.onPull=()=>{this._fullReader.read().then((function({value:t,done:s}){if(s)e.close();else{(0,_util.assert)((0,_util.isArrayBuffer)(t),"GetReader - expected an ArrayBuffer.");e.enqueue(new Uint8Array(t),1,[t])}})).catch((t=>{e.error(t)}))};e.onCancel=t=>{this._fullReader.cancel(t);e.ready.catch((t=>{if(!this.destroyed)throw t}))}}));t.on("ReaderHeadersReady",(t=>{const s=(0,_util.createPromiseCapability)(),n=this._fullReader;n.headersReady.then((()=>{if(!n.isStreamingSupported||!n.isRangeSupported){this._lastProgress&&e.onProgress?.(this._lastProgress);n.onProgress=t=>{e.onProgress?.({loaded:t.loaded,total:t.total})}}s.resolve({isStreamingSupported:n.isStreamingSupported,isRangeSupported:n.isRangeSupported,contentLength:n.contentLength})}),s.reject);return s.promise}));t.on("GetRangeReader",((t,e)=>{(0,_util.assert)(this._networkStream,"GetRangeReader - no `IPDFStream` instance available.");const s=this._networkStream.getRangeReader(t.begin,t.end);if(s){e.onPull=()=>{s.read().then((function({value:t,done:s}){if(s)e.close();else{(0,_util.assert)((0,_util.isArrayBuffer)(t),"GetRangeReader - expected an ArrayBuffer.");e.enqueue(new Uint8Array(t),1,[t])}})).catch((t=>{e.error(t)}))};e.onCancel=t=>{s.cancel(t);e.ready.catch((t=>{if(!this.destroyed)throw t}))}}else e.close()}));t.on("GetDoc",(({pdfInfo:t})=>{this._numPages=t.numPages;this._htmlForXfa=t.htmlForXfa;delete t.htmlForXfa;e._capability.resolve(new PDFDocumentProxy(t,this))}));t.on("DocException",(function(t){let s;switch(t.name){case"PasswordException":s=new _util.PasswordException(t.message,t.code);break;case"InvalidPDFException":s=new _util.InvalidPDFException(t.message);break;case"MissingPDFException":s=new _util.MissingPDFException(t.message);break;case"UnexpectedResponseException":s=new _util.UnexpectedResponseException(t.message,t.status);break;case"UnknownErrorException":s=new _util.UnknownErrorException(t.message,t.details);break;default:(0,_util.unreachable)("DocException - expected a valid Error.")}e._capability.reject(s)}));t.on("PasswordRequest",(t=>{this._passwordCapability=(0,_util.createPromiseCapability)();if(e.onPassword){const updatePassword=t=>{t instanceof Error?this._passwordCapability.reject(t):this._passwordCapability.resolve({password:t})};try{e.onPassword(updatePassword,t.code)}catch(t){this._passwordCapability.reject(t)}}else this._passwordCapability.reject(new _util.PasswordException(t.message,t.code));return this._passwordCapability.promise}));t.on("DataLoaded",(t=>{e.onProgress?.({loaded:t.length,total:t.length});this.downloadInfoCapability.resolve(t)}));t.on("StartRenderPage",(t=>{if(this.destroyed)return;this.#r.get(t.pageIndex)._startRenderPage(t.transparency,t.cacheKey)}));t.on("commonobj",(([e,s,n])=>{if(!this.destroyed&&!this.commonObjs.has(e))switch(s){case"Font":const i=this._params;if("error"in n){const t=n.error;(0,_util.warn)(`Error during font loading: ${t}`);this.commonObjs.resolve(e,t);break}let a=null;i.pdfBug&&globalThis.FontInspector?.enabled&&(a={registerFont(t,e){globalThis.FontInspector.fontAdded(t,e)}});const r=new _font_loader.FontFaceObject(n,{isEvalSupported:i.isEvalSupported,disableFontFace:i.disableFontFace,ignoreErrors:i.ignoreErrors,onUnsupportedFeature:this._onUnsupportedFeature.bind(this),fontRegistry:a});this.fontLoader.bind(r).catch((s=>t.sendWithPromise("FontFallback",{id:e}))).finally((()=>{!i.fontExtraProperties&&r.data&&(r.data=null);this.commonObjs.resolve(e,r)}));break;case"FontPath":case"Image":this.commonObjs.resolve(e,n);break;default:throw new Error(`Got unknown common object type ${s}`)}}));t.on("obj",(([t,e,s,n])=>{if(this.destroyed)return;const i=this.#r.get(e);if(!i.objs.has(t))switch(s){case"Image":i.objs.resolve(t,n);const e=8e6;if(n){let t;if(n.bitmap){const{bitmap:e,width:s,height:a}=n;t=s*a*4;i._bitmaps.add(e)}else t=n.data?.length||0;t>e&&(i.cleanupAfterRender=!0)}break;case"Pattern":i.objs.resolve(t,n);break;default:throw new Error(`Got unknown object type ${s}`)}}));t.on("DocProgress",(t=>{this.destroyed||e.onProgress?.({loaded:t.loaded,total:t.total})}));t.on("DocStats",(t=>{this.destroyed||(this.#a=Object.freeze({streamTypes:Object.freeze(t.streamTypes),fontTypes:Object.freeze(t.fontTypes)}))}));t.on("UnsupportedFeature",this._onUnsupportedFeature.bind(this));t.on("FetchBuiltInCMap",(t=>this.destroyed?Promise.reject(new Error("Worker was destroyed.")):this.CMapReaderFactory?this.CMapReaderFactory.fetch(t):Promise.reject(new Error("CMapReaderFactory not initialized, see the `useWorkerFetch` parameter."))));t.on("FetchStandardFontData",(t=>this.destroyed?Promise.reject(new Error("Worker was destroyed.")):this.StandardFontDataFactory?this.StandardFontDataFactory.fetch(t):Promise.reject(new Error("StandardFontDataFactory not initialized, see the `useWorkerFetch` parameter."))))}_onUnsupportedFeature({featureId:t}){this.destroyed||this.loadingTask.onUnsupportedFeature?.(t)}getData(){return this.messageHandler.sendWithPromise("GetData",null)}saveDocument(){this.annotationStorage.size<=0&&(0,_util.warn)("saveDocument called while `annotationStorage` is empty, please use the getData-method instead.");return this.messageHandler.sendWithPromise("SaveDocument",{isPureXfa:!!this._htmlForXfa,numPages:this._numPages,annotationStorage:this.annotationStorage.serializable,filename:this._fullReader?.filename??null}).finally((()=>{this.annotationStorage.resetModified()}))}getPage(t){if(!Number.isInteger(t)||t<=0||t>this._numPages)return Promise.reject(new Error("Invalid page request."));const e=t-1,s=this.#o.get(e);if(s)return s;const n=this.messageHandler.sendWithPromise("GetPage",{pageIndex:e}).then((t=>{if(this.destroyed)throw new Error("Transport destroyed");const s=new PDFPageProxy(e,t,this,this._params.ownerDocument,this._params.pdfBug);this.#r.set(e,s);return s}));this.#o.set(e,n);return n}getPageIndex(t){return"object"!=typeof t||null===t||!Number.isInteger(t.num)||t.num<0||!Number.isInteger(t.gen)||t.gen<0?Promise.reject(new Error("Invalid pageIndex request.")):this.messageHandler.sendWithPromise("GetPageIndex",{num:t.num,gen:t.gen})}getAnnotations(t,e){return this.messageHandler.sendWithPromise("GetAnnotations",{pageIndex:t,intent:e})}getFieldObjects(){return this._getFieldObjectsPromise||=this.messageHandler.sendWithPromise("GetFieldObjects",null)}hasJSActions(){return this._hasJSActionsPromise||=this.messageHandler.sendWithPromise("HasJSActions",null)}getCalculationOrderIds(){return this.messageHandler.sendWithPromise("GetCalculationOrderIds",null)}getDestinations(){return this.messageHandler.sendWithPromise("GetDestinations",null)}getDestination(t){return"string"!=typeof t?Promise.reject(new Error("Invalid destination request.")):this.messageHandler.sendWithPromise("GetDestination",{id:t})}getPageLabels(){return this.messageHandler.sendWithPromise("GetPageLabels",null)}getPageLayout(){return this.messageHandler.sendWithPromise("GetPageLayout",null)}getPageMode(){return this.messageHandler.sendWithPromise("GetPageMode",null)}getViewerPreferences(){return this.messageHandler.sendWithPromise("GetViewerPreferences",null)}getOpenAction(){return this.messageHandler.sendWithPromise("GetOpenAction",null)}getAttachments(){return this.messageHandler.sendWithPromise("GetAttachments",null)}getJavaScript(){return this.messageHandler.sendWithPromise("GetJavaScript",null)}getDocJSActions(){return this.messageHandler.sendWithPromise("GetDocJSActions",null)}getPageJSActions(t){return this.messageHandler.sendWithPromise("GetPageJSActions",{pageIndex:t})}getStructTree(t){return this.messageHandler.sendWithPromise("GetStructTree",{pageIndex:t})}getOutline(){return this.messageHandler.sendWithPromise("GetOutline",null)}getOptionalContentConfig(){return this.messageHandler.sendWithPromise("GetOptionalContentConfig",null).then((t=>new _optional_content_config.OptionalContentConfig(t)))}getPermissions(){return this.messageHandler.sendWithPromise("GetPermissions",null)}getMetadata(){return this.#l||=this.messageHandler.sendWithPromise("GetMetadata",null).then((t=>({info:t[0],metadata:t[1]?new _metadata.Metadata(t[1]):null,contentDispositionFilename:this._fullReader?.filename??null,contentLength:this._fullReader?.contentLength??null})))}getMarkInfo(){return this.messageHandler.sendWithPromise("GetMarkInfo",null)}async startCleanup(t=!1){if(!this.destroyed){await this.messageHandler.sendWithPromise("Cleanup",null);for(const t of this.#r.values()){if(!t.cleanup())throw new Error(`startCleanup: Page ${t.pageNumber} is currently rendering.`)}this.commonObjs.clear();t||this.fontLoader.clear();this.#l=null;this._getFieldObjectsPromise=null;this._hasJSActionsPromise=null}}get loadingParams(){const t=this._params;return(0,_util.shadow)(this,"loadingParams",{disableAutoFetch:t.disableAutoFetch,enableXfa:t.enableXfa})}}class PDFObjects{#c=Object.create(null);#h(t){const e=this.#c[t];return e||(this.#c[t]={capability:(0,_util.createPromiseCapability)(),data:null})}get(t,e=null){if(e){const s=this.#h(t);s.capability.promise.then((()=>e(s.data)));return null}const s=this.#c[t];if(!s?.capability.settled)throw new Error(`Requesting object that isn't resolved yet ${t}.`);return s.data}has(t){return this.#c[t]?.capability.settled||!1}resolve(t,e=null){const s=this.#h(t);s.data=e;s.capability.resolve()}clear(){this.#c=Object.create(null)}}class RenderTask{#d=null;constructor(t){this.#d=t;this.onContinue=null}get promise(){return this.#d.capability.promise}cancel(t=0){this.#d.cancel(null,t)}get separateAnnots(){const{separateAnnots:t}=this.#d.operatorList;if(!t)return!1;const{annotationCanvasMap:e}=this.#d;return t.form||t.canvas&&e?.size>0}}exports.RenderTask=RenderTask;class InternalRenderTask{static#u=new WeakSet;constructor({callback:t,params:e,objs:s,commonObjs:n,annotationCanvasMap:i,operatorList:a,pageIndex:r,canvasFactory:o,useRequestAnimationFrame:l=!1,pdfBug:c=!1,pageColors:h=null}){this.callback=t;this.params=e;this.objs=s;this.commonObjs=n;this.annotationCanvasMap=i;this.operatorListIdx=null;this.operatorList=a;this._pageIndex=r;this.canvasFactory=o;this._pdfBug=c;this.pageColors=h;this.running=!1;this.graphicsReadyCallback=null;this.graphicsReady=!1;this._useRequestAnimationFrame=!0===l&&"undefined"!=typeof window;this.cancelled=!1;this.capability=(0,_util.createPromiseCapability)();this.task=new RenderTask(this);this._cancelBound=this.cancel.bind(this);this._continueBound=this._continue.bind(this);this._scheduleNextBound=this._scheduleNext.bind(this);this._nextBound=this._next.bind(this);this._canvas=e.canvasContext.canvas}get completed(){return this.capability.promise.catch((function(){}))}initializeGraphics({transparency:t=!1,optionalContentConfig:e}){if(this.cancelled)return;if(this._canvas){if(InternalRenderTask.#u.has(this._canvas))throw new Error("Cannot use the same canvas during multiple render() operations. Use different canvas or ensure previous operations were cancelled or completed.");InternalRenderTask.#u.add(this._canvas)}if(this._pdfBug&&globalThis.StepperManager?.enabled){this.stepper=globalThis.StepperManager.create(this._pageIndex);this.stepper.init(this.operatorList);this.stepper.nextBreakPoint=this.stepper.getNextBreakPoint()}const{canvasContext:s,viewport:n,transform:i,background:a}=this.params;this.gfx=new _canvas.CanvasGraphics(s,this.commonObjs,this.objs,this.canvasFactory,{optionalContentConfig:e},this.annotationCanvasMap,this.pageColors);this.gfx.beginDrawing({transform:i,viewport:n,transparency:t,background:a});this.operatorListIdx=0;this.graphicsReady=!0;this.graphicsReadyCallback?.()}cancel(t=null,e=0){this.running=!1;this.cancelled=!0;this.gfx?.endDrawing();this._canvas&&InternalRenderTask.#u.delete(this._canvas);this.callback(t||new _display_utils.RenderingCancelledException(`Rendering cancelled, page ${this._pageIndex+1}`,"canvas",e))}operatorListChanged(){if(this.graphicsReady){this.stepper?.updateOperatorList(this.operatorList);this.running||this._continue()}else this.graphicsReadyCallback||(this.graphicsReadyCallback=this._continueBound)}_continue(){this.running=!0;this.cancelled||(this.task.onContinue?this.task.onContinue(this._scheduleNextBound):this._scheduleNext())}_scheduleNext(){this._useRequestAnimationFrame?window.requestAnimationFrame((()=>{this._nextBound().catch(this._cancelBound)})):Promise.resolve().then(this._nextBound).catch(this._cancelBound)}async _next(){if(!this.cancelled){this.operatorListIdx=this.gfx.executeOperatorList(this.operatorList,this.operatorListIdx,this._continueBound,this.stepper);if(this.operatorListIdx===this.operatorList.argsArray.length){this.running=!1;if(this.operatorList.lastChunk){this.gfx.endDrawing();this._canvas&&InternalRenderTask.#u.delete(this._canvas);this.callback()}}}}}const version="3.2.146";exports.version=version;const build="3fd2a3548";exports.build=build},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.PrintAnnotationStorage=e.AnnotationStorage=void 0;var n=s(1),i=s(4),a=s(8);class AnnotationStorage{#p=!1;#g=new Map;constructor(){this.onSetModified=null;this.onResetModified=null;this.onAnnotationEditor=null}getValue(t,e){const s=this.#g.get(t);return void 0===s?e:Object.assign(e,s)}getRawValue(t){return this.#g.get(t)}remove(t){this.#g.delete(t);0===this.#g.size&&this.resetModified();if("function"==typeof this.onAnnotationEditor){for(const t of this.#g.values())if(t instanceof i.AnnotationEditor)return;this.onAnnotationEditor(null)}}setValue(t,e){const s=this.#g.get(t);let n=!1;if(void 0!==s){for(const[t,i]of Object.entries(e))if(s[t]!==i){n=!0;s[t]=i}}else{n=!0;this.#g.set(t,e)}n&&this.#m();e instanceof i.AnnotationEditor&&"function"==typeof this.onAnnotationEditor&&this.onAnnotationEditor(e.constructor._type)}has(t){return this.#g.has(t)}getAll(){return this.#g.size>0?(0,n.objectFromMap)(this.#g):null}get size(){return this.#g.size}#m(){if(!this.#p){this.#p=!0;"function"==typeof this.onSetModified&&this.onSetModified()}}resetModified(){if(this.#p){this.#p=!1;"function"==typeof this.onResetModified&&this.onResetModified()}}get print(){return new PrintAnnotationStorage(this)}get serializable(){if(0===this.#g.size)return null;const t=new Map;for(const[e,s]of this.#g){const n=s instanceof i.AnnotationEditor?s.serialize():s;n&&t.set(e,n)}return t}static getHash(t){if(!t)return"";const e=new a.MurmurHash3_64;for(const[s,n]of t)e.update(`${s}:${JSON.stringify(n)}`);return e.hexdigest()}}e.AnnotationStorage=AnnotationStorage;class PrintAnnotationStorage extends AnnotationStorage{#f=null;constructor(t){super();this.#f=structuredClone(t.serializable)}get print(){(0,n.unreachable)("Should not call PrintAnnotationStorage.print")}get serializable(){return this.#f}}e.PrintAnnotationStorage=PrintAnnotationStorage},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.AnnotationEditor=void 0;var n=s(5),i=s(1);class AnnotationEditor{#b=this.focusin.bind(this);#A=this.focusout.bind(this);#_=!1;#y=!1;#v=!1;_uiManager=null;#S=AnnotationEditor._zIndex++;static _colorManager=new n.ColorManager;static _zIndex=1;constructor(t){this.constructor===AnnotationEditor&&(0,i.unreachable)("Cannot initialize AnnotationEditor.");this.parent=t.parent;this.id=t.id;this.width=this.height=null;this.pageIndex=t.parent.pageIndex;this.name=t.name;this.div=null;this._uiManager=t.uiManager;const{rotation:e,rawDims:{pageWidth:s,pageHeight:n,pageX:a,pageY:r}}=this.parent.viewport;this.rotation=e;this.pageDimensions=[s,n];this.pageTranslation=[a,r];const[o,l]=this.parentDimensions;this.x=t.x/o;this.y=t.y/l;this.isAttachedToDOM=!1}static get _defaultLineColor(){return(0,i.shadow)(this,"_defaultLineColor",this._colorManager.getHexCode("CanvasText"))}addCommands(t){this._uiManager.addCommands(t)}get currentLayer(){return this._uiManager.currentLayer}setInBackground(){this.div.style.zIndex=0}setInForeground(){this.div.style.zIndex=this.#S}setParent(t){if(null!==t){this.pageIndex=t.pageIndex;this.pageDimensions=t.pageDimensions}this.parent=t}focusin(t){this.#_?this.#_=!1:this.parent.setSelected(this)}focusout(t){if(!this.isAttachedToDOM)return;if(!t.relatedTarget?.closest(`#${this.id}`)){t.preventDefault();this.parent?.isMultipleSelection||this.commitOrRemove()}}commitOrRemove(){this.isEmpty()?this.remove():this.commit()}commit(){this.addToAnnotationStorage()}addToAnnotationStorage(){this._uiManager.addToAnnotationStorage(this)}dragstart(t){const e=this.parent.div.getBoundingClientRect();this.startX=t.clientX-e.x;this.startY=t.clientY-e.y;t.dataTransfer.setData("text/plain",this.id);t.dataTransfer.effectAllowed="move"}setAt(t,e,s,n){const[i,a]=this.parentDimensions;[s,n]=this.screenToPageTranslation(s,n);this.x=(t+s)/i;this.y=(e+n)/a;this.div.style.left=100*this.x+"%";this.div.style.top=100*this.y+"%"}translate(t,e){const[s,n]=this.parentDimensions;[t,e]=this.screenToPageTranslation(t,e);this.x+=t/s;this.y+=e/n;this.div.style.left=100*this.x+"%";this.div.style.top=100*this.y+"%"}screenToPageTranslation(t,e){switch(this.parentRotation){case 90:return[e,-t];case 180:return[-t,-e];case 270:return[-e,t];default:return[t,e]}}get parentScale(){return this._uiManager.viewParameters.realScale}get parentRotation(){return this._uiManager.viewParameters.rotation}get parentDimensions(){const{realScale:t}=this._uiManager.viewParameters,[e,s]=this.pageDimensions;return[e*t,s*t]}setDims(t,e){const[s,n]=this.parentDimensions;this.div.style.width=100*t/s+"%";this.div.style.height=100*e/n+"%"}fixDims(){const{style:t}=this.div,{height:e,width:s}=t,n=s.endsWith("%"),i=e.endsWith("%");if(n&&i)return;const[a,r]=this.parentDimensions;n||(t.width=100*parseFloat(s)/a+"%");i||(t.height=100*parseFloat(e)/r+"%")}getInitialTranslation(){return[0,0]}render(){this.div=document.createElement("div");this.div.setAttribute("data-editor-rotation",(360-this.rotation)%360);this.div.className=this.name;this.div.setAttribute("id",this.id);this.div.setAttribute("tabIndex",0);this.setInForeground();this.div.addEventListener("focusin",this.#b);this.div.addEventListener("focusout",this.#A);const[t,e]=this.getInitialTranslation();this.translate(t,e);(0,n.bindEvents)(this,this.div,["dragstart","pointerdown"]);return this.div}pointerdown(t){const{isMac:e}=i.FeatureTest.platform;if(0!==t.button||t.ctrlKey&&e)t.preventDefault();else{t.ctrlKey&&!e||t.shiftKey||t.metaKey&&e?this.parent.toggleSelected(this):this.parent.setSelected(this);this.#_=!0}}getRect(t,e){const s=this.parentScale,[n,i]=this.pageDimensions,[a,r]=this.pageTranslation,o=t/s,l=e/s,c=this.x*n,h=this.y*i,d=this.width*n,u=this.height*i;switch(this.rotation){case 0:return[c+o+a,i-h-l-u+r,c+o+d+a,i-h-l+r];case 90:return[c+l+a,i-h+o+r,c+l+u+a,i-h+o+d+r];case 180:return[c-o-d+a,i-h+l+r,c-o+a,i-h+l+u+r];case 270:return[c-l-u+a,i-h-o-d+r,c-l+a,i-h-o+r];default:throw new Error("Invalid rotation")}}getRectInCurrentCoords(t,e){const[s,n,i,a]=t,r=i-s,o=a-n;switch(this.rotation){case 0:return[s,e-a,r,o];case 90:return[s,e-n,o,r];case 180:return[i,e-n,r,o];case 270:return[i,e-a,o,r];default:throw new Error("Invalid rotation")}}onceAdded(){}isEmpty(){return!1}enableEditMode(){this.#v=!0}disableEditMode(){this.#v=!1}isInEditMode(){return this.#v}shouldGetKeyboardEvents(){return!1}needsToBeRebuilt(){return this.div&&!this.isAttachedToDOM}rebuild(){this.div?.addEventListener("focusin",this.#b)}serialize(){(0,i.unreachable)("An editor must be serializable")}static deserialize(t,e,s){const n=new this.prototype.constructor({parent:e,id:e.getNextId(),uiManager:s});n.rotation=t.rotation;const[i,a]=n.pageDimensions,[r,o,l,c]=n.getRectInCurrentCoords(t.rect,a);n.x=r/i;n.y=o/a;n.width=l/i;n.height=c/a;return n}remove(){this.div.removeEventListener("focusin",this.#b);this.div.removeEventListener("focusout",this.#A);this.isEmpty()||this.commit();this.parent.remove(this)}select(){this.div?.classList.add("selectedEditor")}unselect(){this.div?.classList.remove("selectedEditor")}updateParams(t,e){}disableEditing(){}enableEditing(){}get propertiesToUpdate(){return{}}get contentDiv(){return this.div}get isEditing(){return this.#y}set isEditing(t){this.#y=t;if(t){this.parent.setSelected(this);this.parent.setActiveEditor(this)}else this.parent.setActiveEditor(null)}}e.AnnotationEditor=AnnotationEditor},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.KeyboardManager=e.CommandManager=e.ColorManager=e.AnnotationEditorUIManager=void 0;e.bindEvents=function bindEvents(t,e,s){for(const n of s)e.addEventListener(n,t[n].bind(t))};e.opacityToHex=function opacityToHex(t){return Math.round(Math.min(255,Math.max(1,255*t))).toString(16).padStart(2,"0")};var n=s(1),i=s(6);class IdManager{#x=0;getId(){return`${n.AnnotationEditorPrefix}${this.#x++}`}}class CommandManager{#E=[];#C=!1;#P;#T=-1;constructor(t=128){this.#P=t}add({cmd:t,undo:e,mustExec:s,type:n=NaN,overwriteIfSameType:i=!1,keepUndo:a=!1}){s&&t();if(this.#C)return;const r={cmd:t,undo:e,type:n};if(-1===this.#T){this.#E.length>0&&(this.#E.length=0);this.#T=0;this.#E.push(r);return}if(i&&this.#E[this.#T].type===n){a&&(r.undo=this.#E[this.#T].undo);this.#E[this.#T]=r;return}const o=this.#T+1;if(o===this.#P)this.#E.splice(0,1);else{this.#T=o;ot===e[s])))return ColorManager._colorsMapping.get(t);return e}getHexCode(t){const e=this._colors.get(t);return e?n.Util.makeHexColor(...e):t}}e.ColorManager=ColorManager;class AnnotationEditorUIManager{#k=null;#F=new Map;#R=new Map;#M=null;#D=new CommandManager;#I=0;#O=null;#L=new Set;#N=null;#j=new IdManager;#U=!1;#B=n.AnnotationEditorType.NONE;#q=new Set;#W=this.copy.bind(this);#H=this.cut.bind(this);#G=this.paste.bind(this);#z=this.keydown.bind(this);#V=this.onEditingAction.bind(this);#X=this.onPageChanging.bind(this);#$=this.onScaleChanging.bind(this);#Y=this.onRotationChanging.bind(this);#K={isEditing:!1,isEmpty:!0,hasSomethingToUndo:!1,hasSomethingToRedo:!1,hasSelectedEditor:!1};#J=null;static _keyboardManager=new KeyboardManager([[["ctrl+a","mac+meta+a"],AnnotationEditorUIManager.prototype.selectAll],[["ctrl+z","mac+meta+z"],AnnotationEditorUIManager.prototype.undo],[["ctrl+y","ctrl+shift+Z","mac+meta+shift+Z"],AnnotationEditorUIManager.prototype.redo],[["Backspace","alt+Backspace","ctrl+Backspace","shift+Backspace","mac+Backspace","mac+alt+Backspace","mac+ctrl+Backspace","Delete","ctrl+Delete","shift+Delete"],AnnotationEditorUIManager.prototype.delete],[["Escape","mac+Escape"],AnnotationEditorUIManager.prototype.unselectAll]]);constructor(t,e,s){this.#J=t;this.#N=e;this.#N._on("editingaction",this.#V);this.#N._on("pagechanging",this.#X);this.#N._on("scalechanging",this.#$);this.#N._on("rotationchanging",this.#Y);this.#M=s;this.viewParameters={realScale:i.PixelsPerInch.PDF_TO_CSS_UNITS,rotation:0}}destroy(){this.#Q();this.#N._off("editingaction",this.#V);this.#N._off("pagechanging",this.#X);this.#N._off("scalechanging",this.#$);this.#N._off("rotationchanging",this.#Y);for(const t of this.#R.values())t.destroy();this.#R.clear();this.#F.clear();this.#L.clear();this.#k=null;this.#q.clear();this.#D.destroy()}onPageChanging({pageNumber:t}){this.#I=t-1}focusMainContainer(){this.#J.focus()}addShouldRescale(t){this.#L.add(t)}removeShouldRescale(t){this.#L.delete(t)}onScaleChanging({scale:t}){this.commitOrRemove();this.viewParameters.realScale=t*i.PixelsPerInch.PDF_TO_CSS_UNITS;for(const t of this.#L)t.onScaleChanging()}onRotationChanging({pagesRotation:t}){this.commitOrRemove();this.viewParameters.rotation=t}addToAnnotationStorage(t){t.isEmpty()||!this.#M||this.#M.has(t.id)||this.#M.setValue(t.id,t)}#Z(){this.#J.addEventListener("keydown",this.#z)}#Q(){this.#J.removeEventListener("keydown",this.#z)}#tt(){document.addEventListener("copy",this.#W);document.addEventListener("cut",this.#H);document.addEventListener("paste",this.#G)}#et(){document.removeEventListener("copy",this.#W);document.removeEventListener("cut",this.#H);document.removeEventListener("paste",this.#G)}copy(t){t.preventDefault();this.#k&&this.#k.commitOrRemove();if(!this.hasSelection)return;const e=[];for(const t of this.#q)t.isEmpty()||e.push(t.serialize());0!==e.length&&t.clipboardData.setData("application/pdfjs",JSON.stringify(e))}cut(t){this.copy(t);this.delete()}paste(t){t.preventDefault();let e=t.clipboardData.getData("application/pdfjs");if(!e)return;try{e=JSON.parse(e)}catch(t){(0,n.warn)(`paste: "${t.message}".`);return}if(!Array.isArray(e))return;this.unselectAll();const s=this.#R.get(this.#I);try{const t=[];for(const n of e){const e=s.deserialize(n);if(!e)return;t.push(e)}const cmd=()=>{for(const e of t)this.#st(e);this.#nt(t)},undo=()=>{for(const e of t)e.remove()};this.addCommands({cmd:cmd,undo:undo,mustExec:!0})}catch(t){(0,n.warn)(`paste: "${t.message}".`)}}keydown(t){this.getActive()?.shouldGetKeyboardEvents()||AnnotationEditorUIManager._keyboardManager.exec(this,t)}onEditingAction(t){["undo","redo","delete","selectAll"].includes(t.name)&&this[t.name]()}#it(t){Object.entries(t).some((([t,e])=>this.#K[t]!==e))&&this.#N.dispatch("annotationeditorstateschanged",{source:this,details:Object.assign(this.#K,t)})}#at(t){this.#N.dispatch("annotationeditorparamschanged",{source:this,details:t})}setEditingState(t){if(t){this.#Z();this.#tt();this.#it({isEditing:this.#B!==n.AnnotationEditorType.NONE,isEmpty:this.#rt(),hasSomethingToUndo:this.#D.hasSomethingToUndo(),hasSomethingToRedo:this.#D.hasSomethingToRedo(),hasSelectedEditor:!1})}else{this.#Q();this.#et();this.#it({isEditing:!1})}}registerEditorTypes(t){if(!this.#O){this.#O=t;for(const t of this.#O)this.#at(t.defaultPropertiesToUpdate)}}getId(){return this.#j.getId()}get currentLayer(){return this.#R.get(this.#I)}get currentPageIndex(){return this.#I}addLayer(t){this.#R.set(t.pageIndex,t);this.#U?t.enable():t.disable()}removeLayer(t){this.#R.delete(t.pageIndex)}updateMode(t){this.#B=t;if(t===n.AnnotationEditorType.NONE){this.setEditingState(!1);this.#ot()}else{this.setEditingState(!0);this.#lt();for(const e of this.#R.values())e.updateMode(t)}}updateToolbar(t){t!==this.#B&&this.#N.dispatch("switchannotationeditormode",{source:this,mode:t})}updateParams(t,e){if(this.#O){for(const s of this.#q)s.updateParams(t,e);for(const s of this.#O)s.updateDefaultParams(t,e)}}#lt(){if(!this.#U){this.#U=!0;for(const t of this.#R.values())t.enable()}}#ot(){this.unselectAll();if(this.#U){this.#U=!1;for(const t of this.#R.values())t.disable()}}getEditors(t){const e=[];for(const s of this.#F.values())s.pageIndex===t&&e.push(s);return e}getEditor(t){return this.#F.get(t)}addEditor(t){this.#F.set(t.id,t)}removeEditor(t){this.#F.delete(t.id);this.unselect(t);this.#M?.remove(t.id)}#st(t){const e=this.#R.get(t.pageIndex);e?e.addOrRebuild(t):this.addEditor(t)}setActiveEditor(t){if(this.#k!==t){this.#k=t;t&&this.#at(t.propertiesToUpdate)}}toggleSelected(t){if(this.#q.has(t)){this.#q.delete(t);t.unselect();this.#it({hasSelectedEditor:this.hasSelection})}else{this.#q.add(t);t.select();this.#at(t.propertiesToUpdate);this.#it({hasSelectedEditor:!0})}}setSelected(t){for(const e of this.#q)e!==t&&e.unselect();this.#q.clear();this.#q.add(t);t.select();this.#at(t.propertiesToUpdate);this.#it({hasSelectedEditor:!0})}isSelected(t){return this.#q.has(t)}unselect(t){t.unselect();this.#q.delete(t);this.#it({hasSelectedEditor:this.hasSelection})}get hasSelection(){return 0!==this.#q.size}undo(){this.#D.undo();this.#it({hasSomethingToUndo:this.#D.hasSomethingToUndo(),hasSomethingToRedo:!0,isEmpty:this.#rt()})}redo(){this.#D.redo();this.#it({hasSomethingToUndo:!0,hasSomethingToRedo:this.#D.hasSomethingToRedo(),isEmpty:this.#rt()})}addCommands(t){this.#D.add(t);this.#it({hasSomethingToUndo:!0,hasSomethingToRedo:!1,isEmpty:this.#rt()})}#rt(){if(0===this.#F.size)return!0;if(1===this.#F.size)for(const t of this.#F.values())return t.isEmpty();return!1}delete(){this.commitOrRemove();if(!this.hasSelection)return;const t=[...this.#q];this.addCommands({cmd:()=>{for(const e of t)e.remove()},undo:()=>{for(const e of t)this.#st(e)},mustExec:!0})}commitOrRemove(){this.#k?.commitOrRemove()}#nt(t){this.#q.clear();for(const e of t)if(!e.isEmpty()){this.#q.add(e);e.select()}this.#it({hasSelectedEditor:!0})}selectAll(){for(const t of this.#q)t.commit();this.#nt(this.#F.values())}unselectAll(){if(this.#k)this.#k.commitOrRemove();else if(0!==this.#q.size){for(const t of this.#q)t.unselect();this.#q.clear();this.#it({hasSelectedEditor:!1})}}isActive(t){return this.#k===t}getActive(){return this.#k}getMode(){return this.#B}}e.AnnotationEditorUIManager=AnnotationEditorUIManager},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.StatTimer=e.RenderingCancelledException=e.PixelsPerInch=e.PageViewport=e.PDFDateString=e.DOMStandardFontDataFactory=e.DOMSVGFactory=e.DOMCanvasFactory=e.DOMCMapReaderFactory=e.AnnotationPrefix=void 0;e.deprecated=function deprecated(t){console.log("Deprecated API usage: "+t)};e.getColorValues=function getColorValues(t){const e=document.createElement("span");e.style.visibility="hidden";document.body.append(e);for(const s of t.keys()){e.style.color=s;const n=window.getComputedStyle(e).color;t.set(s,getRGB(n))}e.remove()};e.getCurrentTransform=function getCurrentTransform(t){const{a:e,b:s,c:n,d:i,e:a,f:r}=t.getTransform();return[e,s,n,i,a,r]};e.getCurrentTransformInverse=function getCurrentTransformInverse(t){const{a:e,b:s,c:n,d:i,e:a,f:r}=t.getTransform().invertSelf();return[e,s,n,i,a,r]};e.getFilenameFromUrl=function getFilenameFromUrl(t,e=!1){e||([t]=t.split(/[#?]/,1));return t.substring(t.lastIndexOf("/")+1)};e.getPdfFilenameFromUrl=function getPdfFilenameFromUrl(t,e="document.pdf"){if("string"!=typeof t)return e;if(isDataScheme(t)){(0,i.warn)('getPdfFilenameFromUrl: ignore "data:"-URL for performance reasons.');return e}const s=/[^/?#=]+\.pdf\b(?!.*\.pdf\b)/i,n=/^(?:(?:[^:]+:)?\/\/[^/]+)?([^?#]*)(\?[^#]*)?(#.*)?$/.exec(t);let a=s.exec(n[1])||s.exec(n[2])||s.exec(n[3]);if(a){a=a[0];if(a.includes("%"))try{a=s.exec(decodeURIComponent(a))[0]}catch(t){}}return a||e};e.getRGB=getRGB;e.getXfaPageViewport=function getXfaPageViewport(t,{scale:e=1,rotation:s=0}){const{width:n,height:i}=t.attributes.style,a=[0,0,parseInt(n),parseInt(i)];return new PageViewport({viewBox:a,scale:e,rotation:s})};e.isDataScheme=isDataScheme;e.isPdfFile=function isPdfFile(t){return"string"==typeof t&&/\.pdf$/i.test(t)};e.isValidFetchUrl=isValidFetchUrl;e.loadScript=function loadScript(t,e=!1){return new Promise(((s,n)=>{const i=document.createElement("script");i.src=t;i.onload=function(t){e&&i.remove();s(t)};i.onerror=function(){n(new Error(`Cannot load script at: ${i.src}`))};(document.head||document.documentElement).append(i)}))};e.setLayerDimensions=function setLayerDimensions(t,e,s=!1,n=!0){if(e instanceof PageViewport){const{pageWidth:n,pageHeight:i}=e.rawDims,{style:a}=t,r=`calc(var(--scale-factor) * ${n}px)`,o=`calc(var(--scale-factor) * ${i}px)`;if(s&&e.rotation%180!=0){a.width=o;a.height=r}else{a.width=r;a.height=o}}n&&t.setAttribute("data-main-rotation",e.rotation)};var n=s(7),i=s(1);e.AnnotationPrefix="pdfjs_internal_id_";class PixelsPerInch{static CSS=96;static PDF=72;static PDF_TO_CSS_UNITS=this.CSS/this.PDF}e.PixelsPerInch=PixelsPerInch;class DOMCanvasFactory extends n.BaseCanvasFactory{constructor({ownerDocument:t=globalThis.document}={}){super();this._document=t}_createCanvas(t,e){const s=this._document.createElement("canvas");s.width=t;s.height=e;return s}}e.DOMCanvasFactory=DOMCanvasFactory;async function fetchData(t,e=!1){if(isValidFetchUrl(t,document.baseURI)){const s=await fetch(t);if(!s.ok)throw new Error(s.statusText);return e?new Uint8Array(await s.arrayBuffer()):(0,i.stringToBytes)(await s.text())}return new Promise(((s,n)=>{const a=new XMLHttpRequest;a.open("GET",t,!0);e&&(a.responseType="arraybuffer");a.onreadystatechange=()=>{if(a.readyState===XMLHttpRequest.DONE){if(200===a.status||0===a.status){let t;e&&a.response?t=new Uint8Array(a.response):!e&&a.responseText&&(t=(0,i.stringToBytes)(a.responseText));if(t){s(t);return}}n(new Error(a.statusText))}};a.send(null)}))}class DOMCMapReaderFactory extends n.BaseCMapReaderFactory{_fetchData(t,e){return fetchData(t,this.isCompressed).then((t=>({cMapData:t,compressionType:e})))}}e.DOMCMapReaderFactory=DOMCMapReaderFactory;class DOMStandardFontDataFactory extends n.BaseStandardFontDataFactory{_fetchData(t){return fetchData(t,!0)}}e.DOMStandardFontDataFactory=DOMStandardFontDataFactory;class DOMSVGFactory extends n.BaseSVGFactory{_createSVG(t){return document.createElementNS("http://www.w3.org/2000/svg",t)}}e.DOMSVGFactory=DOMSVGFactory;class PageViewport{constructor({viewBox:t,scale:e,rotation:s,offsetX:n=0,offsetY:i=0,dontFlip:a=!1}){this.viewBox=t;this.scale=e;this.rotation=s;this.offsetX=n;this.offsetY=i;const r=(t[2]+t[0])/2,o=(t[3]+t[1])/2;let l,c,h,d,u,p,g,m;(s%=360)<0&&(s+=360);switch(s){case 180:l=-1;c=0;h=0;d=1;break;case 90:l=0;c=1;h=1;d=0;break;case 270:l=0;c=-1;h=-1;d=0;break;case 0:l=1;c=0;h=0;d=-1;break;default:throw new Error("PageViewport: Invalid rotation, must be a multiple of 90 degrees.")}if(a){h=-h;d=-d}if(0===l){u=Math.abs(o-t[1])*e+n;p=Math.abs(r-t[0])*e+i;g=(t[3]-t[1])*e;m=(t[2]-t[0])*e}else{u=Math.abs(r-t[0])*e+n;p=Math.abs(o-t[1])*e+i;g=(t[2]-t[0])*e;m=(t[3]-t[1])*e}this.transform=[l*e,c*e,h*e,d*e,u-l*e*r-h*e*o,p-c*e*r-d*e*o];this.width=g;this.height=m}get rawDims(){const{viewBox:t}=this;return(0,i.shadow)(this,"rawDims",{pageWidth:t[2]-t[0],pageHeight:t[3]-t[1],pageX:t[0],pageY:t[1]})}clone({scale:t=this.scale,rotation:e=this.rotation,offsetX:s=this.offsetX,offsetY:n=this.offsetY,dontFlip:i=!1}={}){return new PageViewport({viewBox:this.viewBox.slice(),scale:t,rotation:e,offsetX:s,offsetY:n,dontFlip:i})}convertToViewportPoint(t,e){return i.Util.applyTransform([t,e],this.transform)}convertToViewportRectangle(t){const e=i.Util.applyTransform([t[0],t[1]],this.transform),s=i.Util.applyTransform([t[2],t[3]],this.transform);return[e[0],e[1],s[0],s[1]]}convertToPdfPoint(t,e){return i.Util.applyInverseTransform([t,e],this.transform)}}e.PageViewport=PageViewport;class RenderingCancelledException extends i.BaseException{constructor(t,e,s=0){super(t,"RenderingCancelledException");this.type=e;this.extraDelay=s}}e.RenderingCancelledException=RenderingCancelledException;function isDataScheme(t){const e=t.length;let s=0;for(;s=1&&n<=12?n-1:0;let i=parseInt(e[3],10);i=i>=1&&i<=31?i:1;let r=parseInt(e[4],10);r=r>=0&&r<=23?r:0;let o=parseInt(e[5],10);o=o>=0&&o<=59?o:0;let l=parseInt(e[6],10);l=l>=0&&l<=59?l:0;const c=e[7]||"Z";let h=parseInt(e[8],10);h=h>=0&&h<=23?h:0;let d=parseInt(e[9],10)||0;d=d>=0&&d<=59?d:0;if("-"===c){r+=h;o+=d}else if("+"===c){r-=h;o-=d}return new Date(Date.UTC(s,n,i,r,o,l))}};function getRGB(t){if(t.startsWith("#")){const e=parseInt(t.slice(1),16);return[(16711680&e)>>16,(65280&e)>>8,255&e]}if(t.startsWith("rgb("))return t.slice(4,-1).split(",").map((t=>parseInt(t)));if(t.startsWith("rgba("))return t.slice(5,-1).split(",").map((t=>parseInt(t))).slice(0,3);(0,i.warn)(`Not a valid color format: "${t}"`);return[0,0,0]}},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.BaseStandardFontDataFactory=e.BaseSVGFactory=e.BaseCanvasFactory=e.BaseCMapReaderFactory=void 0;var n=s(1);class BaseCanvasFactory{constructor(){this.constructor===BaseCanvasFactory&&(0,n.unreachable)("Cannot initialize BaseCanvasFactory.")}create(t,e){if(t<=0||e<=0)throw new Error("Invalid canvas size");const s=this._createCanvas(t,e);return{canvas:s,context:s.getContext("2d")}}reset(t,e,s){if(!t.canvas)throw new Error("Canvas is not specified");if(e<=0||s<=0)throw new Error("Invalid canvas size");t.canvas.width=e;t.canvas.height=s}destroy(t){if(!t.canvas)throw new Error("Canvas is not specified");t.canvas.width=0;t.canvas.height=0;t.canvas=null;t.context=null}_createCanvas(t,e){(0,n.unreachable)("Abstract method `_createCanvas` called.")}}e.BaseCanvasFactory=BaseCanvasFactory;class BaseCMapReaderFactory{constructor({baseUrl:t=null,isCompressed:e=!1}){this.constructor===BaseCMapReaderFactory&&(0,n.unreachable)("Cannot initialize BaseCMapReaderFactory.");this.baseUrl=t;this.isCompressed=e}async fetch({name:t}){if(!this.baseUrl)throw new Error('The CMap "baseUrl" parameter must be specified, ensure that the "cMapUrl" and "cMapPacked" API parameters are provided.');if(!t)throw new Error("CMap name must be specified.");const e=this.baseUrl+t+(this.isCompressed?".bcmap":""),s=this.isCompressed?n.CMapCompressionType.BINARY:n.CMapCompressionType.NONE;return this._fetchData(e,s).catch((t=>{throw new Error(`Unable to load ${this.isCompressed?"binary ":""}CMap at: ${e}`)}))}_fetchData(t,e){(0,n.unreachable)("Abstract method `_fetchData` called.")}}e.BaseCMapReaderFactory=BaseCMapReaderFactory;class BaseStandardFontDataFactory{constructor({baseUrl:t=null}){this.constructor===BaseStandardFontDataFactory&&(0,n.unreachable)("Cannot initialize BaseStandardFontDataFactory.");this.baseUrl=t}async fetch({filename:t}){if(!this.baseUrl)throw new Error('The standard font "baseUrl" parameter must be specified, ensure that the "standardFontDataUrl" API parameter is provided.');if(!t)throw new Error("Font filename must be specified.");const e=`${this.baseUrl}${t}`;return this._fetchData(e).catch((t=>{throw new Error(`Unable to load font data at: ${e}`)}))}_fetchData(t){(0,n.unreachable)("Abstract method `_fetchData` called.")}}e.BaseStandardFontDataFactory=BaseStandardFontDataFactory;class BaseSVGFactory{constructor(){this.constructor===BaseSVGFactory&&(0,n.unreachable)("Cannot initialize BaseSVGFactory.")}create(t,e,s=!1){if(t<=0||e<=0)throw new Error("Invalid SVG dimensions");const n=this._createSVG("svg:svg");n.setAttribute("version","1.1");if(!s){n.setAttribute("width",`${t}px`);n.setAttribute("height",`${e}px`)}n.setAttribute("preserveAspectRatio","none");n.setAttribute("viewBox",`0 0 ${t} ${e}`);return n}createElement(t){if("string"!=typeof t)throw new Error("Invalid SVG element type");return this._createSVG(t)}_createSVG(t){(0,n.unreachable)("Abstract method `_createSVG` called.")}}e.BaseSVGFactory=BaseSVGFactory},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.MurmurHash3_64=void 0;var n=s(1);const i=3285377520,a=4294901760,r=65535;e.MurmurHash3_64=class MurmurHash3_64{constructor(t){this.h1=t?4294967295&t:i;this.h2=t?4294967295&t:i}update(t){let e,s;if("string"==typeof t){e=new Uint8Array(2*t.length);s=0;for(let n=0,i=t.length;n>>8;e[s++]=255&i}}}else{if(!(0,n.isArrayBuffer)(t))throw new Error("Wrong data format in MurmurHash3_64_update. Input must be a string or array.");e=t.slice();s=e.byteLength}const i=s>>2,o=s-4*i,l=new Uint32Array(e.buffer,0,i);let c=0,h=0,d=this.h1,u=this.h2;const p=3432918353,g=461845907,m=11601,f=13715;for(let t=0;t>>17;c=c*g&a|c*f&r;d^=c;d=d<<13|d>>>19;d=5*d+3864292196}else{h=l[t];h=h*p&a|h*m&r;h=h<<15|h>>>17;h=h*g&a|h*f&r;u^=h;u=u<<13|u>>>19;u=5*u+3864292196}c=0;switch(o){case 3:c^=e[4*i+2]<<16;case 2:c^=e[4*i+1]<<8;case 1:c^=e[4*i];c=c*p&a|c*m&r;c=c<<15|c>>>17;c=c*g&a|c*f&r;1&i?d^=c:u^=c}this.h1=d;this.h2=u}hexdigest(){let t=this.h1,e=this.h2;t^=e>>>1;t=3981806797*t&a|36045*t&r;e=4283543511*e&a|(2950163797*(e<<16|t>>>16)&a)>>>16;t^=e>>>1;t=444984403*t&a|60499*t&r;e=3301882366*e&a|(3120437893*(e<<16|t>>>16)&a)>>>16;t^=e>>>1;return(t>>>0).toString(16).padStart(8,"0")+(e>>>0).toString(16).padStart(8,"0")}}},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.FontLoader=e.FontFaceObject=void 0;var n=s(1),i=s(10);e.FontLoader=class FontLoader{constructor({onUnsupportedFeature:t,ownerDocument:e=globalThis.document,styleElement:s=null}){this._onUnsupportedFeature=t;this._document=e;this.nativeFontFaces=[];this.styleElement=null;this.loadingRequests=[];this.loadTestFontId=0}addNativeFontFace(t){this.nativeFontFaces.push(t);this._document.fonts.add(t)}insertRule(t){if(!this.styleElement){this.styleElement=this._document.createElement("style");this._document.documentElement.getElementsByTagName("head")[0].append(this.styleElement)}const e=this.styleElement.sheet;e.insertRule(t,e.cssRules.length)}clear(){for(const t of this.nativeFontFaces)this._document.fonts.delete(t);this.nativeFontFaces.length=0;if(this.styleElement){this.styleElement.remove();this.styleElement=null}}async bind(t){if(t.attached||t.missingFile)return;t.attached=!0;if(this.isFontLoadingAPISupported){const e=t.createNativeFontFace();if(e){this.addNativeFontFace(e);try{await e.loaded}catch(s){this._onUnsupportedFeature({featureId:n.UNSUPPORTED_FEATURES.errorFontLoadNative});(0,n.warn)(`Failed to load font '${e.family}': '${s}'.`);t.disableFontFace=!0;throw s}}return}const e=t.createFontFaceRule();if(e){this.insertRule(e);if(this.isSyncFontLoadingSupported)return;await new Promise((e=>{const s=this._queueLoadingCallback(e);this._prepareFontLoadEvent(t,s)}))}}get isFontLoadingAPISupported(){const t=!!this._document?.fonts;return(0,n.shadow)(this,"isFontLoadingAPISupported",t)}get isSyncFontLoadingSupported(){let t=!1;(i.isNodeJS||"undefined"!=typeof navigator&&/Mozilla\/5.0.*?rv:\d+.*? Gecko/.test(navigator.userAgent))&&(t=!0);return(0,n.shadow)(this,"isSyncFontLoadingSupported",t)}_queueLoadingCallback(t){const{loadingRequests:e}=this,s={done:!1,complete:function completeRequest(){(0,n.assert)(!s.done,"completeRequest() cannot be called twice.");s.done=!0;for(;e.length>0&&e[0].done;){const t=e.shift();setTimeout(t.callback,0)}},callback:t};e.push(s);return s}get _loadTestFont(){const t=atob("T1RUTwALAIAAAwAwQ0ZGIDHtZg4AAAOYAAAAgUZGVE1lkzZwAAAEHAAAABxHREVGABQAFQAABDgAAAAeT1MvMlYNYwkAAAEgAAAAYGNtYXABDQLUAAACNAAAAUJoZWFk/xVFDQAAALwAAAA2aGhlYQdkA+oAAAD0AAAAJGhtdHgD6AAAAAAEWAAAAAZtYXhwAAJQAAAAARgAAAAGbmFtZVjmdH4AAAGAAAAAsXBvc3T/hgAzAAADeAAAACAAAQAAAAEAALZRFsRfDzz1AAsD6AAAAADOBOTLAAAAAM4KHDwAAAAAA+gDIQAAAAgAAgAAAAAAAAABAAADIQAAAFoD6AAAAAAD6AABAAAAAAAAAAAAAAAAAAAAAQAAUAAAAgAAAAQD6AH0AAUAAAKKArwAAACMAooCvAAAAeAAMQECAAACAAYJAAAAAAAAAAAAAQAAAAAAAAAAAAAAAFBmRWQAwAAuAC4DIP84AFoDIQAAAAAAAQAAAAAAAAAAACAAIAABAAAADgCuAAEAAAAAAAAAAQAAAAEAAAAAAAEAAQAAAAEAAAAAAAIAAQAAAAEAAAAAAAMAAQAAAAEAAAAAAAQAAQAAAAEAAAAAAAUAAQAAAAEAAAAAAAYAAQAAAAMAAQQJAAAAAgABAAMAAQQJAAEAAgABAAMAAQQJAAIAAgABAAMAAQQJAAMAAgABAAMAAQQJAAQAAgABAAMAAQQJAAUAAgABAAMAAQQJAAYAAgABWABYAAAAAAAAAwAAAAMAAAAcAAEAAAAAADwAAwABAAAAHAAEACAAAAAEAAQAAQAAAC7//wAAAC7////TAAEAAAAAAAABBgAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAAAAAD/gwAyAAAAAQAAAAAAAAAAAAAAAAAAAAABAAQEAAEBAQJYAAEBASH4DwD4GwHEAvgcA/gXBIwMAYuL+nz5tQXkD5j3CBLnEQACAQEBIVhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYAAABAQAADwACAQEEE/t3Dov6fAH6fAT+fPp8+nwHDosMCvm1Cvm1DAz6fBQAAAAAAAABAAAAAMmJbzEAAAAAzgTjFQAAAADOBOQpAAEAAAAAAAAADAAUAAQAAAABAAAAAgABAAAAAAAAAAAD6AAAAAAAAA==");return(0,n.shadow)(this,"_loadTestFont",t)}_prepareFontLoadEvent(t,e){function int32(t,e){return t.charCodeAt(e)<<24|t.charCodeAt(e+1)<<16|t.charCodeAt(e+2)<<8|255&t.charCodeAt(e+3)}function spliceString(t,e,s,n){return t.substring(0,e)+n+t.substring(e+s)}let s,i;const a=this._document.createElement("canvas");a.width=1;a.height=1;const r=a.getContext("2d");let o=0;const l=`lt${Date.now()}${this.loadTestFontId++}`;let c=this._loadTestFont;c=spliceString(c,976,l.length,l);const h=1482184792;let d=int32(c,16);for(s=0,i=l.length-3;s30){(0,n.warn)("Load test font never loaded.");e();return}r.font="30px "+t;r.fillText(".",0,20);r.getImageData(0,0,1,1).data[3]>0?e():setTimeout(isFontReady.bind(null,t,e))}(l,(()=>{p.remove();e.complete()}))}};e.FontFaceObject=class FontFaceObject{constructor(t,{isEvalSupported:e=!0,disableFontFace:s=!1,ignoreErrors:n=!1,onUnsupportedFeature:i,fontRegistry:a=null}){this.compiledGlyphs=Object.create(null);for(const e in t)this[e]=t[e];this.isEvalSupported=!1!==e;this.disableFontFace=!0===s;this.ignoreErrors=!0===n;this._onUnsupportedFeature=i;this.fontRegistry=a}createNativeFontFace(){if(!this.data||this.disableFontFace)return null;let t;if(this.cssFontInfo){const e={weight:this.cssFontInfo.fontWeight};this.cssFontInfo.italicAngle&&(e.style=`oblique ${this.cssFontInfo.italicAngle}deg`);t=new FontFace(this.cssFontInfo.fontFamily,this.data,e)}else t=new FontFace(this.loadedName,this.data,{});this.fontRegistry?.registerFont(this);return t}createFontFaceRule(){if(!this.data||this.disableFontFace)return null;const t=(0,n.bytesToString)(this.data),e=`url(data:${this.mimetype};base64,${btoa(t)});`;let s;if(this.cssFontInfo){let t=`font-weight: ${this.cssFontInfo.fontWeight};`;this.cssFontInfo.italicAngle&&(t+=`font-style: oblique ${this.cssFontInfo.italicAngle}deg;`);s=`@font-face {font-family:"${this.cssFontInfo.fontFamily}";${t}src:${e}}`}else s=`@font-face {font-family:"${this.loadedName}";src:${e}}`;this.fontRegistry?.registerFont(this,e);return s}getPathGenerator(t,e){if(void 0!==this.compiledGlyphs[e])return this.compiledGlyphs[e];let s;try{s=t.get(this.loadedName+"_path_"+e)}catch(t){if(!this.ignoreErrors)throw t;this._onUnsupportedFeature({featureId:n.UNSUPPORTED_FEATURES.errorFontGetPath});(0,n.warn)(`getPathGenerator - ignoring character: "${t}".`);return this.compiledGlyphs[e]=function(t,e){}}if(this.isEvalSupported&&n.FeatureTest.isEvalSupported){const t=[];for(const e of s){const s=void 0!==e.args?e.args.join(","):"";t.push("c.",e.cmd,"(",s,");\n")}return this.compiledGlyphs[e]=new Function("c","size",t.join(""))}return this.compiledGlyphs[e]=function(t,e){for(const n of s){"scale"===n.cmd&&(n.args=[e,-e]);t[n.cmd].apply(t,n.args)}}}}},(t,e)=>{Object.defineProperty(e,"__esModule",{value:!0});e.isNodeJS=void 0;const s=!("object"!=typeof process||process+""!="[object process]"||process.versions.nw||process.versions.electron&&process.type&&"browser"!==process.type);e.isNodeJS=s},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.CanvasGraphics=void 0;var n=s(1),i=s(6),a=s(12),r=s(13),o=s(10);const l=4096,c=o.isNodeJS&&"undefined"==typeof Path2D?-1:1e3,h=16;class CachedCanvases{constructor(t){this.canvasFactory=t;this.cache=Object.create(null)}getCanvas(t,e,s){let n;if(void 0!==this.cache[t]){n=this.cache[t];this.canvasFactory.reset(n,e,s)}else{n=this.canvasFactory.create(e,s);this.cache[t]=n}return n}delete(t){delete this.cache[t]}clear(){for(const t in this.cache){const e=this.cache[t];this.canvasFactory.destroy(e);delete this.cache[t]}}}function drawImageAtIntegerCoords(t,e,s,n,a,r,o,l,c,h){const[d,u,p,g,m,f]=(0,i.getCurrentTransform)(t);if(0===u&&0===p){const i=o*d+m,b=Math.round(i),A=l*g+f,_=Math.round(A),y=(o+c)*d+m,v=Math.abs(Math.round(y)-b)||1,S=(l+h)*g+f,x=Math.abs(Math.round(S)-_)||1;t.setTransform(Math.sign(d),0,0,Math.sign(g),b,_);t.drawImage(e,s,n,a,r,0,0,v,x);t.setTransform(d,u,p,g,m,f);return[v,x]}if(0===d&&0===g){const i=l*p+m,b=Math.round(i),A=o*u+f,_=Math.round(A),y=(l+h)*p+m,v=Math.abs(Math.round(y)-b)||1,S=(o+c)*u+f,x=Math.abs(Math.round(S)-_)||1;t.setTransform(0,Math.sign(u),Math.sign(p),0,b,_);t.drawImage(e,s,n,a,r,0,0,x,v);t.setTransform(d,u,p,g,m,f);return[x,v]}t.drawImage(e,s,n,a,r,o,l,c,h);return[Math.hypot(d,u)*c,Math.hypot(p,g)*h]}class CanvasExtraState{constructor(t,e){this.alphaIsShape=!1;this.fontSize=0;this.fontSizeScale=1;this.textMatrix=n.IDENTITY_MATRIX;this.textMatrixScale=1;this.fontMatrix=n.FONT_IDENTITY_MATRIX;this.leading=0;this.x=0;this.y=0;this.lineX=0;this.lineY=0;this.charSpacing=0;this.wordSpacing=0;this.textHScale=1;this.textRenderingMode=n.TextRenderingMode.FILL;this.textRise=0;this.fillColor="#000000";this.strokeColor="#000000";this.patternFill=!1;this.fillAlpha=1;this.strokeAlpha=1;this.lineWidth=1;this.activeSMask=null;this.transferMaps=null;this.startNewPathAndClipBox([0,0,t,e])}clone(){const t=Object.create(this);t.clipBox=this.clipBox.slice();return t}setCurrentPoint(t,e){this.x=t;this.y=e}updatePathMinMax(t,e,s){[e,s]=n.Util.applyTransform([e,s],t);this.minX=Math.min(this.minX,e);this.minY=Math.min(this.minY,s);this.maxX=Math.max(this.maxX,e);this.maxY=Math.max(this.maxY,s)}updateRectMinMax(t,e){const s=n.Util.applyTransform(e,t),i=n.Util.applyTransform(e.slice(2),t);this.minX=Math.min(this.minX,s[0],i[0]);this.minY=Math.min(this.minY,s[1],i[1]);this.maxX=Math.max(this.maxX,s[0],i[0]);this.maxY=Math.max(this.maxY,s[1],i[1])}updateScalingPathMinMax(t,e){n.Util.scaleMinMax(t,e);this.minX=Math.min(this.minX,e[0]);this.maxX=Math.max(this.maxX,e[1]);this.minY=Math.min(this.minY,e[2]);this.maxY=Math.max(this.maxY,e[3])}updateCurvePathMinMax(t,e,s,i,a,r,o,l,c,h){const d=n.Util.bezierBoundingBox(e,s,i,a,r,o,l,c);if(h){h[0]=Math.min(h[0],d[0],d[2]);h[1]=Math.max(h[1],d[0],d[2]);h[2]=Math.min(h[2],d[1],d[3]);h[3]=Math.max(h[3],d[1],d[3])}else this.updateRectMinMax(t,d)}getPathBoundingBox(t=a.PathType.FILL,e=null){const s=[this.minX,this.minY,this.maxX,this.maxY];if(t===a.PathType.STROKE){e||(0,n.unreachable)("Stroke bounding box must include transform.");const t=n.Util.singularValueDecompose2dScale(e),i=t[0]*this.lineWidth/2,a=t[1]*this.lineWidth/2;s[0]-=i;s[1]-=a;s[2]+=i;s[3]+=a}return s}updateClipFromPath(){const t=n.Util.intersect(this.clipBox,this.getPathBoundingBox());this.startNewPathAndClipBox(t||[0,0,0,0])}isEmptyClip(){return this.minX===1/0}startNewPathAndClipBox(t){this.clipBox=t;this.minX=1/0;this.minY=1/0;this.maxX=0;this.maxY=0}getClippedPathBoundingBox(t=a.PathType.FILL,e=null){return n.Util.intersect(this.clipBox,this.getPathBoundingBox(t,e))}}function putBinaryImageData(t,e,s=null){if("undefined"!=typeof ImageData&&e instanceof ImageData){t.putImageData(e,0,0);return}const i=e.height,a=e.width,r=i%h,o=(i-r)/h,l=0===r?o:o+1,c=t.createImageData(a,h);let d,u=0;const p=e.data,g=c.data;let m,f,b,A,_,y,v,S;if(s)switch(s.length){case 1:_=s[0];y=s[0];v=s[0];S=s[0];break;case 4:_=s[0];y=s[1];v=s[2];S=s[3]}if(e.kind===n.ImageKind.GRAYSCALE_1BPP){const e=p.byteLength,s=new Uint32Array(g.buffer,0,g.byteLength>>2),i=s.length,A=a+7>>3;let _=4294967295,y=n.FeatureTest.isLittleEndian?4278190080:255;S&&255===S[0]&&0===S[255]&&([_,y]=[y,_]);for(m=0;mA?a:8*t-7,r=-8&i;let o=0,l=0;for(;n>=1}}for(;d=o){b=r;A=a*b}d=0;for(f=A;f--;){g[d++]=p[u++];g[d++]=p[u++];g[d++]=p[u++];g[d++]=255}if(e)for(let t=0;t>8;t[a-2]=t[a-2]*i+s*r>>8;t[a-1]=t[a-1]*i+n*r>>8}}}function composeSMaskAlpha(t,e,s){const n=t.length;for(let i=3;i>8]>>8:e[i]*n>>16}}function composeSMask(t,e,s,n){const i=n[0],a=n[1],r=n[2]-i,o=n[3]-a;if(0!==r&&0!==o){!function genericComposeSMask(t,e,s,n,i,a,r,o,l,c,h){const d=!!a,u=d?a[0]:0,p=d?a[1]:0,g=d?a[2]:0;let m;m="Luminosity"===i?composeSMaskLuminosity:composeSMaskAlpha;const f=Math.min(n,Math.ceil(1048576/s));for(let i=0;i(t/=255)<=.03928?t/12.92:((t+.055)/1.055)**2.4,o=Math.round(.2126*newComp(s)+.7152*newComp(a)+.0722*newComp(r));this.selectColor=(s,n,i)=>{const a=.2126*newComp(s)+.7152*newComp(n)+.0722*newComp(i);return Math.round(a)===o?e:t}}}this.ctx.fillStyle=this.backgroundColor||o;this.ctx.fillRect(0,0,a,r);this.ctx.restore();if(s){const t=this.cachedCanvases.getCanvas("transparent",a,r);this.compositeCtx=this.ctx;this.transparentCanvas=t.canvas;this.ctx=t.context;this.ctx.save();this.ctx.transform(...(0,i.getCurrentTransform)(this.compositeCtx))}this.ctx.save();resetCtxToDefault(this.ctx,this.foregroundColor);if(t){this.ctx.transform(...t);this.outputScaleX=t[0];this.outputScaleY=t[0]}this.ctx.transform(...e.transform);this.viewportScale=e.scale;this.baseTransform=(0,i.getCurrentTransform)(this.ctx)}executeOperatorList(t,e,s,i){const a=t.argsArray,r=t.fnArray;let o=e||0;const l=a.length;if(l===o)return o;const c=l-o>10&&"function"==typeof s,h=c?Date.now()+15:0;let d=0;const u=this.commonObjs,p=this.objs;let g;for(;;){if(void 0!==i&&o===i.nextBreakPoint){i.breakIt(o,s);return o}g=r[o];if(g!==n.OPS.dependency)this[g].apply(this,a[o]);else for(const t of a[o]){const e=t.startsWith("g_")?u:p;if(!e.has(t)){e.get(t,s);return o}}o++;if(o===l)return o;if(c&&++d>10){if(Date.now()>h){s();return o}d=0}}}#ct(){for(;this.stateStack.length||this.inSMaskMode;)this.restore();this.ctx.restore();if(this.transparentCanvas){this.ctx=this.compositeCtx;this.ctx.save();this.ctx.setTransform(1,0,0,1,0,0);this.ctx.drawImage(this.transparentCanvas,0,0);this.ctx.restore();this.transparentCanvas=null}}endDrawing(){this.#ct();this.cachedCanvases.clear();this.cachedPatterns.clear();for(const t of this._cachedBitmapsMap.values()){for(const e of t.values())"undefined"!=typeof HTMLCanvasElement&&e instanceof HTMLCanvasElement&&(e.width=e.height=0);t.clear()}this._cachedBitmapsMap.clear()}_scaleImage(t,e){const s=t.width,n=t.height;let i,a,r=Math.max(Math.hypot(e[0],e[1]),1),o=Math.max(Math.hypot(e[2],e[3]),1),l=s,c=n,h="prescale1";for(;r>2&&l>1||o>2&&c>1;){let e=l,s=c;if(r>2&&l>1){e=Math.ceil(l/2);r/=l/e}if(o>2&&c>1){s=Math.ceil(c/2);o/=c/s}i=this.cachedCanvases.getCanvas(h,e,s);a=i.context;a.clearRect(0,0,e,s);a.drawImage(t,0,0,l,c,0,0,e,s);t=i.canvas;l=e;c=s;h="prescale1"===h?"prescale2":"prescale1"}return{img:t,paintWidth:l,paintHeight:c}}_createMaskCanvas(t){const e=this.ctx,{width:s,height:r}=t,o=this.current.fillColor,l=this.current.patternFill,c=(0,i.getCurrentTransform)(e);let h,d,u,p;if((t.bitmap||t.data)&&t.count>1){const e=t.bitmap||t.data.buffer;d=JSON.stringify(l?c:[c.slice(0,4),o]);h=this._cachedBitmapsMap.get(e);if(!h){h=new Map;this._cachedBitmapsMap.set(e,h)}const s=h.get(d);if(s&&!l){return{canvas:s,offsetX:Math.round(Math.min(c[0],c[2])+c[4]),offsetY:Math.round(Math.min(c[1],c[3])+c[5])}}u=s}if(!u){p=this.cachedCanvases.getCanvas("maskCanvas",s,r);putBinaryImageMask(p.context,t)}let g=n.Util.transform(c,[1/s,0,0,-1/r,0,0]);g=n.Util.transform(g,[1,0,0,1,0,-r]);const m=n.Util.applyTransform([0,0],g),f=n.Util.applyTransform([s,r],g),b=n.Util.normalizeRect([m[0],m[1],f[0],f[1]]),A=Math.round(b[2]-b[0])||1,_=Math.round(b[3]-b[1])||1,y=this.cachedCanvases.getCanvas("fillCanvas",A,_),v=y.context,S=Math.min(m[0],f[0]),x=Math.min(m[1],f[1]);v.translate(-S,-x);v.transform(...g);if(!u){u=this._scaleImage(p.canvas,(0,i.getCurrentTransformInverse)(v));u=u.img;h&&l&&h.set(d,u)}v.imageSmoothingEnabled=getImageSmoothingEnabled((0,i.getCurrentTransform)(v),t.interpolate);drawImageAtIntegerCoords(v,u,0,0,u.width,u.height,0,0,s,r);v.globalCompositeOperation="source-in";const E=n.Util.transform((0,i.getCurrentTransformInverse)(v),[1,0,0,1,-S,-x]);v.fillStyle=l?o.getPattern(e,this,E,a.PathType.FILL):o;v.fillRect(0,0,s,r);if(h&&!l){this.cachedCanvases.delete("fillCanvas");h.set(d,y.canvas)}return{canvas:y.canvas,offsetX:Math.round(S),offsetY:Math.round(x)}}setLineWidth(t){t!==this.current.lineWidth&&(this._cachedScaleForStroking=null);this.current.lineWidth=t;this.ctx.lineWidth=t}setLineCap(t){this.ctx.lineCap=d[t]}setLineJoin(t){this.ctx.lineJoin=u[t]}setMiterLimit(t){this.ctx.miterLimit=t}setDash(t,e){const s=this.ctx;if(void 0!==s.setLineDash){s.setLineDash(t);s.lineDashOffset=e}}setRenderingIntent(t){}setFlatness(t){}setGState(t){for(const[e,s]of t)switch(e){case"LW":this.setLineWidth(s);break;case"LC":this.setLineCap(s);break;case"LJ":this.setLineJoin(s);break;case"ML":this.setMiterLimit(s);break;case"D":this.setDash(s[0],s[1]);break;case"RI":this.setRenderingIntent(s);break;case"FL":this.setFlatness(s);break;case"Font":this.setFont(s[0],s[1]);break;case"CA":this.current.strokeAlpha=s;break;case"ca":this.current.fillAlpha=s;this.ctx.globalAlpha=s;break;case"BM":this.ctx.globalCompositeOperation=s;break;case"SMask":this.current.activeSMask=s?this.tempSMask:null;this.tempSMask=null;this.checkSMaskState();break;case"TR":this.current.transferMaps=s}}get inSMaskMode(){return!!this.suspendedCtx}checkSMaskState(){const t=this.inSMaskMode;this.current.activeSMask&&!t?this.beginSMaskMode():!this.current.activeSMask&&t&&this.endSMaskMode()}beginSMaskMode(){if(this.inSMaskMode)throw new Error("beginSMaskMode called while already in smask mode");const t=this.ctx.canvas.width,e=this.ctx.canvas.height,s="smaskGroupAt"+this.groupLevel,n=this.cachedCanvases.getCanvas(s,t,e);this.suspendedCtx=this.ctx;this.ctx=n.context;const a=this.ctx;a.setTransform(...(0,i.getCurrentTransform)(this.suspendedCtx));copyCtxState(this.suspendedCtx,a);!function mirrorContextOperations(t,e){if(t._removeMirroring)throw new Error("Context is already forwarding operations.");t.__originalSave=t.save;t.__originalRestore=t.restore;t.__originalRotate=t.rotate;t.__originalScale=t.scale;t.__originalTranslate=t.translate;t.__originalTransform=t.transform;t.__originalSetTransform=t.setTransform;t.__originalResetTransform=t.resetTransform;t.__originalClip=t.clip;t.__originalMoveTo=t.moveTo;t.__originalLineTo=t.lineTo;t.__originalBezierCurveTo=t.bezierCurveTo;t.__originalRect=t.rect;t.__originalClosePath=t.closePath;t.__originalBeginPath=t.beginPath;t._removeMirroring=()=>{t.save=t.__originalSave;t.restore=t.__originalRestore;t.rotate=t.__originalRotate;t.scale=t.__originalScale;t.translate=t.__originalTranslate;t.transform=t.__originalTransform;t.setTransform=t.__originalSetTransform;t.resetTransform=t.__originalResetTransform;t.clip=t.__originalClip;t.moveTo=t.__originalMoveTo;t.lineTo=t.__originalLineTo;t.bezierCurveTo=t.__originalBezierCurveTo;t.rect=t.__originalRect;t.closePath=t.__originalClosePath;t.beginPath=t.__originalBeginPath;delete t._removeMirroring};t.save=function ctxSave(){e.save();this.__originalSave()};t.restore=function ctxRestore(){e.restore();this.__originalRestore()};t.translate=function ctxTranslate(t,s){e.translate(t,s);this.__originalTranslate(t,s)};t.scale=function ctxScale(t,s){e.scale(t,s);this.__originalScale(t,s)};t.transform=function ctxTransform(t,s,n,i,a,r){e.transform(t,s,n,i,a,r);this.__originalTransform(t,s,n,i,a,r)};t.setTransform=function ctxSetTransform(t,s,n,i,a,r){e.setTransform(t,s,n,i,a,r);this.__originalSetTransform(t,s,n,i,a,r)};t.resetTransform=function ctxResetTransform(){e.resetTransform();this.__originalResetTransform()};t.rotate=function ctxRotate(t){e.rotate(t);this.__originalRotate(t)};t.clip=function ctxRotate(t){e.clip(t);this.__originalClip(t)};t.moveTo=function(t,s){e.moveTo(t,s);this.__originalMoveTo(t,s)};t.lineTo=function(t,s){e.lineTo(t,s);this.__originalLineTo(t,s)};t.bezierCurveTo=function(t,s,n,i,a,r){e.bezierCurveTo(t,s,n,i,a,r);this.__originalBezierCurveTo(t,s,n,i,a,r)};t.rect=function(t,s,n,i){e.rect(t,s,n,i);this.__originalRect(t,s,n,i)};t.closePath=function(){e.closePath();this.__originalClosePath()};t.beginPath=function(){e.beginPath();this.__originalBeginPath()}}(a,this.suspendedCtx);this.setGState([["BM","source-over"],["ca",1],["CA",1]])}endSMaskMode(){if(!this.inSMaskMode)throw new Error("endSMaskMode called while not in smask mode");this.ctx._removeMirroring();copyCtxState(this.ctx,this.suspendedCtx);this.ctx=this.suspendedCtx;this.suspendedCtx=null}compose(t){if(!this.current.activeSMask)return;if(t){t[0]=Math.floor(t[0]);t[1]=Math.floor(t[1]);t[2]=Math.ceil(t[2]);t[3]=Math.ceil(t[3])}else t=[0,0,this.ctx.canvas.width,this.ctx.canvas.height];const e=this.current.activeSMask;composeSMask(this.suspendedCtx,e,this.ctx,t);this.ctx.save();this.ctx.setTransform(1,0,0,1,0,0);this.ctx.clearRect(0,0,this.ctx.canvas.width,this.ctx.canvas.height);this.ctx.restore()}save(){if(this.inSMaskMode){copyCtxState(this.ctx,this.suspendedCtx);this.suspendedCtx.save()}else this.ctx.save();const t=this.current;this.stateStack.push(t);this.current=t.clone()}restore(){0===this.stateStack.length&&this.inSMaskMode&&this.endSMaskMode();if(0!==this.stateStack.length){this.current=this.stateStack.pop();if(this.inSMaskMode){this.suspendedCtx.restore();copyCtxState(this.suspendedCtx,this.ctx)}else this.ctx.restore();this.checkSMaskState();this.pendingClip=null;this._cachedScaleForStroking=null;this._cachedGetSinglePixelWidth=null}}transform(t,e,s,n,i,a){this.ctx.transform(t,e,s,n,i,a);this._cachedScaleForStroking=null;this._cachedGetSinglePixelWidth=null}constructPath(t,e,s){const a=this.ctx,r=this.current;let o,l,c=r.x,h=r.y;const d=(0,i.getCurrentTransform)(a),u=0===d[0]&&0===d[3]||0===d[1]&&0===d[2],p=u?s.slice(0):null;for(let s=0,i=0,g=t.length;s100&&(c=100);this.current.fontSizeScale=e/c;this.ctx.font=`${o} ${r} ${c}px ${l}`}setTextRenderingMode(t){this.current.textRenderingMode=t}setTextRise(t){this.current.textRise=t}moveText(t,e){this.current.x=this.current.lineX+=t;this.current.y=this.current.lineY+=e}setLeadingMoveText(t,e){this.setLeading(-e);this.moveText(t,e)}setTextMatrix(t,e,s,n,i,a){this.current.textMatrix=[t,e,s,n,i,a];this.current.textMatrixScale=Math.hypot(t,e);this.current.x=this.current.lineX=0;this.current.y=this.current.lineY=0}nextLine(){this.moveText(0,this.current.leading)}paintChar(t,e,s,a){const r=this.ctx,o=this.current,l=o.font,c=o.textRenderingMode,h=o.fontSize/o.fontSizeScale,d=c&n.TextRenderingMode.FILL_STROKE_MASK,u=!!(c&n.TextRenderingMode.ADD_TO_PATH_FLAG),p=o.patternFill&&!l.missingFile;let g;(l.disableFontFace||u||p)&&(g=l.getPathGenerator(this.commonObjs,t));if(l.disableFontFace||p){r.save();r.translate(e,s);r.beginPath();g(r,h);a&&r.setTransform(...a);d!==n.TextRenderingMode.FILL&&d!==n.TextRenderingMode.FILL_STROKE||r.fill();d!==n.TextRenderingMode.STROKE&&d!==n.TextRenderingMode.FILL_STROKE||r.stroke();r.restore()}else{d!==n.TextRenderingMode.FILL&&d!==n.TextRenderingMode.FILL_STROKE||r.fillText(t,e,s);d!==n.TextRenderingMode.STROKE&&d!==n.TextRenderingMode.FILL_STROKE||r.strokeText(t,e,s)}if(u){(this.pendingTextPaths||(this.pendingTextPaths=[])).push({transform:(0,i.getCurrentTransform)(r),x:e,y:s,fontSize:h,addToPath:g})}}get isFontSubpixelAAEnabled(){const{context:t}=this.cachedCanvases.getCanvas("isFontSubpixelAAEnabled",10,10);t.scale(1.5,1);t.fillText("I",0,10);const e=t.getImageData(0,0,10,10).data;let s=!1;for(let t=3;t0&&e[t]<255){s=!0;break}return(0,n.shadow)(this,"isFontSubpixelAAEnabled",s)}showText(t){const e=this.current,s=e.font;if(s.isType3Font)return this.showType3Text(t);const r=e.fontSize;if(0===r)return;const o=this.ctx,l=e.fontSizeScale,c=e.charSpacing,h=e.wordSpacing,d=e.fontDirection,u=e.textHScale*d,p=t.length,g=s.vertical,m=g?1:-1,f=s.defaultVMetrics,b=r*e.fontMatrix[0],A=e.textRenderingMode===n.TextRenderingMode.FILL&&!s.disableFontFace&&!e.patternFill;o.save();o.transform(...e.textMatrix);o.translate(e.x,e.y+e.textRise);d>0?o.scale(u,-1):o.scale(u,1);let _;if(e.patternFill){o.save();const t=e.fillColor.getPattern(o,this,(0,i.getCurrentTransformInverse)(o),a.PathType.FILL);_=(0,i.getCurrentTransform)(o);o.restore();o.fillStyle=t}let y=e.lineWidth;const v=e.textMatrixScale;if(0===v||0===y){const t=e.textRenderingMode&n.TextRenderingMode.FILL_STROKE_MASK;t!==n.TextRenderingMode.STROKE&&t!==n.TextRenderingMode.FILL_STROKE||(y=this.getSinglePixelWidth())}else y/=v;if(1!==l){o.scale(l,l);y/=l}o.lineWidth=y;if(s.isInvalidPDFjsFont){const s=[];let n=0;for(const e of t){s.push(e.unicode);n+=e.width}o.fillText(s.join(""),0,0);e.x+=n*b*u;o.restore();this.compose();return}let S,x=0;for(S=0;S0){const t=1e3*o.measureText(a).width/r*l;if(Enew CanvasGraphics(t,this.commonObjs,this.objs,this.canvasFactory,{optionalContentConfig:this.optionalContentConfig,markedContentStack:this.markedContentStack})};e=new a.TilingPattern(t,s,this.ctx,r,n)}else e=this._getPattern(t[1],t[2]);return e}setStrokeColorN(){this.current.strokeColor=this.getColorN_Pattern(arguments)}setFillColorN(){this.current.fillColor=this.getColorN_Pattern(arguments);this.current.patternFill=!0}setStrokeRGBColor(t,e,s){const i=this.selectColor?.(t,e,s)||n.Util.makeHexColor(t,e,s);this.ctx.strokeStyle=i;this.current.strokeColor=i}setFillRGBColor(t,e,s){const i=this.selectColor?.(t,e,s)||n.Util.makeHexColor(t,e,s);this.ctx.fillStyle=i;this.current.fillColor=i;this.current.patternFill=!1}_getPattern(t,e=null){let s;if(this.cachedPatterns.has(t))s=this.cachedPatterns.get(t);else{s=(0,a.getShadingPattern)(this.objs.get(t));this.cachedPatterns.set(t,s)}e&&(s.matrix=e);return s}shadingFill(t){if(!this.contentVisible)return;const e=this.ctx;this.save();const s=this._getPattern(t);e.fillStyle=s.getPattern(e,this,(0,i.getCurrentTransformInverse)(e),a.PathType.SHADING);const r=(0,i.getCurrentTransformInverse)(e);if(r){const t=e.canvas,s=t.width,i=t.height,a=n.Util.applyTransform([0,0],r),o=n.Util.applyTransform([0,i],r),l=n.Util.applyTransform([s,0],r),c=n.Util.applyTransform([s,i],r),h=Math.min(a[0],o[0],l[0],c[0]),d=Math.min(a[1],o[1],l[1],c[1]),u=Math.max(a[0],o[0],l[0],c[0]),p=Math.max(a[1],o[1],l[1],c[1]);this.ctx.fillRect(h,d,u-h,p-d)}else this.ctx.fillRect(-1e10,-1e10,2e10,2e10);this.compose(this.current.getClippedPathBoundingBox());this.restore()}beginInlineImage(){(0,n.unreachable)("Should not call beginInlineImage")}beginImageData(){(0,n.unreachable)("Should not call beginImageData")}paintFormXObjectBegin(t,e){if(this.contentVisible){this.save();this.baseTransformStack.push(this.baseTransform);Array.isArray(t)&&6===t.length&&this.transform(...t);this.baseTransform=(0,i.getCurrentTransform)(this.ctx);if(e){const t=e[2]-e[0],s=e[3]-e[1];this.ctx.rect(e[0],e[1],t,s);this.current.updateRectMinMax((0,i.getCurrentTransform)(this.ctx),e);this.clip();this.endPath()}}}paintFormXObjectEnd(){if(this.contentVisible){this.restore();this.baseTransform=this.baseTransformStack.pop()}}beginGroup(t){if(!this.contentVisible)return;this.save();if(this.inSMaskMode){this.endSMaskMode();this.current.activeSMask=null}const e=this.ctx;t.isolated||(0,n.info)("TODO: Support non-isolated groups.");t.knockout&&(0,n.warn)("Knockout groups not supported.");const s=(0,i.getCurrentTransform)(e);t.matrix&&e.transform(...t.matrix);if(!t.bbox)throw new Error("Bounding box is required.");let a=n.Util.getAxialAlignedBoundingBox(t.bbox,(0,i.getCurrentTransform)(e));const r=[0,0,e.canvas.width,e.canvas.height];a=n.Util.intersect(a,r)||[0,0,0,0];const o=Math.floor(a[0]),c=Math.floor(a[1]);let h=Math.max(Math.ceil(a[2])-o,1),d=Math.max(Math.ceil(a[3])-c,1),u=1,p=1;if(h>l){u=h/l;h=l}if(d>l){p=d/l;d=l}this.current.startNewPathAndClipBox([0,0,h,d]);let g="groupAt"+this.groupLevel;t.smask&&(g+="_smask_"+this.smaskCounter++%2);const m=this.cachedCanvases.getCanvas(g,h,d),f=m.context;f.scale(1/u,1/p);f.translate(-o,-c);f.transform(...s);if(t.smask)this.smaskStack.push({canvas:m.canvas,context:f,offsetX:o,offsetY:c,scaleX:u,scaleY:p,subtype:t.smask.subtype,backdrop:t.smask.backdrop,transferMap:t.smask.transferMap||null,startTransformInverse:null});else{e.setTransform(1,0,0,1,0,0);e.translate(o,c);e.scale(u,p);e.save()}copyCtxState(e,f);this.ctx=f;this.setGState([["BM","source-over"],["ca",1],["CA",1]]);this.groupStack.push(e);this.groupLevel++}endGroup(t){if(!this.contentVisible)return;this.groupLevel--;const e=this.ctx,s=this.groupStack.pop();this.ctx=s;this.ctx.imageSmoothingEnabled=!1;if(t.smask){this.tempSMask=this.smaskStack.pop();this.restore()}else{this.ctx.restore();const t=(0,i.getCurrentTransform)(this.ctx);this.restore();this.ctx.save();this.ctx.setTransform(...t);const s=n.Util.getAxialAlignedBoundingBox([0,0,e.canvas.width,e.canvas.height],t);this.ctx.drawImage(e.canvas,0,0);this.ctx.restore();this.compose(s)}}beginAnnotation(t,e,s,a,r){this.#ct();resetCtxToDefault(this.ctx,this.foregroundColor);this.ctx.save();this.save();this.baseTransform&&this.ctx.setTransform(...this.baseTransform);if(Array.isArray(e)&&4===e.length){const a=e[2]-e[0],o=e[3]-e[1];if(r&&this.annotationCanvasMap){(s=s.slice())[4]-=e[0];s[5]-=e[1];(e=e.slice())[0]=e[1]=0;e[2]=a;e[3]=o;const[r,l]=n.Util.singularValueDecompose2dScale((0,i.getCurrentTransform)(this.ctx)),{viewportScale:c}=this,h=Math.ceil(a*this.outputScaleX*c),d=Math.ceil(o*this.outputScaleY*c);this.annotationCanvas=this.canvasFactory.create(h,d);const{canvas:u,context:p}=this.annotationCanvas;this.annotationCanvasMap.set(t,u);this.annotationCanvas.savedCtx=this.ctx;this.ctx=p;this.ctx.setTransform(r,0,0,-l,0,o*l);resetCtxToDefault(this.ctx,this.foregroundColor)}else{resetCtxToDefault(this.ctx,this.foregroundColor);this.ctx.rect(e[0],e[1],a,o);this.ctx.clip();this.endPath()}}this.current=new CanvasExtraState(this.ctx.canvas.width,this.ctx.canvas.height);this.transform(...s);this.transform(...a)}endAnnotation(){if(this.annotationCanvas){this.ctx=this.annotationCanvas.savedCtx;delete this.annotationCanvas.savedCtx;delete this.annotationCanvas}}paintImageMaskXObject(t){if(!this.contentVisible)return;const e=t.count;(t=this.getObject(t.data,t)).count=e;const s=this.ctx,n=this.processingType3;if(n){void 0===n.compiled&&(n.compiled=function compileType3Glyph(t){const{width:e,height:s}=t;if(e>c||s>c)return null;const n=new Uint8Array([0,2,4,0,1,0,5,4,8,10,0,8,0,2,1,0]),i=e+1;let a,r,o,l=new Uint8Array(i*(s+1));const h=e+7&-8;let d=new Uint8Array(h*s),u=0;for(const e of t.data){let t=128;for(;t>0;){d[u++]=e&t?0:255;t>>=1}}let p=0;u=0;if(0!==d[u]){l[0]=1;++p}for(r=1;r>2)+(d[u+1]?4:0)+(d[u-h+1]?8:0);if(n[t]){l[o+r]=n[t];++p}u++}if(d[u-h]!==d[u]){l[o+r]=d[u]?2:4;++p}if(p>1e3)return null}u=h*(s-1);o=a*i;if(0!==d[u]){l[o]=8;++p}for(r=1;r1e3)return null;const g=new Int32Array([0,i,-1,0,-i,0,0,0,1]),m=new Path2D;for(a=0;p&&a<=s;a++){let t=a*i;const s=t+e;for(;t>4;l[t]&=r>>2|r<<2}m.lineTo(t%i,t/i|0);l[t]||--p}while(n!==t);--a}d=null;l=null;return function(t){t.save();t.scale(1/e,-1/s);t.translate(0,-s);t.fill(m);t.beginPath();t.restore()}}(t));if(n.compiled){n.compiled(s);return}}const i=this._createMaskCanvas(t),a=i.canvas;s.save();s.setTransform(1,0,0,1,0,0);s.drawImage(a,i.offsetX,i.offsetY);s.restore();this.compose()}paintImageMaskXObjectRepeat(t,e,s=0,a=0,r,o){if(!this.contentVisible)return;t=this.getObject(t.data,t);const l=this.ctx;l.save();const c=(0,i.getCurrentTransform)(l);l.transform(e,s,a,r,0,0);const h=this._createMaskCanvas(t);l.setTransform(1,0,0,1,h.offsetX-c[4],h.offsetY-c[5]);for(let t=0,i=o.length;te?r/e:1;n=a>e?a/e:1}}this._cachedScaleForStroking=[s,n]}return this._cachedScaleForStroking}rescaleAndStroke(t){const{ctx:e}=this,{lineWidth:s}=this.current,[n,a]=this.getScaleForStroking();e.lineWidth=s||1;if(1===n&&1===a){e.stroke();return}let r,o,l;if(t){r=(0,i.getCurrentTransform)(e);o=e.getLineDash().slice();l=e.lineDashOffset}e.scale(n,a);const c=Math.max(n,a);e.setLineDash(e.getLineDash().map((t=>t/c)));e.lineDashOffset/=c;e.stroke();if(t){e.setTransform(...r);e.setLineDash(o);e.lineDashOffset=l}}isContentVisible(){for(let t=this.markedContentStack.length-1;t>=0;t--)if(!this.markedContentStack[t].visible)return!1;return!0}}e.CanvasGraphics=CanvasGraphics;for(const t in n.OPS)void 0!==CanvasGraphics.prototype[t]&&(CanvasGraphics.prototype[n.OPS[t]]=CanvasGraphics.prototype[t])},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.TilingPattern=e.PathType=void 0;e.getShadingPattern=function getShadingPattern(t){switch(t[0]){case"RadialAxial":return new RadialAxialShadingPattern(t);case"Mesh":return new MeshShadingPattern(t);case"Dummy":return new DummyShadingPattern}throw new Error(`Unknown IR type: ${t[0]}`)};var n=s(1),i=s(6),a=s(10);const r={FILL:"Fill",STROKE:"Stroke",SHADING:"Shading"};e.PathType=r;function applyBoundingBox(t,e){if(!e||a.isNodeJS)return;const s=e[2]-e[0],n=e[3]-e[1],i=new Path2D;i.rect(e[0],e[1],s,n);t.clip(i)}class BaseShadingPattern{constructor(){this.constructor===BaseShadingPattern&&(0,n.unreachable)("Cannot initialize BaseShadingPattern.")}getPattern(){(0,n.unreachable)("Abstract method `getPattern` called.")}}class RadialAxialShadingPattern extends BaseShadingPattern{constructor(t){super();this._type=t[1];this._bbox=t[2];this._colorStops=t[3];this._p0=t[4];this._p1=t[5];this._r0=t[6];this._r1=t[7];this.matrix=null}_createGradient(t){let e;"axial"===this._type?e=t.createLinearGradient(this._p0[0],this._p0[1],this._p1[0],this._p1[1]):"radial"===this._type&&(e=t.createRadialGradient(this._p0[0],this._p0[1],this._r0,this._p1[0],this._p1[1],this._r1));for(const t of this._colorStops)e.addColorStop(t[0],t[1]);return e}getPattern(t,e,s,a){let o;if(a===r.STROKE||a===r.FILL){const r=e.current.getClippedPathBoundingBox(a,(0,i.getCurrentTransform)(t))||[0,0,0,0],l=Math.ceil(r[2]-r[0])||1,c=Math.ceil(r[3]-r[1])||1,h=e.cachedCanvases.getCanvas("pattern",l,c,!0),d=h.context;d.clearRect(0,0,d.canvas.width,d.canvas.height);d.beginPath();d.rect(0,0,d.canvas.width,d.canvas.height);d.translate(-r[0],-r[1]);s=n.Util.transform(s,[1,0,0,1,r[0],r[1]]);d.transform(...e.baseTransform);this.matrix&&d.transform(...this.matrix);applyBoundingBox(d,this._bbox);d.fillStyle=this._createGradient(d);d.fill();o=t.createPattern(h.canvas,"no-repeat");const u=new DOMMatrix(s);o.setTransform(u)}else{applyBoundingBox(t,this._bbox);o=this._createGradient(t)}return o}}function drawTriangle(t,e,s,n,i,a,r,o){const l=e.coords,c=e.colors,h=t.data,d=4*t.width;let u;if(l[s+1]>l[n+1]){u=s;s=n;n=u;u=a;a=r;r=u}if(l[n+1]>l[i+1]){u=n;n=i;i=u;u=r;r=o;o=u}if(l[s+1]>l[n+1]){u=s;s=n;n=u;u=a;a=r;r=u}const p=(l[s]+e.offsetX)*e.scaleX,g=(l[s+1]+e.offsetY)*e.scaleY,m=(l[n]+e.offsetX)*e.scaleX,f=(l[n+1]+e.offsetY)*e.scaleY,b=(l[i]+e.offsetX)*e.scaleX,A=(l[i+1]+e.offsetY)*e.scaleY;if(g>=A)return;const _=c[a],y=c[a+1],v=c[a+2],S=c[r],x=c[r+1],E=c[r+2],C=c[o],P=c[o+1],T=c[o+2],w=Math.round(g),k=Math.round(A);let F,R,M,D,I,O,L,N;for(let t=w;t<=k;t++){if(tA?1:f===A?0:(f-t)/(f-A);F=m-(m-b)*e;R=S-(S-C)*e;M=x-(x-P)*e;D=E-(E-T)*e}let e;e=tA?1:(g-t)/(g-A);I=p-(p-b)*e;O=_-(_-C)*e;L=y-(y-P)*e;N=v-(v-T)*e;const s=Math.round(Math.min(F,I)),n=Math.round(Math.max(F,I));let i=d*t+4*s;for(let t=s;t<=n;t++){e=(F-t)/(F-I);e<0?e=0:e>1&&(e=1);h[i++]=R-(R-O)*e|0;h[i++]=M-(M-L)*e|0;h[i++]=D-(D-N)*e|0;h[i++]=255}}}function drawFigure(t,e,s){const n=e.coords,i=e.colors;let a,r;switch(e.type){case"lattice":const o=e.verticesPerRow,l=Math.floor(n.length/o)-1,c=o-1;for(a=0;a=n?i=n:s=i/t;return{scale:s,size:i}}clipBbox(t,e,s,n,a){const r=n-e,o=a-s;t.ctx.rect(e,s,r,o);t.current.updateRectMinMax((0,i.getCurrentTransform)(t.ctx),[e,s,n,a]);t.clip();t.endPath()}setFillAndStrokeStyleToContext(t,e,s){const i=t.ctx,a=t.current;switch(e){case o:const t=this.ctx;i.fillStyle=t.fillStyle;i.strokeStyle=t.strokeStyle;a.fillColor=t.fillStyle;a.strokeColor=t.strokeStyle;break;case l:const r=n.Util.makeHexColor(s[0],s[1],s[2]);i.fillStyle=r;i.strokeStyle=r;a.fillColor=r;a.strokeColor=r;break;default:throw new n.FormatError(`Unsupported paint type: ${e}`)}}getPattern(t,e,s,i){let a=s;if(i!==r.SHADING){a=n.Util.transform(a,e.baseTransform);this.matrix&&(a=n.Util.transform(a,this.matrix))}const o=this.createPatternCanvas(e);let l=new DOMMatrix(a);l=l.translate(o.offsetX,o.offsetY);l=l.scale(1/o.scaleX,1/o.scaleY);const c=t.createPattern(o.canvas,"repeat");c.setTransform(l);return c}}e.TilingPattern=TilingPattern},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.applyMaskImageData=function applyMaskImageData({src:t,srcPos:e=0,dest:s,destPos:i=0,width:a,height:r,inverseDecode:o=!1}){const l=n.FeatureTest.isLittleEndian?4278190080:255,[c,h]=o?[0,l]:[l,0],d=a>>3,u=7&a,p=t.length;s=new Uint32Array(s.buffer);for(let n=0;n{Object.defineProperty(e,"__esModule",{value:!0});e.GlobalWorkerOptions=void 0;const s=Object.create(null);e.GlobalWorkerOptions=s;s.workerPort=void 0===s.workerPort?null:s.workerPort;s.workerSrc=void 0===s.workerSrc?"":s.workerSrc},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.MessageHandler=void 0;var n=s(1);const i=1,a=2,r=1,o=2,l=3,c=4,h=5,d=6,u=7,p=8;function wrapReason(t){t instanceof Error||"object"==typeof t&&null!==t||(0,n.unreachable)('wrapReason: Expected "reason" to be a (possibly cloned) Error.');switch(t.name){case"AbortException":return new n.AbortException(t.message);case"MissingPDFException":return new n.MissingPDFException(t.message);case"PasswordException":return new n.PasswordException(t.message,t.code);case"UnexpectedResponseException":return new n.UnexpectedResponseException(t.message,t.status);case"UnknownErrorException":return new n.UnknownErrorException(t.message,t.details);default:return new n.UnknownErrorException(t.message,t.toString())}}e.MessageHandler=class MessageHandler{constructor(t,e,s){this.sourceName=t;this.targetName=e;this.comObj=s;this.callbackId=1;this.streamId=1;this.streamSinks=Object.create(null);this.streamControllers=Object.create(null);this.callbackCapabilities=Object.create(null);this.actionHandler=Object.create(null);this._onComObjOnMessage=t=>{const e=t.data;if(e.targetName!==this.sourceName)return;if(e.stream){this._processStreamMessage(e);return}if(e.callback){const t=e.callbackId,s=this.callbackCapabilities[t];if(!s)throw new Error(`Cannot resolve callback ${t}`);delete this.callbackCapabilities[t];if(e.callback===i)s.resolve(e.data);else{if(e.callback!==a)throw new Error("Unexpected callback case");s.reject(wrapReason(e.reason))}return}const n=this.actionHandler[e.action];if(!n)throw new Error(`Unknown action from worker: ${e.action}`);if(e.callbackId){const t=this.sourceName,r=e.sourceName;new Promise((function(t){t(n(e.data))})).then((function(n){s.postMessage({sourceName:t,targetName:r,callback:i,callbackId:e.callbackId,data:n})}),(function(n){s.postMessage({sourceName:t,targetName:r,callback:a,callbackId:e.callbackId,reason:wrapReason(n)})}))}else e.streamId?this._createStreamSink(e):n(e.data)};s.addEventListener("message",this._onComObjOnMessage)}on(t,e){const s=this.actionHandler;if(s[t])throw new Error(`There is already an actionName called "${t}"`);s[t]=e}send(t,e,s){this.comObj.postMessage({sourceName:this.sourceName,targetName:this.targetName,action:t,data:e},s)}sendWithPromise(t,e,s){const i=this.callbackId++,a=(0,n.createPromiseCapability)();this.callbackCapabilities[i]=a;try{this.comObj.postMessage({sourceName:this.sourceName,targetName:this.targetName,action:t,callbackId:i,data:e},s)}catch(t){a.reject(t)}return a.promise}sendWithStream(t,e,s,i){const a=this.streamId++,o=this.sourceName,l=this.targetName,c=this.comObj;return new ReadableStream({start:s=>{const r=(0,n.createPromiseCapability)();this.streamControllers[a]={controller:s,startCall:r,pullCall:null,cancelCall:null,isClosed:!1};c.postMessage({sourceName:o,targetName:l,action:t,streamId:a,data:e,desiredSize:s.desiredSize},i);return r.promise},pull:t=>{const e=(0,n.createPromiseCapability)();this.streamControllers[a].pullCall=e;c.postMessage({sourceName:o,targetName:l,stream:d,streamId:a,desiredSize:t.desiredSize});return e.promise},cancel:t=>{(0,n.assert)(t instanceof Error,"cancel must have a valid reason");const e=(0,n.createPromiseCapability)();this.streamControllers[a].cancelCall=e;this.streamControllers[a].isClosed=!0;c.postMessage({sourceName:o,targetName:l,stream:r,streamId:a,reason:wrapReason(t)});return e.promise}},s)}_createStreamSink(t){const e=t.streamId,s=this.sourceName,i=t.sourceName,a=this.comObj,r=this,o=this.actionHandler[t.action],d={enqueue(t,r=1,o){if(this.isCancelled)return;const l=this.desiredSize;this.desiredSize-=r;if(l>0&&this.desiredSize<=0){this.sinkCapability=(0,n.createPromiseCapability)();this.ready=this.sinkCapability.promise}a.postMessage({sourceName:s,targetName:i,stream:c,streamId:e,chunk:t},o)},close(){if(!this.isCancelled){this.isCancelled=!0;a.postMessage({sourceName:s,targetName:i,stream:l,streamId:e});delete r.streamSinks[e]}},error(t){(0,n.assert)(t instanceof Error,"error must have a valid reason");if(!this.isCancelled){this.isCancelled=!0;a.postMessage({sourceName:s,targetName:i,stream:h,streamId:e,reason:wrapReason(t)})}},sinkCapability:(0,n.createPromiseCapability)(),onPull:null,onCancel:null,isCancelled:!1,desiredSize:t.desiredSize,ready:null};d.sinkCapability.resolve();d.ready=d.sinkCapability.promise;this.streamSinks[e]=d;new Promise((function(e){e(o(t.data,d))})).then((function(){a.postMessage({sourceName:s,targetName:i,stream:p,streamId:e,success:!0})}),(function(t){a.postMessage({sourceName:s,targetName:i,stream:p,streamId:e,reason:wrapReason(t)})}))}_processStreamMessage(t){const e=t.streamId,s=this.sourceName,i=t.sourceName,a=this.comObj,g=this.streamControllers[e],m=this.streamSinks[e];switch(t.stream){case p:t.success?g.startCall.resolve():g.startCall.reject(wrapReason(t.reason));break;case u:t.success?g.pullCall.resolve():g.pullCall.reject(wrapReason(t.reason));break;case d:if(!m){a.postMessage({sourceName:s,targetName:i,stream:u,streamId:e,success:!0});break}m.desiredSize<=0&&t.desiredSize>0&&m.sinkCapability.resolve();m.desiredSize=t.desiredSize;new Promise((function(t){t(m.onPull&&m.onPull())})).then((function(){a.postMessage({sourceName:s,targetName:i,stream:u,streamId:e,success:!0})}),(function(t){a.postMessage({sourceName:s,targetName:i,stream:u,streamId:e,reason:wrapReason(t)})}));break;case c:(0,n.assert)(g,"enqueue should have stream controller");if(g.isClosed)break;g.controller.enqueue(t.chunk);break;case l:(0,n.assert)(g,"close should have stream controller");if(g.isClosed)break;g.isClosed=!0;g.controller.close();this._deleteStreamController(g,e);break;case h:(0,n.assert)(g,"error should have stream controller");g.controller.error(wrapReason(t.reason));this._deleteStreamController(g,e);break;case o:t.success?g.cancelCall.resolve():g.cancelCall.reject(wrapReason(t.reason));this._deleteStreamController(g,e);break;case r:if(!m)break;new Promise((function(e){e(m.onCancel&&m.onCancel(wrapReason(t.reason)))})).then((function(){a.postMessage({sourceName:s,targetName:i,stream:o,streamId:e,success:!0})}),(function(t){a.postMessage({sourceName:s,targetName:i,stream:o,streamId:e,reason:wrapReason(t)})}));m.sinkCapability.reject(wrapReason(t.reason));m.isCancelled=!0;delete this.streamSinks[e];break;default:throw new Error("Unexpected stream case")}}async _deleteStreamController(t,e){await Promise.allSettled([t.startCall&&t.startCall.promise,t.pullCall&&t.pullCall.promise,t.cancelCall&&t.cancelCall.promise]);delete this.streamControllers[e]}destroy(){this.comObj.removeEventListener("message",this._onComObjOnMessage)}}},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.Metadata=void 0;var n=s(1);e.Metadata=class Metadata{#ht;#dt;constructor({parsedData:t,rawData:e}){this.#ht=t;this.#dt=e}getRaw(){return this.#dt}get(t){return this.#ht.get(t)??null}getAll(){return(0,n.objectFromMap)(this.#ht)}has(t){return this.#ht.has(t)}}},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.OptionalContentConfig=void 0;var n=s(1),i=s(8);const a=Symbol("INTERNAL");class OptionalContentGroup{#ut=!0;constructor(t,e){this.name=t;this.intent=e}get visible(){return this.#ut}_setVisible(t,e){t!==a&&(0,n.unreachable)("Internal method `_setVisible` called.");this.#ut=e}}e.OptionalContentConfig=class OptionalContentConfig{#pt=null;#gt=new Map;#mt=null;#ft=null;constructor(t){this.name=null;this.creator=null;if(null!==t){this.name=t.name;this.creator=t.creator;this.#ft=t.order;for(const e of t.groups)this.#gt.set(e.id,new OptionalContentGroup(e.name,e.intent));if("OFF"===t.baseState)for(const t of this.#gt.values())t._setVisible(a,!1);for(const e of t.on)this.#gt.get(e)._setVisible(a,!0);for(const e of t.off)this.#gt.get(e)._setVisible(a,!1);this.#mt=this.getHash()}}#bt(t){const e=t.length;if(e<2)return!0;const s=t[0];for(let i=1;i0?(0,n.objectFromMap)(this.#gt):null}getGroup(t){return this.#gt.get(t)||null}getHash(){if(null!==this.#pt)return this.#pt;const t=new i.MurmurHash3_64;for(const[e,s]of this.#gt)t.update(`${e}:${s.visible}`);return this.#pt=t.hexdigest()}}},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.PDFDataTransportStream=void 0;var n=s(1),i=s(6);e.PDFDataTransportStream=class PDFDataTransportStream{constructor(t,e){(0,n.assert)(e,'PDFDataTransportStream - missing required "pdfDataRangeTransport" argument.');this._queuedChunks=[];this._progressiveDone=t.progressiveDone||!1;this._contentDispositionFilename=t.contentDispositionFilename||null;const s=t.initialData;if(s?.length>0){const t=new Uint8Array(s).buffer;this._queuedChunks.push(t)}this._pdfDataRangeTransport=e;this._isStreamingSupported=!t.disableStream;this._isRangeSupported=!t.disableRange;this._contentLength=t.length;this._fullRequestReader=null;this._rangeReaders=[];this._pdfDataRangeTransport.addRangeListener(((t,e)=>{this._onReceiveData({begin:t,chunk:e})}));this._pdfDataRangeTransport.addProgressListener(((t,e)=>{this._onProgress({loaded:t,total:e})}));this._pdfDataRangeTransport.addProgressiveReadListener((t=>{this._onReceiveData({chunk:t})}));this._pdfDataRangeTransport.addProgressiveDoneListener((()=>{this._onProgressiveDone()}));this._pdfDataRangeTransport.transportReady()}_onReceiveData(t){const e=new Uint8Array(t.chunk).buffer;if(void 0===t.begin)this._fullRequestReader?this._fullRequestReader._enqueue(e):this._queuedChunks.push(e);else{const s=this._rangeReaders.some((function(s){if(s._begin!==t.begin)return!1;s._enqueue(e);return!0}));(0,n.assert)(s,"_onReceiveData - no `PDFDataTransportStreamRangeReader` instance found.")}}get _progressiveDataLength(){return this._fullRequestReader?._loaded??0}_onProgress(t){void 0===t.total?this._rangeReaders[0]?.onProgress?.({loaded:t.loaded}):this._fullRequestReader?.onProgress?.({loaded:t.loaded,total:t.total})}_onProgressiveDone(){this._fullRequestReader?.progressiveDone();this._progressiveDone=!0}_removeRangeReader(t){const e=this._rangeReaders.indexOf(t);e>=0&&this._rangeReaders.splice(e,1)}getFullReader(){(0,n.assert)(!this._fullRequestReader,"PDFDataTransportStream.getFullReader can only be called once.");const t=this._queuedChunks;this._queuedChunks=null;return new PDFDataTransportStreamReader(this,t,this._progressiveDone,this._contentDispositionFilename)}getRangeReader(t,e){if(e<=this._progressiveDataLength)return null;const s=new PDFDataTransportStreamRangeReader(this,t,e);this._pdfDataRangeTransport.requestDataRange(t,e);this._rangeReaders.push(s);return s}cancelAllRequests(t){this._fullRequestReader?.cancel(t);for(const e of this._rangeReaders.slice(0))e.cancel(t);this._pdfDataRangeTransport.abort()}};class PDFDataTransportStreamReader{constructor(t,e,s=!1,n=null){this._stream=t;this._done=s||!1;this._filename=(0,i.isPdfFile)(n)?n:null;this._queuedChunks=e||[];this._loaded=0;for(const t of this._queuedChunks)this._loaded+=t.byteLength;this._requests=[];this._headersReady=Promise.resolve();t._fullRequestReader=this;this.onProgress=null}_enqueue(t){if(!this._done){if(this._requests.length>0){this._requests.shift().resolve({value:t,done:!1})}else this._queuedChunks.push(t);this._loaded+=t.byteLength}}get headersReady(){return this._headersReady}get filename(){return this._filename}get isRangeSupported(){return this._stream._isRangeSupported}get isStreamingSupported(){return this._stream._isStreamingSupported}get contentLength(){return this._stream._contentLength}async read(){if(this._queuedChunks.length>0){return{value:this._queuedChunks.shift(),done:!1}}if(this._done)return{value:void 0,done:!0};const t=(0,n.createPromiseCapability)();this._requests.push(t);return t.promise}cancel(t){this._done=!0;for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0}progressiveDone(){this._done||(this._done=!0)}}class PDFDataTransportStreamRangeReader{constructor(t,e,s){this._stream=t;this._begin=e;this._end=s;this._queuedChunk=null;this._requests=[];this._done=!1;this.onProgress=null}_enqueue(t){if(!this._done){if(0===this._requests.length)this._queuedChunk=t;else{this._requests.shift().resolve({value:t,done:!1});for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0}this._done=!0;this._stream._removeRangeReader(this)}}get isStreamingSupported(){return!1}async read(){if(this._queuedChunk){const t=this._queuedChunk;this._queuedChunk=null;return{value:t,done:!1}}if(this._done)return{value:void 0,done:!0};const t=(0,n.createPromiseCapability)();this._requests.push(t);return t.promise}cancel(t){this._done=!0;for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0;this._stream._removeRangeReader(this)}}},(t,e)=>{Object.defineProperty(e,"__esModule",{value:!0});e.XfaText=void 0;class XfaText{static textContent(t){const e=[],s={items:e,styles:Object.create(null)};!function walk(t){if(!t)return;let s=null;const n=t.name;if("#text"===n)s=t.value;else{if(!XfaText.shouldBuildText(n))return;t?.attributes?.textContent?s=t.attributes.textContent:t.value&&(s=t.value)}null!==s&&e.push({str:s});if(t.children)for(const e of t.children)walk(e)}(t);return s}static shouldBuildText(t){return!("textarea"===t||"input"===t||"option"===t||"select"===t)}}e.XfaText=XfaText},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.NodeStandardFontDataFactory=e.NodeCanvasFactory=e.NodeCMapReaderFactory=void 0;var n=s(7);const fetchData=function(t){return new Promise(((e,s)=>{require("fs").readFile(t,((t,n)=>{!t&&n?e(new Uint8Array(n)):s(new Error(t))}))}))};class NodeCanvasFactory extends n.BaseCanvasFactory{_createCanvas(t,e){return require("canvas").createCanvas(t,e)}}e.NodeCanvasFactory=NodeCanvasFactory;class NodeCMapReaderFactory extends n.BaseCMapReaderFactory{_fetchData(t,e){return fetchData(t).then((t=>({cMapData:t,compressionType:e})))}}e.NodeCMapReaderFactory=NodeCMapReaderFactory;class NodeStandardFontDataFactory extends n.BaseStandardFontDataFactory{_fetchData(t){return fetchData(t)}}e.NodeStandardFontDataFactory=NodeStandardFontDataFactory},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.TextLayerRenderTask=void 0;e.renderTextLayer=function renderTextLayer(t){if(!t.textContentSource&&(t.textContent||t.textContentStream)){(0,i.deprecated)("The TextLayerRender `textContent`/`textContentStream` parameters will be removed in the future, please use `textContentSource` instead.");t.textContentSource=t.textContent||t.textContentStream}const e=new TextLayerRenderTask(t);e._render();return e};e.updateTextLayer=function updateTextLayer({container:t,viewport:e,textDivs:s,textDivProperties:n,isOffscreenCanvasSupported:a,mustRotate:r=!0,mustRescale:o=!0}){r&&(0,i.setLayerDimensions)(t,{rotation:e.rotation});if(o){const t=getCtx(0,a),i={prevFontSize:null,prevFontFamily:null,div:null,scale:e.scale*(globalThis.devicePixelRatio||1),properties:null,ctx:t};for(const t of s){i.properties=n.get(t);i.div=t;layout(i)}}};var n=s(1),i=s(6);const a=30,r=new Map;function getCtx(t,e){let s;if(e&&n.FeatureTest.isOffscreenCanvasSupported)s=new OffscreenCanvas(t,t).getContext("2d",{alpha:!1});else{const e=document.createElement("canvas");e.width=e.height=t;s=e.getContext("2d",{alpha:!1})}return s}function appendText(t,e,s){const i=document.createElement("span"),o={angle:0,canvasWidth:0,hasText:""!==e.str,hasEOL:e.hasEOL,fontSize:0};t._textDivs.push(i);const l=n.Util.transform(t._transform,e.transform);let c=Math.atan2(l[1],l[0]);const h=s[e.fontName];h.vertical&&(c+=Math.PI/2);const d=Math.hypot(l[2],l[3]),u=d*function getAscent(t,e){const s=r.get(t);if(s)return s;const n=getCtx(a,e);n.font=`30px ${t}`;const i=n.measureText("");let o=i.fontBoundingBoxAscent,l=Math.abs(i.fontBoundingBoxDescent);if(o){const e=o/(o+l);r.set(t,e);n.canvas.width=n.canvas.height=0;return e}n.strokeStyle="red";n.clearRect(0,0,a,a);n.strokeText("g",0,0);let c=n.getImageData(0,0,a,a).data;l=0;for(let t=c.length-1-3;t>=0;t-=4)if(c[t]>0){l=Math.ceil(t/4/a);break}n.clearRect(0,0,a,a);n.strokeText("A",0,a);c=n.getImageData(0,0,a,a).data;o=0;for(let t=0,e=c.length;t0){o=a-Math.floor(t/4/a);break}n.canvas.width=n.canvas.height=0;if(o){const e=o/(o+l);r.set(t,e);return e}r.set(t,.8);return.8}(h.fontFamily,t._isOffscreenCanvasSupported);let p,g;if(0===c){p=l[4];g=l[5]-u}else{p=l[4]+u*Math.sin(c);g=l[5]-u*Math.cos(c)}const m="calc(var(--scale-factor)*",f=i.style;if(t._container===t._rootContainer){f.left=`${(100*p/t._pageWidth).toFixed(2)}%`;f.top=`${(100*g/t._pageHeight).toFixed(2)}%`}else{f.left=`${m}${p.toFixed(2)}px)`;f.top=`${m}${g.toFixed(2)}px)`}f.fontSize=`${m}${d.toFixed(2)}px)`;f.fontFamily=h.fontFamily;o.fontSize=d;i.setAttribute("role","presentation");i.textContent=e.str;i.dir=e.dir;t._fontInspectorEnabled&&(i.dataset.fontName=e.fontName);0!==c&&(o.angle=c*(180/Math.PI));let b=!1;if(e.str.length>1)b=!0;else if(" "!==e.str&&e.transform[0]!==e.transform[3]){const t=Math.abs(e.transform[0]),s=Math.abs(e.transform[3]);t!==s&&Math.max(t,s)/Math.min(t,s)>1.5&&(b=!0)}b&&(o.canvasWidth=h.vertical?e.height:e.width);t._textDivProperties.set(i,o);t._isReadableStream&&t._layoutText(i)}function layout(t){const{div:e,scale:s,properties:n,ctx:i,prevFontSize:a,prevFontFamily:r}=t,{style:o}=e;let l="";if(0!==n.canvasWidth&&n.hasText){const{fontFamily:c}=o,{canvasWidth:h,fontSize:d}=n;if(a!==d||r!==c){i.font=`${d*s}px ${c}`;t.prevFontSize=d;t.prevFontFamily=c}const{width:u}=i.measureText(e.textContent);u>0&&(l=`scaleX(${h*s/u})`)}0!==n.angle&&(l=`rotate(${n.angle}deg) ${l}`);l.length>0&&(o.transform=l)}class TextLayerRenderTask{constructor({textContentSource:t,container:e,viewport:s,textDivs:a,textDivProperties:r,textContentItemsStr:o,isOffscreenCanvasSupported:l}){this._textContentSource=t;this._isReadableStream=t instanceof ReadableStream;this._container=this._rootContainer=e;this._textDivs=a||[];this._textContentItemsStr=o||[];this._fontInspectorEnabled=!!globalThis.FontInspector?.enabled;this._reader=null;this._textDivProperties=r||new WeakMap;this._canceled=!1;this._capability=(0,n.createPromiseCapability)();this._layoutTextParams={prevFontSize:null,prevFontFamily:null,div:null,scale:s.scale*(globalThis.devicePixelRatio||1),properties:null,ctx:getCtx(0,l)};const{pageWidth:c,pageHeight:h,pageX:d,pageY:u}=s.rawDims;this._transform=[1,0,0,-1,-d,u+h];this._pageWidth=c;this._pageHeight=h;(0,i.setLayerDimensions)(e,s);this._capability.promise.finally((()=>{this._layoutTextParams=null})).catch((()=>{}))}get promise(){return this._capability.promise}cancel(){this._canceled=!0;if(this._reader){this._reader.cancel(new n.AbortException("TextLayer task cancelled.")).catch((()=>{}));this._reader=null}this._capability.reject(new n.AbortException("TextLayer task cancelled."))}_processItems(t,e){for(const s of t)if(void 0!==s.str){this._textContentItemsStr.push(s.str);appendText(this,s,e)}else if("beginMarkedContentProps"===s.type||"beginMarkedContent"===s.type){const t=this._container;this._container=document.createElement("span");this._container.classList.add("markedContent");null!==s.id&&this._container.setAttribute("id",`${s.id}`);t.append(this._container)}else"endMarkedContent"===s.type&&(this._container=this._container.parentNode)}_layoutText(t){const e=this._layoutTextParams.properties=this._textDivProperties.get(t);this._layoutTextParams.div=t;layout(this._layoutTextParams);e.hasText&&this._container.append(t);if(e.hasEOL){const t=document.createElement("br");t.setAttribute("role","presentation");this._container.append(t)}}_render(){const t=(0,n.createPromiseCapability)();let e=Object.create(null);if(this._isReadableStream){const pump=()=>{this._reader.read().then((({value:s,done:n})=>{if(n)t.resolve();else{Object.assign(e,s.styles);this._processItems(s.items,e);pump()}}),t.reject)};this._reader=this._textContentSource.getReader();pump()}else{if(!this._textContentSource)throw new Error('No "textContentSource" parameter specified.');{const{items:e,styles:s}=this._textContentSource;this._processItems(e,s);t.resolve()}}t.promise.then((()=>{e=null;!function render(t){if(t._canceled)return;const e=t._textDivs,s=t._capability;if(e.length>1e5)s.resolve();else{if(!t._isReadableStream)for(const s of e)t._layoutText(s);s.resolve()}}(this)}),this._capability.reject)}}e.TextLayerRenderTask=TextLayerRenderTask},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.AnnotationEditorLayer=void 0;var n=s(1),i=s(5),a=s(23),r=s(24),o=s(6);class AnnotationEditorLayer{#At;#_t=!1;#yt=this.pointerup.bind(this);#vt=this.pointerdown.bind(this);#St=new Map;#xt=!1;#Et=!1;#Ct;static _initialized=!1;constructor(t){if(!AnnotationEditorLayer._initialized){AnnotationEditorLayer._initialized=!0;a.FreeTextEditor.initialize(t.l10n);r.InkEditor.initialize(t.l10n)}t.uiManager.registerEditorTypes([a.FreeTextEditor,r.InkEditor]);this.#Ct=t.uiManager;this.pageIndex=t.pageIndex;this.div=t.div;this.#At=t.accessibilityManager;this.#Ct.addLayer(this)}updateToolbar(t){this.#Ct.updateToolbar(t)}updateMode(t=this.#Ct.getMode()){this.#Pt();if(t===n.AnnotationEditorType.INK){this.addInkEditorIfNeeded(!1);this.disableClick()}else this.enableClick();this.#Ct.unselectAll();this.div.classList.toggle("freeTextEditing",t===n.AnnotationEditorType.FREETEXT);this.div.classList.toggle("inkEditing",t===n.AnnotationEditorType.INK)}addInkEditorIfNeeded(t){if(!t&&this.#Ct.getMode()!==n.AnnotationEditorType.INK)return;if(!t)for(const t of this.#St.values())if(t.isEmpty()){t.setInBackground();return}this.#Tt({offsetX:0,offsetY:0}).setInBackground()}setEditingState(t){this.#Ct.setEditingState(t)}addCommands(t){this.#Ct.addCommands(t)}enable(){this.div.style.pointerEvents="auto";for(const t of this.#St.values())t.enableEditing()}disable(){this.div.style.pointerEvents="none";for(const t of this.#St.values())t.disableEditing()}setActiveEditor(t){this.#Ct.getActive()!==t&&this.#Ct.setActiveEditor(t)}enableClick(){this.div.addEventListener("pointerdown",this.#vt);this.div.addEventListener("pointerup",this.#yt)}disableClick(){this.div.removeEventListener("pointerdown",this.#vt);this.div.removeEventListener("pointerup",this.#yt)}attach(t){this.#St.set(t.id,t)}detach(t){this.#St.delete(t.id);this.#At?.removePointerInTextLayer(t.contentDiv)}remove(t){this.#Ct.removeEditor(t);this.detach(t);t.div.style.display="none";setTimeout((()=>{t.div.style.display="";t.div.remove();t.isAttachedToDOM=!1;document.activeElement===document.body&&this.#Ct.focusMainContainer()}),0);this.#Et||this.addInkEditorIfNeeded(!1)}#wt(t){if(t.parent!==this){this.attach(t);t.parent?.detach(t);t.setParent(this);if(t.div&&t.isAttachedToDOM){t.div.remove();this.div.append(t.div)}}}add(t){this.#wt(t);this.#Ct.addEditor(t);this.attach(t);if(!t.isAttachedToDOM){const e=t.render();this.div.append(e);t.isAttachedToDOM=!0}this.moveEditorInDOM(t);t.onceAdded();this.#Ct.addToAnnotationStorage(t)}moveEditorInDOM(t){this.#At?.moveElementInDOM(this.div,t.div,t.contentDiv,!0)}addOrRebuild(t){t.needsToBeRebuilt()?t.rebuild():this.add(t)}addANewEditor(t){this.addCommands({cmd:()=>{this.addOrRebuild(t)},undo:()=>{t.remove()},mustExec:!0})}addUndoableEditor(t){this.addCommands({cmd:()=>{this.addOrRebuild(t)},undo:()=>{t.remove()},mustExec:!1})}getNextId(){return this.#Ct.getId()}#kt(t){switch(this.#Ct.getMode()){case n.AnnotationEditorType.FREETEXT:return new a.FreeTextEditor(t);case n.AnnotationEditorType.INK:return new r.InkEditor(t)}return null}deserialize(t){switch(t.annotationType){case n.AnnotationEditorType.FREETEXT:return a.FreeTextEditor.deserialize(t,this,this.#Ct);case n.AnnotationEditorType.INK:return r.InkEditor.deserialize(t,this,this.#Ct)}return null}#Tt(t){const e=this.getNextId(),s=this.#kt({parent:this,id:e,x:t.offsetX,y:t.offsetY,uiManager:this.#Ct});s&&this.add(s);return s}setSelected(t){this.#Ct.setSelected(t)}toggleSelected(t){this.#Ct.toggleSelected(t)}isSelected(t){return this.#Ct.isSelected(t)}unselect(t){this.#Ct.unselect(t)}pointerup(t){const{isMac:e}=n.FeatureTest.platform;if(!(0!==t.button||t.ctrlKey&&e)&&t.target===this.div&&this.#xt){this.#xt=!1;this.#_t?this.#Tt(t):this.#_t=!0}}pointerdown(t){const{isMac:e}=n.FeatureTest.platform;if(0!==t.button||t.ctrlKey&&e)return;if(t.target!==this.div)return;this.#xt=!0;const s=this.#Ct.getActive();this.#_t=!s||s.isEmpty()}drop(t){const e=t.dataTransfer.getData("text/plain"),s=this.#Ct.getEditor(e);if(!s)return;t.preventDefault();t.dataTransfer.dropEffect="move";this.#wt(s);const n=this.div.getBoundingClientRect(),i=t.clientX-n.x,a=t.clientY-n.y;s.translate(i-s.startX,a-s.startY);this.moveEditorInDOM(s);s.div.focus()}dragover(t){t.preventDefault()}destroy(){this.#Ct.getActive()?.parent===this&&this.#Ct.setActiveEditor(null);for(const t of this.#St.values()){this.#At?.removePointerInTextLayer(t.contentDiv);t.setParent(null);t.isAttachedToDOM=!1;t.div.remove()}this.div=null;this.#St.clear();this.#Ct.removeLayer(this)}#Pt(){this.#Et=!0;for(const t of this.#St.values())t.isEmpty()&&t.remove();this.#Et=!1}render({viewport:t}){this.viewport=t;(0,o.setLayerDimensions)(this.div,t);(0,i.bindEvents)(this,this.div,["dragover","drop"]);for(const t of this.#Ct.getEditors(this.pageIndex))this.add(t);this.updateMode()}update({viewport:t}){this.#Ct.commitOrRemove();this.viewport=t;(0,o.setLayerDimensions)(this.div,{rotation:t.rotation});this.updateMode()}get pageDimensions(){const{pageWidth:t,pageHeight:e}=this.viewport.rawDims;return[t,e]}}e.AnnotationEditorLayer=AnnotationEditorLayer},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.FreeTextEditor=void 0;var n=s(1),i=s(5),a=s(4);class FreeTextEditor extends a.AnnotationEditor{#Ft=this.editorDivBlur.bind(this);#Rt=this.editorDivFocus.bind(this);#Mt=this.editorDivInput.bind(this);#Dt=this.editorDivKeydown.bind(this);#It;#Ot="";#Lt=`${this.id}-editor`;#Nt=!1;#jt;static _freeTextDefaultContent="";static _l10nPromise;static _internalPadding=0;static _defaultColor=null;static _defaultFontSize=10;static _keyboardManager=new i.KeyboardManager([[["ctrl+Enter","mac+meta+Enter","Escape","mac+Escape"],FreeTextEditor.prototype.commitOrRemove]]);static _type="freetext";constructor(t){super({...t,name:"freeTextEditor"});this.#It=t.color||FreeTextEditor._defaultColor||a.AnnotationEditor._defaultLineColor;this.#jt=t.fontSize||FreeTextEditor._defaultFontSize}static initialize(t){this._l10nPromise=new Map(["free_text2_default_content","editor_free_text2_aria_label"].map((e=>[e,t.get(e)])));const e=getComputedStyle(document.documentElement);this._internalPadding=parseFloat(e.getPropertyValue("--freetext-padding"))}static updateDefaultParams(t,e){switch(t){case n.AnnotationEditorParamsType.FREETEXT_SIZE:FreeTextEditor._defaultFontSize=e;break;case n.AnnotationEditorParamsType.FREETEXT_COLOR:FreeTextEditor._defaultColor=e}}updateParams(t,e){switch(t){case n.AnnotationEditorParamsType.FREETEXT_SIZE:this.#Ut(e);break;case n.AnnotationEditorParamsType.FREETEXT_COLOR:this.#Bt(e)}}static get defaultPropertiesToUpdate(){return[[n.AnnotationEditorParamsType.FREETEXT_SIZE,FreeTextEditor._defaultFontSize],[n.AnnotationEditorParamsType.FREETEXT_COLOR,FreeTextEditor._defaultColor||a.AnnotationEditor._defaultLineColor]]}get propertiesToUpdate(){return[[n.AnnotationEditorParamsType.FREETEXT_SIZE,this.#jt],[n.AnnotationEditorParamsType.FREETEXT_COLOR,this.#It]]}#Ut(t){const setFontsize=t=>{this.editorDiv.style.fontSize=`calc(${t}px * var(--scale-factor))`;this.translate(0,-(t-this.#jt)*this.parentScale);this.#jt=t;this.#qt()},e=this.#jt;this.addCommands({cmd:()=>{setFontsize(t)},undo:()=>{setFontsize(e)},mustExec:!0,type:n.AnnotationEditorParamsType.FREETEXT_SIZE,overwriteIfSameType:!0,keepUndo:!0})}#Bt(t){const e=this.#It;this.addCommands({cmd:()=>{this.#It=this.editorDiv.style.color=t},undo:()=>{this.#It=this.editorDiv.style.color=e},mustExec:!0,type:n.AnnotationEditorParamsType.FREETEXT_COLOR,overwriteIfSameType:!0,keepUndo:!0})}getInitialTranslation(){const t=this.parentScale;return[-FreeTextEditor._internalPadding*t,-(FreeTextEditor._internalPadding+this.#jt)*t]}rebuild(){super.rebuild();null!==this.div&&(this.isAttachedToDOM||this.parent.add(this))}enableEditMode(){if(!this.isInEditMode()){this.parent.setEditingState(!1);this.parent.updateToolbar(n.AnnotationEditorType.FREETEXT);super.enableEditMode();this.overlayDiv.classList.remove("enabled");this.editorDiv.contentEditable=!0;this.div.draggable=!1;this.div.removeAttribute("aria-activedescendant");this.editorDiv.addEventListener("keydown",this.#Dt);this.editorDiv.addEventListener("focus",this.#Rt);this.editorDiv.addEventListener("blur",this.#Ft);this.editorDiv.addEventListener("input",this.#Mt)}}disableEditMode(){if(this.isInEditMode()){this.parent.setEditingState(!0);super.disableEditMode();this.overlayDiv.classList.add("enabled");this.editorDiv.contentEditable=!1;this.div.setAttribute("aria-activedescendant",this.#Lt);this.div.draggable=!0;this.editorDiv.removeEventListener("keydown",this.#Dt);this.editorDiv.removeEventListener("focus",this.#Rt);this.editorDiv.removeEventListener("blur",this.#Ft);this.editorDiv.removeEventListener("input",this.#Mt);this.div.focus({preventScroll:!0});this.isEditing=!1;this.parent.div.classList.add("freeTextEditing")}}focusin(t){super.focusin(t);t.target!==this.editorDiv&&this.editorDiv.focus()}onceAdded(){if(!this.width){this.enableEditMode();this.editorDiv.focus()}}isEmpty(){return!this.editorDiv||""===this.editorDiv.innerText.trim()}remove(){this.isEditing=!1;this.parent.setEditingState(!0);this.parent.div.classList.add("freeTextEditing");super.remove()}#Wt(){const t=this.editorDiv.getElementsByTagName("div");if(0===t.length)return this.editorDiv.innerText;const e=[];for(const s of t)e.push(s.innerText.replace(/\r\n?|\n/,""));return e.join("\n")}#qt(){const[t,e]=this.parentDimensions;let s;if(this.isAttachedToDOM)s=this.div.getBoundingClientRect();else{const{currentLayer:t,div:e}=this,n=e.style.display;e.style.display="hidden";t.div.append(this.div);s=e.getBoundingClientRect();e.remove();e.style.display=n}this.width=s.width/t;this.height=s.height/e}commit(){if(this.isInEditMode()){super.commit();if(!this.#Nt){this.#Nt=!0;this.parent.addUndoableEditor(this)}this.disableEditMode();this.#Ot=this.#Wt().trimEnd();this.#qt()}}shouldGetKeyboardEvents(){return this.isInEditMode()}dblclick(t){this.enableEditMode();this.editorDiv.focus()}keydown(t){if(t.target===this.div&&"Enter"===t.key){this.enableEditMode();this.editorDiv.focus()}}editorDivKeydown(t){FreeTextEditor._keyboardManager.exec(this,t)}editorDivFocus(t){this.isEditing=!0}editorDivBlur(t){this.isEditing=!1}editorDivInput(t){this.parent.div.classList.toggle("freeTextEditing",this.isEmpty())}disableEditing(){this.editorDiv.setAttribute("role","comment");this.editorDiv.removeAttribute("aria-multiline")}enableEditing(){this.editorDiv.setAttribute("role","textbox");this.editorDiv.setAttribute("aria-multiline",!0)}render(){if(this.div)return this.div;let t,e;if(this.width){t=this.x;e=this.y}super.render();this.editorDiv=document.createElement("div");this.editorDiv.className="internal";this.editorDiv.setAttribute("id",this.#Lt);this.enableEditing();FreeTextEditor._l10nPromise.get("editor_free_text2_aria_label").then((t=>this.editorDiv?.setAttribute("aria-label",t)));FreeTextEditor._l10nPromise.get("free_text2_default_content").then((t=>this.editorDiv?.setAttribute("default-content",t)));this.editorDiv.contentEditable=!0;const{style:s}=this.editorDiv;s.fontSize=`calc(${this.#jt}px * var(--scale-factor))`;s.color=this.#It;this.div.append(this.editorDiv);this.overlayDiv=document.createElement("div");this.overlayDiv.classList.add("overlay","enabled");this.div.append(this.overlayDiv);(0,i.bindEvents)(this,this.div,["dblclick","keydown"]);if(this.width){const[s,n]=this.parentDimensions;this.setAt(t*s,e*n,this.width*s,this.height*n);for(const t of this.#Ot.split("\n")){const e=document.createElement("div");e.append(t?document.createTextNode(t):document.createElement("br"));this.editorDiv.append(e)}this.div.draggable=!0;this.editorDiv.contentEditable=!1}else{this.div.draggable=!1;this.editorDiv.contentEditable=!0}return this.div}get contentDiv(){return this.editorDiv}static deserialize(t,e,s){const i=super.deserialize(t,e,s);i.#jt=t.fontSize;i.#It=n.Util.makeHexColor(...t.color);i.#Ot=t.value;return i}serialize(){if(this.isEmpty())return null;const t=FreeTextEditor._internalPadding*this.parentScale,e=this.getRect(t,t),s=a.AnnotationEditor._colorManager.convert(this.isAttachedToDOM?getComputedStyle(this.editorDiv).color:this.#It);return{annotationType:n.AnnotationEditorType.FREETEXT,color:s,fontSize:this.#jt,value:this.#Ot,pageIndex:this.pageIndex,rect:e,rotation:this.rotation}}}e.FreeTextEditor=FreeTextEditor},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.InkEditor=void 0;Object.defineProperty(e,"fitCurve",{enumerable:!0,get:function(){return a.fitCurve}});var n=s(1),i=s(4),a=s(25),r=s(5);const o=16;class InkEditor extends i.AnnotationEditor{#Ht=0;#Gt=0;#zt=0;#Vt=this.canvasPointermove.bind(this);#Xt=this.canvasPointerleave.bind(this);#$t=this.canvasPointerup.bind(this);#Yt=this.canvasPointerdown.bind(this);#Kt=!1;#Jt=!1;#Qt=null;#Zt=null;#te=0;#ee=0;#se=null;static _defaultColor=null;static _defaultOpacity=1;static _defaultThickness=1;static _l10nPromise;static _type="ink";constructor(t){super({...t,name:"inkEditor"});this.color=t.color||null;this.thickness=t.thickness||null;this.opacity=t.opacity||null;this.paths=[];this.bezierPath2D=[];this.currentPath=[];this.scaleFactor=1;this.translationX=this.translationY=0;this.x=0;this.y=0}static initialize(t){this._l10nPromise=new Map(["editor_ink_canvas_aria_label","editor_ink2_aria_label"].map((e=>[e,t.get(e)])))}static updateDefaultParams(t,e){switch(t){case n.AnnotationEditorParamsType.INK_THICKNESS:InkEditor._defaultThickness=e;break;case n.AnnotationEditorParamsType.INK_COLOR:InkEditor._defaultColor=e;break;case n.AnnotationEditorParamsType.INK_OPACITY:InkEditor._defaultOpacity=e/100}}updateParams(t,e){switch(t){case n.AnnotationEditorParamsType.INK_THICKNESS:this.#ne(e);break;case n.AnnotationEditorParamsType.INK_COLOR:this.#Bt(e);break;case n.AnnotationEditorParamsType.INK_OPACITY:this.#ie(e)}}static get defaultPropertiesToUpdate(){return[[n.AnnotationEditorParamsType.INK_THICKNESS,InkEditor._defaultThickness],[n.AnnotationEditorParamsType.INK_COLOR,InkEditor._defaultColor||i.AnnotationEditor._defaultLineColor],[n.AnnotationEditorParamsType.INK_OPACITY,Math.round(100*InkEditor._defaultOpacity)]]}get propertiesToUpdate(){return[[n.AnnotationEditorParamsType.INK_THICKNESS,this.thickness||InkEditor._defaultThickness],[n.AnnotationEditorParamsType.INK_COLOR,this.color||InkEditor._defaultColor||i.AnnotationEditor._defaultLineColor],[n.AnnotationEditorParamsType.INK_OPACITY,Math.round(100*(this.opacity??InkEditor._defaultOpacity))]]}#ne(t){const e=this.thickness;this.addCommands({cmd:()=>{this.thickness=t;this.#ae()},undo:()=>{this.thickness=e;this.#ae()},mustExec:!0,type:n.AnnotationEditorParamsType.INK_THICKNESS,overwriteIfSameType:!0,keepUndo:!0})}#Bt(t){const e=this.color;this.addCommands({cmd:()=>{this.color=t;this.#re()},undo:()=>{this.color=e;this.#re()},mustExec:!0,type:n.AnnotationEditorParamsType.INK_COLOR,overwriteIfSameType:!0,keepUndo:!0})}#ie(t){t/=100;const e=this.opacity;this.addCommands({cmd:()=>{this.opacity=t;this.#re()},undo:()=>{this.opacity=e;this.#re()},mustExec:!0,type:n.AnnotationEditorParamsType.INK_OPACITY,overwriteIfSameType:!0,keepUndo:!0})}rebuild(){super.rebuild();if(null!==this.div){if(!this.canvas){this.#oe();this.#le()}if(!this.isAttachedToDOM){this.parent.add(this);this.#ce()}this.#ae()}}remove(){if(null!==this.canvas){this.isEmpty()||this.commit();this.canvas.width=this.canvas.height=0;this.canvas.remove();this.canvas=null;this.#Zt.disconnect();this.#Zt=null;super.remove()}}setParent(t){!this.parent&&t?this._uiManager.removeShouldRescale(this):this.parent&&null===t&&this._uiManager.addShouldRescale(this);super.setParent(t)}onScaleChanging(){const[t,e]=this.parentDimensions,s=this.width*t,n=this.height*e;this.setDimensions(s,n)}enableEditMode(){if(!this.#Kt&&null!==this.canvas){super.enableEditMode();this.div.draggable=!1;this.canvas.addEventListener("pointerdown",this.#Yt);this.canvas.addEventListener("pointerup",this.#$t)}}disableEditMode(){if(this.isInEditMode()&&null!==this.canvas){super.disableEditMode();this.div.draggable=!this.isEmpty();this.div.classList.remove("editing");this.canvas.removeEventListener("pointerdown",this.#Yt);this.canvas.removeEventListener("pointerup",this.#$t)}}onceAdded(){this.div.draggable=!this.isEmpty()}isEmpty(){return 0===this.paths.length||1===this.paths.length&&0===this.paths[0].length}#he(){const{parentRotation:t,parentDimensions:[e,s]}=this;switch(t){case 90:return[0,s,s,e];case 180:return[e,s,e,s];case 270:return[e,0,s,e];default:return[0,0,e,s]}}#de(){const{ctx:t,color:e,opacity:s,thickness:n,parentScale:i,scaleFactor:a}=this;t.lineWidth=n*i/a;t.lineCap="round";t.lineJoin="round";t.miterLimit=10;t.strokeStyle=`${e}${(0,r.opacityToHex)(s)}`}#ue(t,e){this.isEditing=!0;if(!this.#Jt){this.#Jt=!0;this.#ce();this.thickness||=InkEditor._defaultThickness;this.color||=InkEditor._defaultColor||i.AnnotationEditor._defaultLineColor;this.opacity??=InkEditor._defaultOpacity}this.currentPath.push([t,e]);this.#Qt=null;this.#de();this.ctx.beginPath();this.ctx.moveTo(t,e);this.#se=()=>{if(this.#se){if(this.#Qt){if(this.isEmpty()){this.ctx.setTransform(1,0,0,1,0,0);this.ctx.clearRect(0,0,this.canvas.width,this.canvas.height)}else this.#re();this.ctx.lineTo(...this.#Qt);this.#Qt=null;this.ctx.stroke()}window.requestAnimationFrame(this.#se)}};window.requestAnimationFrame(this.#se)}#pe(t,e){const[s,n]=this.currentPath.at(-1);if(t!==s||e!==n){this.currentPath.push([t,e]);this.#Qt=[t,e]}}#ge(t,e){this.ctx.closePath();this.#se=null;t=Math.min(Math.max(t,0),this.canvas.width);e=Math.min(Math.max(e,0),this.canvas.height);const[s,n]=this.currentPath.at(-1);t===s&&e===n||this.currentPath.push([t,e]);let i;if(1!==this.currentPath.length)i=(0,a.fitCurve)(this.currentPath,30,null);else{const s=[t,e];i=[[s,s.slice(),s.slice(),s]]}const r=InkEditor.#me(i);this.currentPath.length=0;this.addCommands({cmd:()=>{this.paths.push(i);this.bezierPath2D.push(r);this.rebuild()},undo:()=>{this.paths.pop();this.bezierPath2D.pop();if(0===this.paths.length)this.remove();else{if(!this.canvas){this.#oe();this.#le()}this.#ae()}},mustExec:!0})}#re(){if(this.isEmpty()){this.#fe();return}this.#de();const{canvas:t,ctx:e}=this;e.setTransform(1,0,0,1,0,0);e.clearRect(0,0,t.width,t.height);this.#fe();for(const t of this.bezierPath2D)e.stroke(t)}commit(){if(!this.#Kt){super.commit();this.isEditing=!1;this.disableEditMode();this.setInForeground();this.#Kt=!0;this.div.classList.add("disabled");this.#ae(!0);this.parent.addInkEditorIfNeeded(!0);this.parent.moveEditorInDOM(this);this.div.focus({preventScroll:!0})}}focusin(t){super.focusin(t);this.enableEditMode()}canvasPointerdown(t){if(0===t.button&&this.isInEditMode()&&!this.#Kt){this.setInForeground();"mouse"!==t.type&&this.div.focus();t.stopPropagation();this.canvas.addEventListener("pointerleave",this.#Xt);this.canvas.addEventListener("pointermove",this.#Vt);this.#ue(t.offsetX,t.offsetY)}}canvasPointermove(t){t.stopPropagation();this.#pe(t.offsetX,t.offsetY)}canvasPointerup(t){if(0===t.button&&this.isInEditMode()&&0!==this.currentPath.length){t.stopPropagation();this.#be(t);this.setInBackground()}}canvasPointerleave(t){this.#be(t);this.setInBackground()}#be(t){this.#ge(t.offsetX,t.offsetY);this.canvas.removeEventListener("pointerleave",this.#Xt);this.canvas.removeEventListener("pointermove",this.#Vt);this.addToAnnotationStorage()}#oe(){this.canvas=document.createElement("canvas");this.canvas.width=this.canvas.height=0;this.canvas.className="inkEditorCanvas";InkEditor._l10nPromise.get("editor_ink_canvas_aria_label").then((t=>this.canvas?.setAttribute("aria-label",t)));this.div.append(this.canvas);this.ctx=this.canvas.getContext("2d")}#le(){let t=null;this.#Zt=new ResizeObserver((e=>{const s=e[0].contentRect;if(s.width&&s.height){null!==t&&clearTimeout(t);t=setTimeout((()=>{this.fixDims();t=null}),100);this.setDimensions(s.width,s.height)}}));this.#Zt.observe(this.div)}render(){if(this.div)return this.div;let t,e;if(this.width){t=this.x;e=this.y}super.render();InkEditor._l10nPromise.get("editor_ink2_aria_label").then((t=>this.div?.setAttribute("aria-label",t)));const[s,n,i,a]=this.#he();this.setAt(s,n,0,0);this.setDims(i,a);this.#oe();if(this.width){const[s,n]=this.parentDimensions;this.setAt(t*s,e*n,this.width*s,this.height*n);this.#Jt=!0;this.#ce();this.setDims(this.width*s,this.height*n);this.#re();this.#Ae();this.div.classList.add("disabled")}else{this.div.classList.add("editing");this.enableEditMode()}this.#le();return this.div}#ce(){if(!this.#Jt)return;const[t,e]=this.parentDimensions;this.canvas.width=Math.ceil(this.width*t);this.canvas.height=Math.ceil(this.height*e);this.#fe()}setDimensions(t,e){const s=Math.round(t),n=Math.round(e);if(this.#te===s&&this.#ee===n)return;this.#te=s;this.#ee=n;this.canvas.style.visibility="hidden";if(this.#Ht&&Math.abs(this.#Ht-t/e)>.01){e=Math.ceil(t/this.#Ht);this.setDims(t,e)}const[i,a]=this.parentDimensions;this.width=t/i;this.height=e/a;this.#Kt&&this.#_e(t,e);this.#ce();this.#re();this.canvas.style.visibility="visible"}#_e(t,e){const s=this.#ye(),n=(t-s)/this.#zt,i=(e-s)/this.#Gt;this.scaleFactor=Math.min(n,i)}#fe(){const t=this.#ye()/2;this.ctx.setTransform(this.scaleFactor,0,0,this.scaleFactor,this.translationX*this.scaleFactor+t,this.translationY*this.scaleFactor+t)}static#me(t){const e=new Path2D;for(let s=0,n=t.length;s=1){t.minHeight="16px";t.minWidth=`${Math.round(this.#Ht*o)}px`}else{t.minWidth="16px";t.minHeight=`${Math.round(o/this.#Ht)}px`}}static deserialize(t,e,s){const i=super.deserialize(t,e,s);i.thickness=t.thickness;i.color=n.Util.makeHexColor(...t.color);i.opacity=t.opacity;const[a,r]=i.pageDimensions,l=i.width*a,c=i.height*r,h=i.parentScale,d=t.thickness/2;i.#Ht=l/c;i.#Kt=!0;i.#te=Math.round(l);i.#ee=Math.round(c);for(const{bezier:e}of t.paths){const t=[];i.paths.push(t);let s=h*(e[0]-d),n=h*(c-e[1]-d);for(let i=2,a=e.length;i{Object.defineProperty(e,"__esModule",{value:!0});e.fitCurve=void 0;const n=s(26);e.fitCurve=n},t=>{function fitCubic(t,e,s,n,i){var a,r,o,l,c,h,d,u,p,g,m,f,b;if(2===t.length){f=maths.vectorLen(maths.subtract(t[0],t[1]))/3;return[a=[t[0],maths.addArrays(t[0],maths.mulItems(e,f)),maths.addArrays(t[1],maths.mulItems(s,f)),t[1]]]}r=function chordLengthParameterize(t){var e,s,n,i=[];t.forEach(((t,a)=>{e=a?s+maths.vectorLen(maths.subtract(t,n)):0;i.push(e);s=e;n=t}));i=i.map((t=>t/s));return i}(t);[a,l,h]=generateAndReport(t,r,r,e,s,i);if(0===l||l.9999&&t<1.0001)break}c=l;d=h}}m=[];if((u=maths.subtract(t[h-1],t[h+1])).every((t=>0===t))){u=maths.subtract(t[h-1],t[h]);[u[0],u[1]]=[-u[1],u[0]]}p=maths.normalize(u);g=maths.mulItems(p,-1);return m=(m=m.concat(fitCubic(t.slice(0,h+1),e,p,n,i))).concat(fitCubic(t.slice(h),g,s,n,i))}function generateAndReport(t,e,s,n,i,a){var r,o,l;r=function generateBezier(t,e,s,n){var i,a,r,o,l,c,h,d,u,p,g,m,f,b,A,_,y,v=t[0],S=t[t.length-1];i=[v,null,null,S];a=maths.zeros_Xx2x2(e.length);for(f=0,b=e.length;fi){i=n;a=o}}return[i,a]}(t,r,e);a&&a({bez:r,points:t,params:e,maxErr:o,maxPoint:l});return[r,o,l]}function reparameterize(t,e,s){return s.map(((s,n)=>newtonRaphsonRootFind(t,e[n],s)))}function newtonRaphsonRootFind(t,e,s){var n=maths.subtract(bezier.q(t,s),e),i=bezier.qprime(t,s),a=maths.mulMatrix(n,i),r=maths.sum(maths.squareItems(i))+2*maths.mulMatrix(n,bezier.qprimeprime(t,s));return 0===r?s:s-a/r}var mapTtoRelativeDistances=function(t,e){for(var s,n=[0],i=t[0],a=0,r=1;r<=e;r++){s=bezier.q(t,r/e);a+=maths.vectorLen(maths.subtract(s,i));n.push(a);i=s}return n=n.map((t=>t/a))};function find_t(t,e,s,n){if(e<0)return 0;if(e>1)return 1;for(var i,a,r,o,l=1;l<=n;l++)if(e<=s[l]){r=(l-1)/n;a=l/n;o=(e-(i=s[l-1]))/(s[l]-i)*(a-r)+r;break}return o}function createTangent(t,e){return maths.normalize(maths.subtract(t,e))}class maths{static zeros_Xx2x2(t){for(var e=[];t--;)e.push([0,0]);return e}static mulItems(t,e){return t.map((t=>t*e))}static mulMatrix(t,e){return t.reduce(((t,s,n)=>t+s*e[n]),0)}static subtract(t,e){return t.map(((t,s)=>t-e[s]))}static addArrays(t,e){return t.map(((t,s)=>t+e[s]))}static addItems(t,e){return t.map((t=>t+e))}static sum(t){return t.reduce(((t,e)=>t+e))}static dot(t,e){return maths.mulMatrix(t,e)}static vectorLen(t){return Math.hypot(...t)}static divItems(t,e){return t.map((t=>t/e))}static squareItems(t){return t.map((t=>t*t))}static normalize(t){return this.divItems(t,this.vectorLen(t))}}class bezier{static q(t,e){var s=1-e,n=maths.mulItems(t[0],s*s*s),i=maths.mulItems(t[1],3*s*s*e),a=maths.mulItems(t[2],3*s*e*e),r=maths.mulItems(t[3],e*e*e);return maths.addArrays(maths.addArrays(n,i),maths.addArrays(a,r))}static qprime(t,e){var s=1-e,n=maths.mulItems(maths.subtract(t[1],t[0]),3*s*s),i=maths.mulItems(maths.subtract(t[2],t[1]),6*s*e),a=maths.mulItems(maths.subtract(t[3],t[2]),3*e*e);return maths.addArrays(maths.addArrays(n,i),a)}static qprimeprime(t,e){return maths.addArrays(maths.mulItems(maths.addArrays(maths.subtract(t[2],maths.mulItems(t[1],2)),t[0]),6*(1-e)),maths.mulItems(maths.addArrays(maths.subtract(t[3],maths.mulItems(t[2],2)),t[1]),6*e))}}t.exports=function fitCurve(t,e,s){if(!Array.isArray(t))throw new TypeError("First argument should be an array");t.forEach((e=>{if(!Array.isArray(e)||e.some((t=>"number"!=typeof t))||e.length!==t[0].length)throw Error("Each point should be an array of numbers. Each point should have the same amount of numbers.")}));if((t=t.filter(((e,s)=>0===s||!e.every(((e,n)=>e===t[s-1][n]))))).length<2)return[];const n=t.length,i=createTangent(t[1],t[0]),a=createTangent(t[n-2],t[n-1]);return fitCubic(t,i,a,e,s)};t.exports.fitCubic=fitCubic;t.exports.createTangent=createTangent},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.AnnotationLayer=void 0;var n=s(1),i=s(6),a=s(3),r=s(28),o=s(29);const l=1e3,c=new WeakSet;function getRectDims(t){return{width:t[2]-t[0],height:t[3]-t[1]}}class AnnotationElementFactory{static create(t){switch(t.data.annotationType){case n.AnnotationType.LINK:return new LinkAnnotationElement(t);case n.AnnotationType.TEXT:return new TextAnnotationElement(t);case n.AnnotationType.WIDGET:switch(t.data.fieldType){case"Tx":return new TextWidgetAnnotationElement(t);case"Btn":return t.data.radioButton?new RadioButtonWidgetAnnotationElement(t):t.data.checkBox?new CheckboxWidgetAnnotationElement(t):new PushButtonWidgetAnnotationElement(t);case"Ch":return new ChoiceWidgetAnnotationElement(t)}return new WidgetAnnotationElement(t);case n.AnnotationType.POPUP:return new PopupAnnotationElement(t);case n.AnnotationType.FREETEXT:return new FreeTextAnnotationElement(t);case n.AnnotationType.LINE:return new LineAnnotationElement(t);case n.AnnotationType.SQUARE:return new SquareAnnotationElement(t);case n.AnnotationType.CIRCLE:return new CircleAnnotationElement(t);case n.AnnotationType.POLYLINE:return new PolylineAnnotationElement(t);case n.AnnotationType.CARET:return new CaretAnnotationElement(t);case n.AnnotationType.INK:return new InkAnnotationElement(t);case n.AnnotationType.POLYGON:return new PolygonAnnotationElement(t);case n.AnnotationType.HIGHLIGHT:return new HighlightAnnotationElement(t);case n.AnnotationType.UNDERLINE:return new UnderlineAnnotationElement(t);case n.AnnotationType.SQUIGGLY:return new SquigglyAnnotationElement(t);case n.AnnotationType.STRIKEOUT:return new StrikeOutAnnotationElement(t);case n.AnnotationType.STAMP:return new StampAnnotationElement(t);case n.AnnotationType.FILEATTACHMENT:return new FileAttachmentAnnotationElement(t);default:return new AnnotationElement(t)}}}class AnnotationElement{constructor(t,{isRenderable:e=!1,ignoreBorder:s=!1,createQuadrilaterals:n=!1}={}){this.isRenderable=e;this.data=t.data;this.layer=t.layer;this.page=t.page;this.viewport=t.viewport;this.linkService=t.linkService;this.downloadManager=t.downloadManager;this.imageResourcesPath=t.imageResourcesPath;this.renderForms=t.renderForms;this.svgFactory=t.svgFactory;this.annotationStorage=t.annotationStorage;this.enableScripting=t.enableScripting;this.hasJSActions=t.hasJSActions;this._fieldObjects=t.fieldObjects;e&&(this.container=this._createContainer(s));n&&(this.quadrilaterals=this._createQuadrilaterals(s))}_createContainer(t=!1){const{data:e,page:s,viewport:i}=this,a=document.createElement("section");a.setAttribute("data-annotation-id",e.id);const{pageWidth:r,pageHeight:o,pageX:l,pageY:c}=i.rawDims,{width:h,height:d}=getRectDims(e.rect),u=n.Util.normalizeRect([e.rect[0],s.view[3]-e.rect[1]+s.view[1],e.rect[2],s.view[3]-e.rect[3]+s.view[1]]);if(!t&&e.borderStyle.width>0){a.style.borderWidth=`${e.borderStyle.width}px`;const t=e.borderStyle.horizontalCornerRadius,s=e.borderStyle.verticalCornerRadius;if(t>0||s>0){const e=`calc(${t}px * var(--scale-factor)) / calc(${s}px * var(--scale-factor))`;a.style.borderRadius=e}else if(this instanceof RadioButtonWidgetAnnotationElement){const t=`calc(${h}px * var(--scale-factor)) / calc(${d}px * var(--scale-factor))`;a.style.borderRadius=t}switch(e.borderStyle.style){case n.AnnotationBorderStyleType.SOLID:a.style.borderStyle="solid";break;case n.AnnotationBorderStyleType.DASHED:a.style.borderStyle="dashed";break;case n.AnnotationBorderStyleType.BEVELED:(0,n.warn)("Unimplemented border style: beveled");break;case n.AnnotationBorderStyleType.INSET:(0,n.warn)("Unimplemented border style: inset");break;case n.AnnotationBorderStyleType.UNDERLINE:a.style.borderBottomStyle="solid"}const i=e.borderColor||null;i?a.style.borderColor=n.Util.makeHexColor(0|i[0],0|i[1],0|i[2]):a.style.borderWidth=0}a.style.left=100*(u[0]-l)/r+"%";a.style.top=100*(u[1]-c)/o+"%";const{rotation:p}=e;if(e.hasOwnCanvas||0===p){a.style.width=100*h/r+"%";a.style.height=100*d/o+"%"}else this.setRotation(p,a);return a}setRotation(t,e=this.container){const{pageWidth:s,pageHeight:n}=this.viewport.rawDims,{width:i,height:a}=getRectDims(this.data.rect);let r,o;if(t%180==0){r=100*i/s;o=100*a/n}else{r=100*a/s;o=100*i/n}e.style.width=`${r}%`;e.style.height=`${o}%`;e.setAttribute("data-main-rotation",(360-t)%360)}get _commonActions(){const setColor=(t,e,s)=>{const n=s.detail[t];s.target.style[e]=r.ColorConverters[`${n[0]}_HTML`](n.slice(1))};return(0,n.shadow)(this,"_commonActions",{display:t=>{const e=t.detail.display%2==1;this.container.style.visibility=e?"hidden":"visible";this.annotationStorage.setValue(this.data.id,{hidden:e,print:0===t.detail.display||3===t.detail.display})},print:t=>{this.annotationStorage.setValue(this.data.id,{print:t.detail.print})},hidden:t=>{this.container.style.visibility=t.detail.hidden?"hidden":"visible";this.annotationStorage.setValue(this.data.id,{hidden:t.detail.hidden})},focus:t=>{setTimeout((()=>t.target.focus({preventScroll:!1})),0)},userName:t=>{t.target.title=t.detail.userName},readonly:t=>{t.detail.readonly?t.target.setAttribute("readonly",""):t.target.removeAttribute("readonly")},required:t=>{this._setRequired(t.target,t.detail.required)},bgColor:t=>{setColor("bgColor","backgroundColor",t)},fillColor:t=>{setColor("fillColor","backgroundColor",t)},fgColor:t=>{setColor("fgColor","color",t)},textColor:t=>{setColor("textColor","color",t)},borderColor:t=>{setColor("borderColor","borderColor",t)},strokeColor:t=>{setColor("strokeColor","borderColor",t)},rotation:t=>{const e=t.detail.rotation;this.setRotation(e);this.annotationStorage.setValue(this.data.id,{rotation:e})}})}_dispatchEventFromSandbox(t,e){const s=this._commonActions;for(const n of Object.keys(e.detail)){(t[n]||s[n])?.(e)}}_setDefaultPropertiesFromJS(t){if(!this.enableScripting)return;const e=this.annotationStorage.getRawValue(this.data.id);if(!e)return;const s=this._commonActions;for(const[n,i]of Object.entries(e)){const a=s[n];if(a){a({detail:{[n]:i},target:t});delete e[n]}}}_createQuadrilaterals(t=!1){if(!this.data.quadPoints)return null;const e=[],s=this.data.rect;for(const s of this.data.quadPoints){this.data.rect=[s[2].x,s[2].y,s[1].x,s[1].y];e.push(this._createContainer(t))}this.data.rect=s;return e}_createPopup(t,e){let s=this.container;if(this.quadrilaterals){t=t||this.quadrilaterals;s=this.quadrilaterals[0]}if(!t){(t=document.createElement("div")).className="popupTriggerArea";s.append(t)}const n=new PopupElement({container:s,trigger:t,color:e.color,titleObj:e.titleObj,modificationDate:e.modificationDate,contentsObj:e.contentsObj,richText:e.richText,hideWrapper:!0}).render();n.style.left="100%";s.append(n)}_renderQuadrilaterals(t){for(const e of this.quadrilaterals)e.className=t;return this.quadrilaterals}render(){(0,n.unreachable)("Abstract method `AnnotationElement.render` called")}_getElementsByName(t,e=null){const s=[];if(this._fieldObjects){const i=this._fieldObjects[t];if(i)for(const{page:t,id:a,exportValues:r}of i){if(-1===t)continue;if(a===e)continue;const i="string"==typeof r?r:null,o=document.querySelector(`[data-element-id="${a}"]`);!o||c.has(o)?s.push({id:a,exportValue:i,domElement:o}):(0,n.warn)(`_getElementsByName - element not allowed: ${a}`)}return s}for(const n of document.getElementsByName(t)){const{exportValue:t}=n,i=n.getAttribute("data-element-id");i!==e&&(c.has(n)&&s.push({id:i,exportValue:t,domElement:n}))}return s}}class LinkAnnotationElement extends AnnotationElement{constructor(t,e=null){super(t,{isRenderable:!0,ignoreBorder:!!e?.ignoreBorder,createQuadrilaterals:!0});this.isTooltipOnly=t.data.isTooltipOnly}render(){const{data:t,linkService:e}=this,s=document.createElement("a");s.setAttribute("data-element-id",t.id);let n=!1;if(t.url){e.addLinkAttributes(s,t.url,t.newWindow);n=!0}else if(t.action){this._bindNamedAction(s,t.action);n=!0}else if(t.attachment){this._bindAttachment(s,t.attachment);n=!0}else if(t.setOCGState){this.#Ce(s,t.setOCGState);n=!0}else if(t.dest){this._bindLink(s,t.dest);n=!0}else{if(t.actions&&(t.actions.Action||t.actions["Mouse Up"]||t.actions["Mouse Down"])&&this.enableScripting&&this.hasJSActions){this._bindJSAction(s,t);n=!0}if(t.resetForm){this._bindResetFormAction(s,t.resetForm);n=!0}else if(this.isTooltipOnly&&!n){this._bindLink(s,"");n=!0}}if(this.quadrilaterals)return this._renderQuadrilaterals("linkAnnotation").map(((t,e)=>{const n=0===e?s:s.cloneNode();t.append(n);return t}));this.container.className="linkAnnotation";n&&this.container.append(s);return this.container}#Pe(){this.container.setAttribute("data-internal-link","")}_bindLink(t,e){t.href=this.linkService.getDestinationHash(e);t.onclick=()=>{e&&this.linkService.goToDestination(e);return!1};(e||""===e)&&this.#Pe()}_bindNamedAction(t,e){t.href=this.linkService.getAnchorUrl("");t.onclick=()=>{this.linkService.executeNamedAction(e);return!1};this.#Pe()}_bindAttachment(t,e){t.href=this.linkService.getAnchorUrl("");t.onclick=()=>{this.downloadManager?.openOrDownloadData(this.container,e.content,e.filename);return!1};this.#Pe()}#Ce(t,e){t.href=this.linkService.getAnchorUrl("");t.onclick=()=>{this.linkService.executeSetOCGState(e);return!1};this.#Pe()}_bindJSAction(t,e){t.href=this.linkService.getAnchorUrl("");const s=new Map([["Action","onclick"],["Mouse Up","onmouseup"],["Mouse Down","onmousedown"]]);for(const n of Object.keys(e.actions)){const i=s.get(n);i&&(t[i]=()=>{this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e.id,name:n}});return!1})}t.onclick||(t.onclick=()=>!1);this.#Pe()}_bindResetFormAction(t,e){const s=t.onclick;s||(t.href=this.linkService.getAnchorUrl(""));this.#Pe();if(this._fieldObjects)t.onclick=()=>{s?.();const{fields:t,refs:i,include:a}=e,r=[];if(0!==t.length||0!==i.length){const e=new Set(i);for(const s of t){const t=this._fieldObjects[s]||[];for(const{id:s}of t)e.add(s)}for(const t of Object.values(this._fieldObjects))for(const s of t)e.has(s.id)===a&&r.push(s)}else for(const t of Object.values(this._fieldObjects))r.push(...t);const o=this.annotationStorage,l=[];for(const t of r){const{id:e}=t;l.push(e);switch(t.type){case"text":{const s=t.defaultValue||"";o.setValue(e,{value:s});break}case"checkbox":case"radiobutton":{const s=t.defaultValue===t.exportValues;o.setValue(e,{value:s});break}case"combobox":case"listbox":{const s=t.defaultValue||"";o.setValue(e,{value:s});break}default:continue}const s=document.querySelector(`[data-element-id="${e}"]`);s&&(c.has(s)?s.dispatchEvent(new Event("resetform")):(0,n.warn)(`_bindResetFormAction - element not allowed: ${e}`))}this.enableScripting&&this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:"app",ids:l,name:"ResetForm"}});return!1};else{(0,n.warn)('_bindResetFormAction - "resetForm" action not supported, ensure that the `fieldObjects` parameter is provided.');s||(t.onclick=()=>!1)}}}class TextAnnotationElement extends AnnotationElement{constructor(t){super(t,{isRenderable:!!(t.data.hasPopup||t.data.titleObj?.str||t.data.contentsObj?.str||t.data.richText?.str)})}render(){this.container.className="textAnnotation";const t=document.createElement("img");t.src=this.imageResourcesPath+"annotation-"+this.data.name.toLowerCase()+".svg";t.alt="[{{type}} Annotation]";t.dataset.l10nId="text_annotation_type";t.dataset.l10nArgs=JSON.stringify({type:this.data.name});this.data.hasPopup||this._createPopup(t,this.data);this.container.append(t);return this.container}}class WidgetAnnotationElement extends AnnotationElement{render(){this.data.alternativeText&&(this.container.title=this.data.alternativeText);return this.container}_getKeyModifier(t){const{isWin:e,isMac:s}=n.FeatureTest.platform;return e&&t.ctrlKey||s&&t.metaKey}_setEventListener(t,e,s,n){e.includes("mouse")?t.addEventListener(e,(t=>{this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:this.data.id,name:s,value:n(t),shift:t.shiftKey,modifier:this._getKeyModifier(t)}})})):t.addEventListener(e,(t=>{this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:this.data.id,name:s,value:n(t)}})}))}_setEventListeners(t,e,s){for(const[n,i]of e)("Action"===i||this.data.actions?.[i])&&this._setEventListener(t,n,i,s)}_setBackgroundColor(t){const e=this.data.backgroundColor||null;t.style.backgroundColor=null===e?"transparent":n.Util.makeHexColor(e[0],e[1],e[2])}_setTextStyle(t){const e=["left","center","right"],{fontColor:s}=this.data.defaultAppearanceData,i=this.data.defaultAppearanceData.fontSize||9,a=t.style;let r;const roundToOneDecimal=t=>Math.round(10*t)/10;if(this.data.multiLine){const t=Math.abs(this.data.rect[3]-this.data.rect[1]-2),e=t/(Math.round(t/(n.LINE_FACTOR*i))||1);r=Math.min(i,roundToOneDecimal(e/n.LINE_FACTOR))}else{const t=Math.abs(this.data.rect[3]-this.data.rect[1]-2);r=Math.min(i,roundToOneDecimal(t/n.LINE_FACTOR))}a.fontSize=`calc(${r}px * var(--scale-factor))`;a.color=n.Util.makeHexColor(s[0],s[1],s[2]);null!==this.data.textAlignment&&(a.textAlign=e[this.data.textAlignment])}_setRequired(t,e){e?t.setAttribute("required",!0):t.removeAttribute("required");t.setAttribute("aria-required",e)}}class TextWidgetAnnotationElement extends WidgetAnnotationElement{constructor(t){super(t,{isRenderable:t.renderForms||!t.data.hasAppearance&&!!t.data.fieldValue})}setPropertyOnSiblings(t,e,s,n){const i=this.annotationStorage;for(const a of this._getElementsByName(t.name,t.id)){a.domElement&&(a.domElement[e]=s);i.setValue(a.id,{[n]:s})}}render(){const t=this.annotationStorage,e=this.data.id;this.container.className="textWidgetAnnotation";let s=null;if(this.renderForms){const n=t.getValue(e,{value:this.data.fieldValue});let i=n.formattedValue||n.value||"";const a=t.getValue(e,{charLimit:this.data.maxLen}).charLimit;a&&i.length>a&&(i=i.slice(0,a));const r={userValue:i,formattedValue:null,lastCommittedValue:null,commitKey:1};if(this.data.multiLine){s=document.createElement("textarea");s.textContent=i;this.data.doNotScroll&&(s.style.overflowY="hidden")}else{s=document.createElement("input");s.type="text";s.setAttribute("value",i);this.data.doNotScroll&&(s.style.overflowX="hidden")}c.add(s);s.setAttribute("data-element-id",e);s.disabled=this.data.readOnly;s.name=this.data.fieldName;s.tabIndex=l;this._setRequired(s,this.data.required);a&&(s.maxLength=a);s.addEventListener("input",(n=>{t.setValue(e,{value:n.target.value});this.setPropertyOnSiblings(s,"value",n.target.value,"value")}));s.addEventListener("resetform",(t=>{const e=this.data.defaultFieldValue??"";s.value=r.userValue=e;r.formattedValue=null}));let blurListener=t=>{const{formattedValue:e}=r;null!=e&&(t.target.value=e);t.target.scrollLeft=0};if(this.enableScripting&&this.hasJSActions){s.addEventListener("focus",(t=>{const{target:e}=t;r.userValue&&(e.value=r.userValue);r.lastCommittedValue=e.value;r.commitKey=1}));s.addEventListener("updatefromsandbox",(s=>{const n={value(s){r.userValue=s.detail.value??"";t.setValue(e,{value:r.userValue.toString()});s.target.value=r.userValue},formattedValue(s){const{formattedValue:n}=s.detail;r.formattedValue=n;null!=n&&s.target!==document.activeElement&&(s.target.value=n);t.setValue(e,{formattedValue:n})},selRange(t){t.target.setSelectionRange(...t.detail.selRange)},charLimit:s=>{const{charLimit:n}=s.detail,{target:i}=s;if(0===n){i.removeAttribute("maxLength");return}i.setAttribute("maxLength",n);let a=r.userValue;if(a&&!(a.length<=n)){a=a.slice(0,n);i.value=r.userValue=a;t.setValue(e,{value:a});this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e,name:"Keystroke",value:a,willCommit:!0,commitKey:1,selStart:i.selectionStart,selEnd:i.selectionEnd}})}}};this._dispatchEventFromSandbox(n,s)}));s.addEventListener("keydown",(t=>{r.commitKey=1;let s=-1;"Escape"===t.key?s=0:"Enter"!==t.key||this.data.multiLine?"Tab"===t.key&&(r.commitKey=3):s=2;if(-1===s)return;const{value:n}=t.target;if(r.lastCommittedValue!==n){r.lastCommittedValue=n;r.userValue=n;this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e,name:"Keystroke",value:n,willCommit:!0,commitKey:s,selStart:t.target.selectionStart,selEnd:t.target.selectionEnd}})}}));const n=blurListener;blurListener=null;s.addEventListener("blur",(t=>{if(!t.relatedTarget)return;const{value:s}=t.target;r.userValue=s;r.lastCommittedValue!==s&&this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e,name:"Keystroke",value:s,willCommit:!0,commitKey:r.commitKey,selStart:t.target.selectionStart,selEnd:t.target.selectionEnd}});n(t)}));this.data.actions?.Keystroke&&s.addEventListener("beforeinput",(t=>{r.lastCommittedValue=null;const{data:s,target:n}=t,{value:i,selectionStart:a,selectionEnd:o}=n;let l=a,c=o;switch(t.inputType){case"deleteWordBackward":{const t=i.substring(0,a).match(/\w*[^\w]*$/);t&&(l-=t[0].length);break}case"deleteWordForward":{const t=i.substring(a).match(/^[^\w]*\w*/);t&&(c+=t[0].length);break}case"deleteContentBackward":a===o&&(l-=1);break;case"deleteContentForward":a===o&&(c+=1)}t.preventDefault();this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e,name:"Keystroke",value:i,change:s||"",willCommit:!1,selStart:l,selEnd:c}})}));this._setEventListeners(s,[["focus","Focus"],["blur","Blur"],["mousedown","Mouse Down"],["mouseenter","Mouse Enter"],["mouseleave","Mouse Exit"],["mouseup","Mouse Up"]],(t=>t.target.value))}blurListener&&s.addEventListener("blur",blurListener);if(this.data.comb){const t=(this.data.rect[2]-this.data.rect[0])/a;s.classList.add("comb");s.style.letterSpacing=`calc(${t}px * var(--scale-factor) - 1ch)`}}else{s=document.createElement("div");s.textContent=this.data.fieldValue;s.style.verticalAlign="middle";s.style.display="table-cell"}this._setTextStyle(s);this._setBackgroundColor(s);this._setDefaultPropertiesFromJS(s);this.container.append(s);return this.container}}class CheckboxWidgetAnnotationElement extends WidgetAnnotationElement{constructor(t){super(t,{isRenderable:t.renderForms})}render(){const t=this.annotationStorage,e=this.data,s=e.id;let n=t.getValue(s,{value:e.exportValue===e.fieldValue}).value;if("string"==typeof n){n="Off"!==n;t.setValue(s,{value:n})}this.container.className="buttonWidgetAnnotation checkBox";const i=document.createElement("input");c.add(i);i.setAttribute("data-element-id",s);i.disabled=e.readOnly;this._setRequired(i,this.data.required);i.type="checkbox";i.name=e.fieldName;n&&i.setAttribute("checked",!0);i.setAttribute("exportValue",e.exportValue);i.tabIndex=l;i.addEventListener("change",(n=>{const{name:i,checked:a}=n.target;for(const n of this._getElementsByName(i,s)){const s=a&&n.exportValue===e.exportValue;n.domElement&&(n.domElement.checked=s);t.setValue(n.id,{value:s})}t.setValue(s,{value:a})}));i.addEventListener("resetform",(t=>{const s=e.defaultFieldValue||"Off";t.target.checked=s===e.exportValue}));if(this.enableScripting&&this.hasJSActions){i.addEventListener("updatefromsandbox",(e=>{const n={value(e){e.target.checked="Off"!==e.detail.value;t.setValue(s,{value:e.target.checked})}};this._dispatchEventFromSandbox(n,e)}));this._setEventListeners(i,[["change","Validate"],["change","Action"],["focus","Focus"],["blur","Blur"],["mousedown","Mouse Down"],["mouseenter","Mouse Enter"],["mouseleave","Mouse Exit"],["mouseup","Mouse Up"]],(t=>t.target.checked))}this._setBackgroundColor(i);this._setDefaultPropertiesFromJS(i);this.container.append(i);return this.container}}class RadioButtonWidgetAnnotationElement extends WidgetAnnotationElement{constructor(t){super(t,{isRenderable:t.renderForms})}render(){this.container.className="buttonWidgetAnnotation radioButton";const t=this.annotationStorage,e=this.data,s=e.id;let n=t.getValue(s,{value:e.fieldValue===e.buttonValue}).value;if("string"==typeof n){n=n!==e.buttonValue;t.setValue(s,{value:n})}const i=document.createElement("input");c.add(i);i.setAttribute("data-element-id",s);i.disabled=e.readOnly;this._setRequired(i,this.data.required);i.type="radio";i.name=e.fieldName;n&&i.setAttribute("checked",!0);i.tabIndex=l;i.addEventListener("change",(e=>{const{name:n,checked:i}=e.target;for(const e of this._getElementsByName(n,s))t.setValue(e.id,{value:!1});t.setValue(s,{value:i})}));i.addEventListener("resetform",(t=>{const s=e.defaultFieldValue;t.target.checked=null!=s&&s===e.buttonValue}));if(this.enableScripting&&this.hasJSActions){const n=e.buttonValue;i.addEventListener("updatefromsandbox",(e=>{const i={value:e=>{const i=n===e.detail.value;for(const n of this._getElementsByName(e.target.name)){const e=i&&n.id===s;n.domElement&&(n.domElement.checked=e);t.setValue(n.id,{value:e})}}};this._dispatchEventFromSandbox(i,e)}));this._setEventListeners(i,[["change","Validate"],["change","Action"],["focus","Focus"],["blur","Blur"],["mousedown","Mouse Down"],["mouseenter","Mouse Enter"],["mouseleave","Mouse Exit"],["mouseup","Mouse Up"]],(t=>t.target.checked))}this._setBackgroundColor(i);this._setDefaultPropertiesFromJS(i);this.container.append(i);return this.container}}class PushButtonWidgetAnnotationElement extends LinkAnnotationElement{constructor(t){super(t,{ignoreBorder:t.data.hasAppearance})}render(){const t=super.render();t.className="buttonWidgetAnnotation pushButton";this.data.alternativeText&&(t.title=this.data.alternativeText);const e=t.lastChild;if(this.enableScripting&&this.hasJSActions&&e){this._setDefaultPropertiesFromJS(e);e.addEventListener("updatefromsandbox",(t=>{this._dispatchEventFromSandbox({},t)}))}return t}}class ChoiceWidgetAnnotationElement extends WidgetAnnotationElement{constructor(t){super(t,{isRenderable:t.renderForms})}render(){this.container.className="choiceWidgetAnnotation";const t=this.annotationStorage,e=this.data.id,s=t.getValue(e,{value:this.data.fieldValue}),n=document.createElement("select");c.add(n);n.setAttribute("data-element-id",e);n.disabled=this.data.readOnly;this._setRequired(n,this.data.required);n.name=this.data.fieldName;n.tabIndex=l;let i=this.data.combo&&this.data.options.length>0;if(!this.data.combo){n.size=this.data.options.length;this.data.multiSelect&&(n.multiple=!0)}n.addEventListener("resetform",(t=>{const e=this.data.defaultFieldValue;for(const t of n.options)t.selected=t.value===e}));for(const t of this.data.options){const e=document.createElement("option");e.textContent=t.displayValue;e.value=t.exportValue;if(s.value.includes(t.exportValue)){e.setAttribute("selected",!0);i=!1}n.append(e)}let a=null;if(i){const t=document.createElement("option");t.value=" ";t.setAttribute("hidden",!0);t.setAttribute("selected",!0);n.prepend(t);a=()=>{t.remove();n.removeEventListener("input",a);a=null};n.addEventListener("input",a)}const getValue=t=>{const e=t?"value":"textContent",{options:s,multiple:i}=n;return i?Array.prototype.filter.call(s,(t=>t.selected)).map((t=>t[e])):-1===s.selectedIndex?null:s[s.selectedIndex][e]};let r=getValue(!1);const getItems=t=>{const e=t.target.options;return Array.prototype.map.call(e,(t=>({displayValue:t.textContent,exportValue:t.value})))};if(this.enableScripting&&this.hasJSActions){n.addEventListener("updatefromsandbox",(s=>{const i={value(s){a?.();const i=s.detail.value,o=new Set(Array.isArray(i)?i:[i]);for(const t of n.options)t.selected=o.has(t.value);t.setValue(e,{value:getValue(!0)});r=getValue(!1)},multipleSelection(t){n.multiple=!0},remove(s){const i=n.options,a=s.detail.remove;i[a].selected=!1;n.remove(a);if(i.length>0){-1===Array.prototype.findIndex.call(i,(t=>t.selected))&&(i[0].selected=!0)}t.setValue(e,{value:getValue(!0),items:getItems(s)});r=getValue(!1)},clear(s){for(;0!==n.length;)n.remove(0);t.setValue(e,{value:null,items:[]});r=getValue(!1)},insert(s){const{index:i,displayValue:a,exportValue:o}=s.detail.insert,l=n.children[i],c=document.createElement("option");c.textContent=a;c.value=o;l?l.before(c):n.append(c);t.setValue(e,{value:getValue(!0),items:getItems(s)});r=getValue(!1)},items(s){const{items:i}=s.detail;for(;0!==n.length;)n.remove(0);for(const t of i){const{displayValue:e,exportValue:s}=t,i=document.createElement("option");i.textContent=e;i.value=s;n.append(i)}n.options.length>0&&(n.options[0].selected=!0);t.setValue(e,{value:getValue(!0),items:getItems(s)});r=getValue(!1)},indices(s){const n=new Set(s.detail.indices);for(const t of s.target.options)t.selected=n.has(t.index);t.setValue(e,{value:getValue(!0)});r=getValue(!1)},editable(t){t.target.disabled=!t.detail.editable}};this._dispatchEventFromSandbox(i,s)}));n.addEventListener("input",(s=>{const n=getValue(!0);t.setValue(e,{value:n});s.preventDefault();this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e,name:"Keystroke",value:r,changeEx:n,willCommit:!1,commitKey:1,keyDown:!1}})}));this._setEventListeners(n,[["focus","Focus"],["blur","Blur"],["mousedown","Mouse Down"],["mouseenter","Mouse Enter"],["mouseleave","Mouse Exit"],["mouseup","Mouse Up"],["input","Action"]],(t=>t.target.checked))}else n.addEventListener("input",(function(s){t.setValue(e,{value:getValue(!0)})}));this.data.combo&&this._setTextStyle(n);this._setBackgroundColor(n);this._setDefaultPropertiesFromJS(n);this.container.append(n);return this.container}}class PopupAnnotationElement extends AnnotationElement{static IGNORE_TYPES=new Set(["Line","Square","Circle","PolyLine","Polygon","Ink"]);constructor(t){const{data:e}=t;super(t,{isRenderable:!PopupAnnotationElement.IGNORE_TYPES.has(e.parentType)&&!!(e.titleObj?.str||e.contentsObj?.str||e.richText?.str)})}render(){this.container.className="popupAnnotation";const t=this.layer.querySelectorAll(`[data-annotation-id="${this.data.parentId}"]`);if(0===t.length)return this.container;const e=new PopupElement({container:this.container,trigger:Array.from(t),color:this.data.color,titleObj:this.data.titleObj,modificationDate:this.data.modificationDate,contentsObj:this.data.contentsObj,richText:this.data.richText}),s=this.page,i=n.Util.normalizeRect([this.data.parentRect[0],s.view[3]-this.data.parentRect[1]+s.view[1],this.data.parentRect[2],s.view[3]-this.data.parentRect[3]+s.view[1]]),a=i[0]+this.data.parentRect[2]-this.data.parentRect[0],r=i[1],{pageWidth:o,pageHeight:l,pageX:c,pageY:h}=this.viewport.rawDims;this.container.style.left=100*(a-c)/o+"%";this.container.style.top=100*(r-h)/l+"%";this.container.append(e.render());return this.container}}class PopupElement{constructor(t){this.container=t.container;this.trigger=t.trigger;this.color=t.color;this.titleObj=t.titleObj;this.modificationDate=t.modificationDate;this.contentsObj=t.contentsObj;this.richText=t.richText;this.hideWrapper=t.hideWrapper||!1;this.pinned=!1}render(){const t=document.createElement("div");t.className="popupWrapper";this.hideElement=this.hideWrapper?t:this.container;this.hideElement.hidden=!0;const e=document.createElement("div");e.className="popup";const s=this.color;if(s){const t=.7*(255-s[0])+s[0],i=.7*(255-s[1])+s[1],a=.7*(255-s[2])+s[2];e.style.backgroundColor=n.Util.makeHexColor(0|t,0|i,0|a)}const a=document.createElement("h1");a.dir=this.titleObj.dir;a.textContent=this.titleObj.str;e.append(a);const r=i.PDFDateString.toDateObject(this.modificationDate);if(r){const t=document.createElement("span");t.className="popupDate";t.textContent="{{date}}, {{time}}";t.dataset.l10nId="annotation_date_string";t.dataset.l10nArgs=JSON.stringify({date:r.toLocaleDateString(),time:r.toLocaleTimeString()});e.append(t)}if(!this.richText?.str||this.contentsObj?.str&&this.contentsObj.str!==this.richText.str){const t=this._formatContents(this.contentsObj);e.append(t)}else{o.XfaLayer.render({xfaHtml:this.richText.html,intent:"richText",div:e});e.lastChild.className="richText popupContent"}Array.isArray(this.trigger)||(this.trigger=[this.trigger]);for(const t of this.trigger){t.addEventListener("click",this._toggle.bind(this));t.addEventListener("mouseover",this._show.bind(this,!1));t.addEventListener("mouseout",this._hide.bind(this,!1))}e.addEventListener("click",this._hide.bind(this,!0));t.append(e);return t}_formatContents({str:t,dir:e}){const s=document.createElement("p");s.className="popupContent";s.dir=e;const n=t.split(/(?:\r\n?|\n)/);for(let t=0,e=n.length;t{Object.defineProperty(e,"__esModule",{value:!0});e.ColorConverters=void 0;function makeColorComp(t){return Math.floor(255*Math.max(0,Math.min(1,t))).toString(16).padStart(2,"0")}e.ColorConverters=class ColorConverters{static CMYK_G([t,e,s,n]){return["G",1-Math.min(1,.3*t+.59*s+.11*e+n)]}static G_CMYK([t]){return["CMYK",0,0,0,1-t]}static G_RGB([t]){return["RGB",t,t,t]}static G_HTML([t]){const e=makeColorComp(t);return`#${e}${e}${e}`}static RGB_G([t,e,s]){return["G",.3*t+.59*e+.11*s]}static RGB_HTML([t,e,s]){return`#${makeColorComp(t)}${makeColorComp(e)}${makeColorComp(s)}`}static T_HTML(){return"#00000000"}static CMYK_RGB([t,e,s,n]){return["RGB",1-Math.min(1,t+n),1-Math.min(1,s+n),1-Math.min(1,e+n)]}static CMYK_HTML(t){const e=this.CMYK_RGB(t).slice(1);return this.RGB_HTML(e)}static RGB_CMYK([t,e,s]){const n=1-t,i=1-e,a=1-s;return["CMYK",n,i,a,Math.min(n,i,a)]}}},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.XfaLayer=void 0;var n=s(19);e.XfaLayer=class XfaLayer{static setupStorage(t,e,s,n,i){const a=n.getValue(e,{value:null});switch(s.name){case"textarea":null!==a.value&&(t.textContent=a.value);if("print"===i)break;t.addEventListener("input",(t=>{n.setValue(e,{value:t.target.value})}));break;case"input":if("radio"===s.attributes.type||"checkbox"===s.attributes.type){a.value===s.attributes.xfaOn?t.setAttribute("checked",!0):a.value===s.attributes.xfaOff&&t.removeAttribute("checked");if("print"===i)break;t.addEventListener("change",(t=>{n.setValue(e,{value:t.target.checked?t.target.getAttribute("xfaOn"):t.target.getAttribute("xfaOff")})}))}else{null!==a.value&&t.setAttribute("value",a.value);if("print"===i)break;t.addEventListener("input",(t=>{n.setValue(e,{value:t.target.value})}))}break;case"select":if(null!==a.value)for(const t of s.children)t.attributes.value===a.value&&(t.attributes.selected=!0);t.addEventListener("input",(t=>{const s=t.target.options,i=-1===s.selectedIndex?"":s[s.selectedIndex].value;n.setValue(e,{value:i})}))}}static setAttributes({html:t,element:e,storage:s=null,intent:n,linkService:i}){const{attributes:a}=e,r=t instanceof HTMLAnchorElement;"radio"===a.type&&(a.name=`${a.name}-${n}`);for(const[e,s]of Object.entries(a))if(null!=s)switch(e){case"class":s.length&&t.setAttribute(e,s.join(" "));break;case"dataId":break;case"id":t.setAttribute("data-element-id",s);break;case"style":Object.assign(t.style,s);break;case"textContent":t.textContent=s;break;default:(!r||"href"!==e&&"newWindow"!==e)&&t.setAttribute(e,s)}r&&i.addLinkAttributes(t,a.href,a.newWindow);s&&a.dataId&&this.setupStorage(t,a.dataId,e,s)}static render(t){const e=t.annotationStorage,s=t.linkService,i=t.xfaHtml,a=t.intent||"display",r=document.createElement(i.name);i.attributes&&this.setAttributes({html:r,element:i,intent:a,linkService:s});const o=[[i,-1,r]],l=t.div;l.append(r);if(t.viewport){const e=`matrix(${t.viewport.transform.join(",")})`;l.style.transform=e}"richText"!==a&&l.setAttribute("class","xfaLayer xfaFont");const c=[];for(;o.length>0;){const[t,i,r]=o.at(-1);if(i+1===t.children.length){o.pop();continue}const l=t.children[++o.at(-1)[1]];if(null===l)continue;const{name:h}=l;if("#text"===h){const t=document.createTextNode(l.value);c.push(t);r.append(t);continue}let d;d=l?.attributes?.xmlns?document.createElementNS(l.attributes.xmlns,h):document.createElement(h);r.append(d);l.attributes&&this.setAttributes({html:d,element:l,storage:e,intent:a,linkService:s});if(l.children&&l.children.length>0)o.push([l,-1,d]);else if(l.value){const t=document.createTextNode(l.value);n.XfaText.shouldBuildText(h)&&c.push(t);d.append(t)}}for(const t of l.querySelectorAll(".xfaNonInteractive input, .xfaNonInteractive textarea"))t.setAttribute("readOnly",!0);return{textDivs:c}}static update(t){const e=`matrix(${t.viewport.transform.join(",")})`;t.div.style.transform=e;t.div.hidden=!1}}},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.SVGGraphics=void 0;var n=s(6),i=s(1),a=s(10);let r=class{constructor(){(0,i.unreachable)("Not implemented: SVGGraphics")}};e.SVGGraphics=r;{const o={fontStyle:"normal",fontWeight:"normal",fillColor:"#000000"},l="http://www.w3.org/XML/1998/namespace",c="http://www.w3.org/1999/xlink",h=["butt","round","square"],d=["miter","round","bevel"],createObjectURL=function(t,e="",s=!1){if(URL.createObjectURL&&"undefined"!=typeof Blob&&!s)return URL.createObjectURL(new Blob([t],{type:e}));const n="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";let i=`data:${e};base64,`;for(let e=0,s=t.length;e>2]+n[(3&a)<<4|r>>4]+n[e+1>6:64]+n[e+2>1&2147483647:s>>1&2147483647;e[t]=s}function writePngChunk(t,s,n,i){let a=i;const r=s.length;n[a]=r>>24&255;n[a+1]=r>>16&255;n[a+2]=r>>8&255;n[a+3]=255&r;a+=4;n[a]=255&t.charCodeAt(0);n[a+1]=255&t.charCodeAt(1);n[a+2]=255&t.charCodeAt(2);n[a+3]=255&t.charCodeAt(3);a+=4;n.set(s,a);a+=s.length;const o=function crc32(t,s,n){let i=-1;for(let a=s;a>>8^e[s]}return-1^i}(n,i+4,a);n[a]=o>>24&255;n[a+1]=o>>16&255;n[a+2]=o>>8&255;n[a+3]=255&o}function deflateSyncUncompressed(t){let e=t.length;const s=65535,n=Math.ceil(e/s),i=new Uint8Array(2+e+5*n+4);let a=0;i[a++]=120;i[a++]=156;let r=0;for(;e>s;){i[a++]=0;i[a++]=255;i[a++]=255;i[a++]=0;i[a++]=0;i.set(t.subarray(r,r+s),a);a+=s;r+=s;e-=s}i[a++]=1;i[a++]=255&e;i[a++]=e>>8&255;i[a++]=255&~e;i[a++]=(65535&~e)>>8&255;i.set(t.subarray(r),a);a+=t.length-r;const o=function adler32(t,e,s){let n=1,i=0;for(let a=e;a>24&255;i[a++]=o>>16&255;i[a++]=o>>8&255;i[a++]=255&o;return i}function encode(e,s,n,r){const o=e.width,l=e.height;let c,h,d;const u=e.data;switch(s){case i.ImageKind.GRAYSCALE_1BPP:h=0;c=1;d=o+7>>3;break;case i.ImageKind.RGB_24BPP:h=2;c=8;d=3*o;break;case i.ImageKind.RGBA_32BPP:h=6;c=8;d=4*o;break;default:throw new Error("invalid format")}const p=new Uint8Array((1+d)*l);let g=0,m=0;for(let t=0;t>24&255,o>>16&255,o>>8&255,255&o,l>>24&255,l>>16&255,l>>8&255,255&l,c,h,0,0,0]),b=function deflateSync(t){if(!a.isNodeJS)return deflateSyncUncompressed(t);try{let e;e=parseInt(process.versions.node)>=8?t:Buffer.from(t);const s=require("zlib").deflateSync(e,{level:9});return s instanceof Uint8Array?s:new Uint8Array(s)}catch(t){(0,i.warn)("Not compressing PNG because zlib.deflateSync is unavailable: "+t)}return deflateSyncUncompressed(t)}(p),A=t.length+36+f.length+b.length,_=new Uint8Array(A);let y=0;_.set(t,y);y+=t.length;writePngChunk("IHDR",f,_,y);y+=12+f.length;writePngChunk("IDATA",b,_,y);y+=12+b.length;writePngChunk("IEND",new Uint8Array(0),_,y);return createObjectURL(_,"image/png",n)}return function convertImgDataToPng(t,e,s){return encode(t,void 0===t.kind?i.ImageKind.GRAYSCALE_1BPP:t.kind,e,s)}}();class SVGExtraState{constructor(){this.fontSizeScale=1;this.fontWeight=o.fontWeight;this.fontSize=0;this.textMatrix=i.IDENTITY_MATRIX;this.fontMatrix=i.FONT_IDENTITY_MATRIX;this.leading=0;this.textRenderingMode=i.TextRenderingMode.FILL;this.textMatrixScale=1;this.x=0;this.y=0;this.lineX=0;this.lineY=0;this.charSpacing=0;this.wordSpacing=0;this.textHScale=1;this.textRise=0;this.fillColor=o.fillColor;this.strokeColor="#000000";this.fillAlpha=1;this.strokeAlpha=1;this.lineWidth=1;this.lineJoin="";this.lineCap="";this.miterLimit=0;this.dashArray=[];this.dashPhase=0;this.dependencies=[];this.activeClipUrl=null;this.clipGroup=null;this.maskId=""}clone(){return Object.create(this)}setCurrentPoint(t,e){this.x=t;this.y=e}}function opListToTree(t){let e=[];const s=[];for(const n of t)if("save"!==n.fn)"restore"===n.fn?e=s.pop():e.push(n);else{e.push({fnId:92,fn:"group",items:[]});s.push(e);e=e.at(-1).items}return e}function pf(t){if(Number.isInteger(t))return t.toString();const e=t.toFixed(10);let s=e.length-1;if("0"!==e[s])return e;do{s--}while("0"===e[s]);return e.substring(0,"."===e[s]?s:s+1)}function pm(t){if(0===t[4]&&0===t[5]){if(0===t[1]&&0===t[2])return 1===t[0]&&1===t[3]?"":`scale(${pf(t[0])} ${pf(t[3])})`;if(t[0]===t[3]&&t[1]===-t[2]){return`rotate(${pf(180*Math.acos(t[0])/Math.PI)})`}}else if(1===t[0]&&0===t[1]&&0===t[2]&&1===t[3])return`translate(${pf(t[4])} ${pf(t[5])})`;return`matrix(${pf(t[0])} ${pf(t[1])} ${pf(t[2])} ${pf(t[3])} ${pf(t[4])} ${pf(t[5])})`}let p=0,g=0,m=0;e.SVGGraphics=r=class{constructor(t,e,s=!1){(0,n.deprecated)("The SVG back-end is no longer maintained and *may* be removed in the future.");this.svgFactory=new n.DOMSVGFactory;this.current=new SVGExtraState;this.transformMatrix=i.IDENTITY_MATRIX;this.transformStack=[];this.extraStack=[];this.commonObjs=t;this.objs=e;this.pendingClip=null;this.pendingEOFill=!1;this.embedFonts=!1;this.embeddedFonts=Object.create(null);this.cssStyle=null;this.forceDataSchema=!!s;this._operatorIdMapping=[];for(const t in i.OPS)this._operatorIdMapping[i.OPS[t]]=t}getObject(t,e=null){return"string"==typeof t?t.startsWith("g_")?this.commonObjs.get(t):this.objs.get(t):e}save(){this.transformStack.push(this.transformMatrix);const t=this.current;this.extraStack.push(t);this.current=t.clone()}restore(){this.transformMatrix=this.transformStack.pop();this.current=this.extraStack.pop();this.pendingClip=null;this.tgrp=null}group(t){this.save();this.executeOpTree(t);this.restore()}loadDependencies(t){const e=t.fnArray,s=t.argsArray;for(let t=0,n=e.length;t{t.get(e,s)}));this.current.dependencies.push(s)}return Promise.all(this.current.dependencies)}transform(t,e,s,n,a,r){const o=[t,e,s,n,a,r];this.transformMatrix=i.Util.transform(this.transformMatrix,o);this.tgrp=null}getSVG(t,e){this.viewport=e;const s=this._initialize(e);return this.loadDependencies(t).then((()=>{this.transformMatrix=i.IDENTITY_MATRIX;this.executeOpTree(this.convertOpList(t));return s}))}convertOpList(t){const e=this._operatorIdMapping,s=t.argsArray,n=t.fnArray,i=[];for(let t=0,a=n.length;t0&&(this.current.lineWidth=t)}setLineCap(t){this.current.lineCap=h[t]}setLineJoin(t){this.current.lineJoin=d[t]}setMiterLimit(t){this.current.miterLimit=t}setStrokeAlpha(t){this.current.strokeAlpha=t}setStrokeRGBColor(t,e,s){this.current.strokeColor=i.Util.makeHexColor(t,e,s)}setFillAlpha(t){this.current.fillAlpha=t}setFillRGBColor(t,e,s){this.current.fillColor=i.Util.makeHexColor(t,e,s);this.current.tspan=this.svgFactory.createElement("svg:tspan");this.current.xcoords=[];this.current.ycoords=[]}setStrokeColorN(t){this.current.strokeColor=this._makeColorN_Pattern(t)}setFillColorN(t){this.current.fillColor=this._makeColorN_Pattern(t)}shadingFill(t){const e=this.viewport.width,s=this.viewport.height,n=i.Util.inverseTransform(this.transformMatrix),a=i.Util.applyTransform([0,0],n),r=i.Util.applyTransform([0,s],n),o=i.Util.applyTransform([e,0],n),l=i.Util.applyTransform([e,s],n),c=Math.min(a[0],r[0],o[0],l[0]),h=Math.min(a[1],r[1],o[1],l[1]),d=Math.max(a[0],r[0],o[0],l[0]),u=Math.max(a[1],r[1],o[1],l[1]),p=this.svgFactory.createElement("svg:rect");p.setAttributeNS(null,"x",c);p.setAttributeNS(null,"y",h);p.setAttributeNS(null,"width",d-c);p.setAttributeNS(null,"height",u-h);p.setAttributeNS(null,"fill",this._makeShadingPattern(t));this.current.fillAlpha<1&&p.setAttributeNS(null,"fill-opacity",this.current.fillAlpha);this._ensureTransformGroup().append(p)}_makeColorN_Pattern(t){return"TilingPattern"===t[0]?this._makeTilingPattern(t):this._makeShadingPattern(t)}_makeTilingPattern(t){const e=t[1],s=t[2],n=t[3]||i.IDENTITY_MATRIX,[a,r,o,l]=t[4],c=t[5],h=t[6],d=t[7],u="shading"+m++,[p,g,f,b]=i.Util.normalizeRect([...i.Util.applyTransform([a,r],n),...i.Util.applyTransform([o,l],n)]),[A,_]=i.Util.singularValueDecompose2dScale(n),y=c*A,v=h*_,S=this.svgFactory.createElement("svg:pattern");S.setAttributeNS(null,"id",u);S.setAttributeNS(null,"patternUnits","userSpaceOnUse");S.setAttributeNS(null,"width",y);S.setAttributeNS(null,"height",v);S.setAttributeNS(null,"x",`${p}`);S.setAttributeNS(null,"y",`${g}`);const x=this.svg,E=this.transformMatrix,C=this.current.fillColor,P=this.current.strokeColor,T=this.svgFactory.create(f-p,b-g);this.svg=T;this.transformMatrix=n;if(2===d){const t=i.Util.makeHexColor(...e);this.current.fillColor=t;this.current.strokeColor=t}this.executeOpTree(this.convertOpList(s));this.svg=x;this.transformMatrix=E;this.current.fillColor=C;this.current.strokeColor=P;S.append(T.childNodes[0]);this.defs.append(S);return`url(#${u})`}_makeShadingPattern(t){"string"==typeof t&&(t=this.objs.get(t));switch(t[0]){case"RadialAxial":const e="shading"+m++,s=t[3];let n;switch(t[1]){case"axial":const s=t[4],i=t[5];n=this.svgFactory.createElement("svg:linearGradient");n.setAttributeNS(null,"id",e);n.setAttributeNS(null,"gradientUnits","userSpaceOnUse");n.setAttributeNS(null,"x1",s[0]);n.setAttributeNS(null,"y1",s[1]);n.setAttributeNS(null,"x2",i[0]);n.setAttributeNS(null,"y2",i[1]);break;case"radial":const a=t[4],r=t[5],o=t[6],l=t[7];n=this.svgFactory.createElement("svg:radialGradient");n.setAttributeNS(null,"id",e);n.setAttributeNS(null,"gradientUnits","userSpaceOnUse");n.setAttributeNS(null,"cx",r[0]);n.setAttributeNS(null,"cy",r[1]);n.setAttributeNS(null,"r",l);n.setAttributeNS(null,"fx",a[0]);n.setAttributeNS(null,"fy",a[1]);n.setAttributeNS(null,"fr",o);break;default:throw new Error(`Unknown RadialAxial type: ${t[1]}`)}for(const t of s){const e=this.svgFactory.createElement("svg:stop");e.setAttributeNS(null,"offset",t[0]);e.setAttributeNS(null,"stop-color",t[1]);n.append(e)}this.defs.append(n);return`url(#${e})`;case"Mesh":(0,i.warn)("Unimplemented pattern Mesh");return null;case"Dummy":return"hotpink";default:throw new Error(`Unknown IR type: ${t[0]}`)}}setDash(t,e){this.current.dashArray=t;this.current.dashPhase=e}constructPath(t,e){const s=this.current;let n=s.x,a=s.y,r=[],o=0;for(const s of t)switch(0|s){case i.OPS.rectangle:n=e[o++];a=e[o++];const t=n+e[o++],s=a+e[o++];r.push("M",pf(n),pf(a),"L",pf(t),pf(a),"L",pf(t),pf(s),"L",pf(n),pf(s),"Z");break;case i.OPS.moveTo:n=e[o++];a=e[o++];r.push("M",pf(n),pf(a));break;case i.OPS.lineTo:n=e[o++];a=e[o++];r.push("L",pf(n),pf(a));break;case i.OPS.curveTo:n=e[o+4];a=e[o+5];r.push("C",pf(e[o]),pf(e[o+1]),pf(e[o+2]),pf(e[o+3]),pf(n),pf(a));o+=6;break;case i.OPS.curveTo2:r.push("C",pf(n),pf(a),pf(e[o]),pf(e[o+1]),pf(e[o+2]),pf(e[o+3]));n=e[o+2];a=e[o+3];o+=4;break;case i.OPS.curveTo3:n=e[o+2];a=e[o+3];r.push("C",pf(e[o]),pf(e[o+1]),pf(n),pf(a),pf(n),pf(a));o+=4;break;case i.OPS.closePath:r.push("Z")}r=r.join(" ");if(s.path&&t.length>0&&t[0]!==i.OPS.rectangle&&t[0]!==i.OPS.moveTo)r=s.path.getAttributeNS(null,"d")+r;else{s.path=this.svgFactory.createElement("svg:path");this._ensureTransformGroup().append(s.path)}s.path.setAttributeNS(null,"d",r);s.path.setAttributeNS(null,"fill","none");s.element=s.path;s.setCurrentPoint(n,a)}endPath(){const t=this.current;t.path=null;if(!this.pendingClip)return;if(!t.element){this.pendingClip=null;return}const e="clippath"+p++,s=this.svgFactory.createElement("svg:clipPath");s.setAttributeNS(null,"id",e);s.setAttributeNS(null,"transform",pm(this.transformMatrix));const n=t.element.cloneNode(!0);"evenodd"===this.pendingClip?n.setAttributeNS(null,"clip-rule","evenodd"):n.setAttributeNS(null,"clip-rule","nonzero");this.pendingClip=null;s.append(n);this.defs.append(s);if(t.activeClipUrl){t.clipGroup=null;for(const t of this.extraStack)t.clipGroup=null;s.setAttributeNS(null,"clip-path",t.activeClipUrl)}t.activeClipUrl=`url(#${e})`;this.tgrp=null}clip(t){this.pendingClip=t}closePath(){const t=this.current;if(t.path){const e=`${t.path.getAttributeNS(null,"d")}Z`;t.path.setAttributeNS(null,"d",e)}}setLeading(t){this.current.leading=-t}setTextRise(t){this.current.textRise=t}setTextRenderingMode(t){this.current.textRenderingMode=t}setHScale(t){this.current.textHScale=t/100}setRenderingIntent(t){}setFlatness(t){}setGState(t){for(const[e,s]of t)switch(e){case"LW":this.setLineWidth(s);break;case"LC":this.setLineCap(s);break;case"LJ":this.setLineJoin(s);break;case"ML":this.setMiterLimit(s);break;case"D":this.setDash(s[0],s[1]);break;case"RI":this.setRenderingIntent(s);break;case"FL":this.setFlatness(s);break;case"Font":this.setFont(s);break;case"CA":this.setStrokeAlpha(s);break;case"ca":this.setFillAlpha(s);break;default:(0,i.warn)(`Unimplemented graphic state operator ${e}`)}}fill(){const t=this.current;if(t.element){t.element.setAttributeNS(null,"fill",t.fillColor);t.element.setAttributeNS(null,"fill-opacity",t.fillAlpha);this.endPath()}}stroke(){const t=this.current;if(t.element){this._setStrokeAttributes(t.element);t.element.setAttributeNS(null,"fill","none");this.endPath()}}_setStrokeAttributes(t,e=1){const s=this.current;let n=s.dashArray;1!==e&&n.length>0&&(n=n.map((function(t){return e*t})));t.setAttributeNS(null,"stroke",s.strokeColor);t.setAttributeNS(null,"stroke-opacity",s.strokeAlpha);t.setAttributeNS(null,"stroke-miterlimit",pf(s.miterLimit));t.setAttributeNS(null,"stroke-linecap",s.lineCap);t.setAttributeNS(null,"stroke-linejoin",s.lineJoin);t.setAttributeNS(null,"stroke-width",pf(e*s.lineWidth)+"px");t.setAttributeNS(null,"stroke-dasharray",n.map(pf).join(" "));t.setAttributeNS(null,"stroke-dashoffset",pf(e*s.dashPhase)+"px")}eoFill(){this.current.element?.setAttributeNS(null,"fill-rule","evenodd");this.fill()}fillStroke(){this.stroke();this.fill()}eoFillStroke(){this.current.element?.setAttributeNS(null,"fill-rule","evenodd");this.fillStroke()}closeStroke(){this.closePath();this.stroke()}closeFillStroke(){this.closePath();this.fillStroke()}closeEOFillStroke(){this.closePath();this.eoFillStroke()}paintSolidColorImageMask(){const t=this.svgFactory.createElement("svg:rect");t.setAttributeNS(null,"x","0");t.setAttributeNS(null,"y","0");t.setAttributeNS(null,"width","1px");t.setAttributeNS(null,"height","1px");t.setAttributeNS(null,"fill",this.current.fillColor);this._ensureTransformGroup().append(t)}paintImageXObject(t){const e=this.getObject(t);e?this.paintInlineImageXObject(e):(0,i.warn)(`Dependent image with object ID ${t} is not ready yet`)}paintInlineImageXObject(t,e){const s=t.width,n=t.height,i=u(t,this.forceDataSchema,!!e),a=this.svgFactory.createElement("svg:rect");a.setAttributeNS(null,"x","0");a.setAttributeNS(null,"y","0");a.setAttributeNS(null,"width",pf(s));a.setAttributeNS(null,"height",pf(n));this.current.element=a;this.clip("nonzero");const r=this.svgFactory.createElement("svg:image");r.setAttributeNS(c,"xlink:href",i);r.setAttributeNS(null,"x","0");r.setAttributeNS(null,"y",pf(-n));r.setAttributeNS(null,"width",pf(s)+"px");r.setAttributeNS(null,"height",pf(n)+"px");r.setAttributeNS(null,"transform",`scale(${pf(1/s)} ${pf(-1/n)})`);e?e.append(r):this._ensureTransformGroup().append(r)}paintImageMaskXObject(t){const e=this.getObject(t.data,t);if(e.bitmap){(0,i.warn)("paintImageMaskXObject: ImageBitmap support is not implemented, ensure that the `isOffscreenCanvasSupported` API parameter is disabled.");return}const s=this.current,n=e.width,a=e.height,r=s.fillColor;s.maskId="mask"+g++;const o=this.svgFactory.createElement("svg:mask");o.setAttributeNS(null,"id",s.maskId);const l=this.svgFactory.createElement("svg:rect");l.setAttributeNS(null,"x","0");l.setAttributeNS(null,"y","0");l.setAttributeNS(null,"width",pf(n));l.setAttributeNS(null,"height",pf(a));l.setAttributeNS(null,"fill",r);l.setAttributeNS(null,"mask",`url(#${s.maskId})`);this.defs.append(o);this._ensureTransformGroup().append(l);this.paintInlineImageXObject(e,o)}paintFormXObjectBegin(t,e){Array.isArray(t)&&6===t.length&&this.transform(t[0],t[1],t[2],t[3],t[4],t[5]);if(e){const t=e[2]-e[0],s=e[3]-e[1],n=this.svgFactory.createElement("svg:rect");n.setAttributeNS(null,"x",e[0]);n.setAttributeNS(null,"y",e[1]);n.setAttributeNS(null,"width",pf(t));n.setAttributeNS(null,"height",pf(s));this.current.element=n;this.clip("nonzero");this.endPath()}}paintFormXObjectEnd(){}_initialize(t){const e=this.svgFactory.create(t.width,t.height),s=this.svgFactory.createElement("svg:defs");e.append(s);this.defs=s;const n=this.svgFactory.createElement("svg:g");n.setAttributeNS(null,"transform",pm(t.transform));e.append(n);this.svg=n;return e}_ensureClipGroup(){if(!this.current.clipGroup){const t=this.svgFactory.createElement("svg:g");t.setAttributeNS(null,"clip-path",this.current.activeClipUrl);this.svg.append(t);this.current.clipGroup=t}return this.current.clipGroup}_ensureTransformGroup(){if(!this.tgrp){this.tgrp=this.svgFactory.createElement("svg:g");this.tgrp.setAttributeNS(null,"transform",pm(this.transformMatrix));this.current.activeClipUrl?this._ensureClipGroup().append(this.tgrp):this.svg.append(this.tgrp)}return this.tgrp}}}},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.PDFNodeStream=void 0;var n=s(1),i=s(32);const a=require("fs"),r=require("http"),o=require("https"),l=require("url"),c=/^file:\/\/\/[a-zA-Z]:\//;e.PDFNodeStream=class PDFNodeStream{constructor(t){this.source=t;this.url=function parseUrl(t){const e=l.parse(t);if("file:"===e.protocol||e.host)return e;if(/^[a-z]:[/\\]/i.test(t))return l.parse(`file:///${t}`);e.host||(e.protocol="file:");return e}(t.url);this.isHttp="http:"===this.url.protocol||"https:"===this.url.protocol;this.isFsUrl="file:"===this.url.protocol;this.httpHeaders=this.isHttp&&t.httpHeaders||{};this._fullRequestReader=null;this._rangeRequestReaders=[]}get _progressiveDataLength(){return this._fullRequestReader?._loaded??0}getFullReader(){(0,n.assert)(!this._fullRequestReader,"PDFNodeStream.getFullReader can only be called once.");this._fullRequestReader=this.isFsUrl?new PDFNodeStreamFsFullReader(this):new PDFNodeStreamFullReader(this);return this._fullRequestReader}getRangeReader(t,e){if(e<=this._progressiveDataLength)return null;const s=this.isFsUrl?new PDFNodeStreamFsRangeReader(this,t,e):new PDFNodeStreamRangeReader(this,t,e);this._rangeRequestReaders.push(s);return s}cancelAllRequests(t){this._fullRequestReader?.cancel(t);for(const e of this._rangeRequestReaders.slice(0))e.cancel(t)}};class BaseFullReader{constructor(t){this._url=t.url;this._done=!1;this._storedError=null;this.onProgress=null;const e=t.source;this._contentLength=e.length;this._loaded=0;this._filename=null;this._disableRange=e.disableRange||!1;this._rangeChunkSize=e.rangeChunkSize;this._rangeChunkSize||this._disableRange||(this._disableRange=!0);this._isStreamingSupported=!e.disableStream;this._isRangeSupported=!e.disableRange;this._readableStream=null;this._readCapability=(0,n.createPromiseCapability)();this._headersCapability=(0,n.createPromiseCapability)()}get headersReady(){return this._headersCapability.promise}get filename(){return this._filename}get contentLength(){return this._contentLength}get isRangeSupported(){return this._isRangeSupported}get isStreamingSupported(){return this._isStreamingSupported}async read(){await this._readCapability.promise;if(this._done)return{value:void 0,done:!0};if(this._storedError)throw this._storedError;const t=this._readableStream.read();if(null===t){this._readCapability=(0,n.createPromiseCapability)();return this.read()}this._loaded+=t.length;this.onProgress?.({loaded:this._loaded,total:this._contentLength});return{value:new Uint8Array(t).buffer,done:!1}}cancel(t){this._readableStream?this._readableStream.destroy(t):this._error(t)}_error(t){this._storedError=t;this._readCapability.resolve()}_setReadableStream(t){this._readableStream=t;t.on("readable",(()=>{this._readCapability.resolve()}));t.on("end",(()=>{t.destroy();this._done=!0;this._readCapability.resolve()}));t.on("error",(t=>{this._error(t)}));!this._isStreamingSupported&&this._isRangeSupported&&this._error(new n.AbortException("streaming is disabled"));this._storedError&&this._readableStream.destroy(this._storedError)}}class BaseRangeReader{constructor(t){this._url=t.url;this._done=!1;this._storedError=null;this.onProgress=null;this._loaded=0;this._readableStream=null;this._readCapability=(0,n.createPromiseCapability)();const e=t.source;this._isStreamingSupported=!e.disableStream}get isStreamingSupported(){return this._isStreamingSupported}async read(){await this._readCapability.promise;if(this._done)return{value:void 0,done:!0};if(this._storedError)throw this._storedError;const t=this._readableStream.read();if(null===t){this._readCapability=(0,n.createPromiseCapability)();return this.read()}this._loaded+=t.length;this.onProgress?.({loaded:this._loaded});return{value:new Uint8Array(t).buffer,done:!1}}cancel(t){this._readableStream?this._readableStream.destroy(t):this._error(t)}_error(t){this._storedError=t;this._readCapability.resolve()}_setReadableStream(t){this._readableStream=t;t.on("readable",(()=>{this._readCapability.resolve()}));t.on("end",(()=>{t.destroy();this._done=!0;this._readCapability.resolve()}));t.on("error",(t=>{this._error(t)}));this._storedError&&this._readableStream.destroy(this._storedError)}}function createRequestOptions(t,e){return{protocol:t.protocol,auth:t.auth,host:t.hostname,port:t.port,path:t.path,method:"GET",headers:e}}class PDFNodeStreamFullReader extends BaseFullReader{constructor(t){super(t);const handleResponse=e=>{if(404===e.statusCode){const t=new n.MissingPDFException(`Missing PDF "${this._url}".`);this._storedError=t;this._headersCapability.reject(t);return}this._headersCapability.resolve();this._setReadableStream(e);const getResponseHeader=t=>this._readableStream.headers[t.toLowerCase()],{allowRangeRequests:s,suggestedLength:a}=(0,i.validateRangeRequestCapabilities)({getResponseHeader:getResponseHeader,isHttp:t.isHttp,rangeChunkSize:this._rangeChunkSize,disableRange:this._disableRange});this._isRangeSupported=s;this._contentLength=a||this._contentLength;this._filename=(0,i.extractFilenameFromHeader)(getResponseHeader)};this._request=null;"http:"===this._url.protocol?this._request=r.request(createRequestOptions(this._url,t.httpHeaders),handleResponse):this._request=o.request(createRequestOptions(this._url,t.httpHeaders),handleResponse);this._request.on("error",(t=>{this._storedError=t;this._headersCapability.reject(t)}));this._request.end()}}class PDFNodeStreamRangeReader extends BaseRangeReader{constructor(t,e,s){super(t);this._httpHeaders={};for(const e in t.httpHeaders){const s=t.httpHeaders[e];void 0!==s&&(this._httpHeaders[e]=s)}this._httpHeaders.Range=`bytes=${e}-${s-1}`;const handleResponse=t=>{if(404!==t.statusCode)this._setReadableStream(t);else{const t=new n.MissingPDFException(`Missing PDF "${this._url}".`);this._storedError=t}};this._request=null;"http:"===this._url.protocol?this._request=r.request(createRequestOptions(this._url,this._httpHeaders),handleResponse):this._request=o.request(createRequestOptions(this._url,this._httpHeaders),handleResponse);this._request.on("error",(t=>{this._storedError=t}));this._request.end()}}class PDFNodeStreamFsFullReader extends BaseFullReader{constructor(t){super(t);let e=decodeURIComponent(this._url.path);c.test(this._url.href)&&(e=e.replace(/^\//,""));a.lstat(e,((t,s)=>{if(t){"ENOENT"===t.code&&(t=new n.MissingPDFException(`Missing PDF "${e}".`));this._storedError=t;this._headersCapability.reject(t)}else{this._contentLength=s.size;this._setReadableStream(a.createReadStream(e));this._headersCapability.resolve()}}))}}class PDFNodeStreamFsRangeReader extends BaseRangeReader{constructor(t,e,s){super(t);let n=decodeURIComponent(this._url.path);c.test(this._url.href)&&(n=n.replace(/^\//,""));this._setReadableStream(a.createReadStream(n,{start:e,end:s-1}))}}},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.createResponseStatusError=function createResponseStatusError(t,e){if(404===t||0===t&&e.startsWith("file:"))return new n.MissingPDFException('Missing PDF "'+e+'".');return new n.UnexpectedResponseException(`Unexpected server response (${t}) while retrieving PDF "${e}".`,t)};e.extractFilenameFromHeader=function extractFilenameFromHeader(t){const e=t("Content-Disposition");if(e){let t=(0,i.getFilenameFromContentDispositionHeader)(e);if(t.includes("%"))try{t=decodeURIComponent(t)}catch(t){}if((0,a.isPdfFile)(t))return t}return null};e.validateRangeRequestCapabilities=function validateRangeRequestCapabilities({getResponseHeader:t,isHttp:e,rangeChunkSize:s,disableRange:n}){const i={allowRangeRequests:!1,suggestedLength:void 0},a=parseInt(t("Content-Length"),10);if(!Number.isInteger(a))return i;i.suggestedLength=a;if(a<=2*s)return i;if(n||!e)return i;if("bytes"!==t("Accept-Ranges"))return i;if("identity"!==(t("Content-Encoding")||"identity"))return i;i.allowRangeRequests=!0;return i};e.validateResponseStatus=function validateResponseStatus(t){return 200===t||206===t};var n=s(1),i=s(33),a=s(6)},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.getFilenameFromContentDispositionHeader=function getFilenameFromContentDispositionHeader(t){let e=!0,s=toParamRegExp("filename\\*","i").exec(t);if(s){s=s[1];let t=rfc2616unquote(s);t=unescape(t);t=rfc5987decode(t);t=rfc2047decode(t);return fixupEncoding(t)}s=function rfc2231getparam(t){const e=[];let s;const n=toParamRegExp("filename\\*((?!0\\d)\\d+)(\\*?)","ig");for(;null!==(s=n.exec(t));){let[,t,n,i]=s;t=parseInt(t,10);if(t in e){if(0===t)break}else e[t]=[n,i]}const i=[];for(let t=0;t{Object.defineProperty(e,"__esModule",{value:!0});e.PDFNetworkStream=void 0;var n=s(1),i=s(32);class NetworkManager{constructor(t,e={}){this.url=t;this.isHttp=/^https?:/i.test(t);this.httpHeaders=this.isHttp&&e.httpHeaders||Object.create(null);this.withCredentials=e.withCredentials||!1;this.getXhr=e.getXhr||function NetworkManager_getXhr(){return new XMLHttpRequest};this.currXhrId=0;this.pendingRequests=Object.create(null)}requestRange(t,e,s){const n={begin:t,end:e};for(const t in s)n[t]=s[t];return this.request(n)}requestFull(t){return this.request(t)}request(t){const e=this.getXhr(),s=this.currXhrId++,n=this.pendingRequests[s]={xhr:e};e.open("GET",this.url);e.withCredentials=this.withCredentials;for(const t in this.httpHeaders){const s=this.httpHeaders[t];void 0!==s&&e.setRequestHeader(t,s)}if(this.isHttp&&"begin"in t&&"end"in t){e.setRequestHeader("Range",`bytes=${t.begin}-${t.end-1}`);n.expectedStatus=206}else n.expectedStatus=200;e.responseType="arraybuffer";t.onError&&(e.onerror=function(s){t.onError(e.status)});e.onreadystatechange=this.onStateChange.bind(this,s);e.onprogress=this.onProgress.bind(this,s);n.onHeadersReceived=t.onHeadersReceived;n.onDone=t.onDone;n.onError=t.onError;n.onProgress=t.onProgress;e.send(null);return s}onProgress(t,e){const s=this.pendingRequests[t];s&&s.onProgress?.(e)}onStateChange(t,e){const s=this.pendingRequests[t];if(!s)return;const i=s.xhr;if(i.readyState>=2&&s.onHeadersReceived){s.onHeadersReceived();delete s.onHeadersReceived}if(4!==i.readyState)return;if(!(t in this.pendingRequests))return;delete this.pendingRequests[t];if(0===i.status&&this.isHttp){s.onError?.(i.status);return}const a=i.status||200;if(!(200===a&&206===s.expectedStatus)&&a!==s.expectedStatus){s.onError?.(i.status);return}const r=function getArrayBuffer(t){const e=t.response;return"string"!=typeof e?e:(0,n.stringToBytes)(e).buffer}(i);if(206===a){const t=i.getResponseHeader("Content-Range"),e=/bytes (\d+)-(\d+)\/(\d+)/.exec(t);s.onDone({begin:parseInt(e[1],10),chunk:r})}else r?s.onDone({begin:0,chunk:r}):s.onError?.(i.status)}getRequestXhr(t){return this.pendingRequests[t].xhr}isPendingRequest(t){return t in this.pendingRequests}abortRequest(t){const e=this.pendingRequests[t].xhr;delete this.pendingRequests[t];e.abort()}}e.PDFNetworkStream=class PDFNetworkStream{constructor(t){this._source=t;this._manager=new NetworkManager(t.url,{httpHeaders:t.httpHeaders,withCredentials:t.withCredentials});this._rangeChunkSize=t.rangeChunkSize;this._fullRequestReader=null;this._rangeRequestReaders=[]}_onRangeRequestReaderClosed(t){const e=this._rangeRequestReaders.indexOf(t);e>=0&&this._rangeRequestReaders.splice(e,1)}getFullReader(){(0,n.assert)(!this._fullRequestReader,"PDFNetworkStream.getFullReader can only be called once.");this._fullRequestReader=new PDFNetworkStreamFullRequestReader(this._manager,this._source);return this._fullRequestReader}getRangeReader(t,e){const s=new PDFNetworkStreamRangeRequestReader(this._manager,t,e);s.onClosed=this._onRangeRequestReaderClosed.bind(this);this._rangeRequestReaders.push(s);return s}cancelAllRequests(t){this._fullRequestReader?.cancel(t);for(const e of this._rangeRequestReaders.slice(0))e.cancel(t)}};class PDFNetworkStreamFullRequestReader{constructor(t,e){this._manager=t;const s={onHeadersReceived:this._onHeadersReceived.bind(this),onDone:this._onDone.bind(this),onError:this._onError.bind(this),onProgress:this._onProgress.bind(this)};this._url=e.url;this._fullRequestId=t.requestFull(s);this._headersReceivedCapability=(0,n.createPromiseCapability)();this._disableRange=e.disableRange||!1;this._contentLength=e.length;this._rangeChunkSize=e.rangeChunkSize;this._rangeChunkSize||this._disableRange||(this._disableRange=!0);this._isStreamingSupported=!1;this._isRangeSupported=!1;this._cachedChunks=[];this._requests=[];this._done=!1;this._storedError=void 0;this._filename=null;this.onProgress=null}_onHeadersReceived(){const t=this._fullRequestId,e=this._manager.getRequestXhr(t),getResponseHeader=t=>e.getResponseHeader(t),{allowRangeRequests:s,suggestedLength:n}=(0,i.validateRangeRequestCapabilities)({getResponseHeader:getResponseHeader,isHttp:this._manager.isHttp,rangeChunkSize:this._rangeChunkSize,disableRange:this._disableRange});s&&(this._isRangeSupported=!0);this._contentLength=n||this._contentLength;this._filename=(0,i.extractFilenameFromHeader)(getResponseHeader);this._isRangeSupported&&this._manager.abortRequest(t);this._headersReceivedCapability.resolve()}_onDone(t){if(t)if(this._requests.length>0){this._requests.shift().resolve({value:t.chunk,done:!1})}else this._cachedChunks.push(t.chunk);this._done=!0;if(!(this._cachedChunks.length>0)){for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0}}_onError(t){this._storedError=(0,i.createResponseStatusError)(t,this._url);this._headersReceivedCapability.reject(this._storedError);for(const t of this._requests)t.reject(this._storedError);this._requests.length=0;this._cachedChunks.length=0}_onProgress(t){this.onProgress?.({loaded:t.loaded,total:t.lengthComputable?t.total:this._contentLength})}get filename(){return this._filename}get isRangeSupported(){return this._isRangeSupported}get isStreamingSupported(){return this._isStreamingSupported}get contentLength(){return this._contentLength}get headersReady(){return this._headersReceivedCapability.promise}async read(){if(this._storedError)throw this._storedError;if(this._cachedChunks.length>0){return{value:this._cachedChunks.shift(),done:!1}}if(this._done)return{value:void 0,done:!0};const t=(0,n.createPromiseCapability)();this._requests.push(t);return t.promise}cancel(t){this._done=!0;this._headersReceivedCapability.reject(t);for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0;this._manager.isPendingRequest(this._fullRequestId)&&this._manager.abortRequest(this._fullRequestId);this._fullRequestReader=null}}class PDFNetworkStreamRangeRequestReader{constructor(t,e,s){this._manager=t;const n={onDone:this._onDone.bind(this),onError:this._onError.bind(this),onProgress:this._onProgress.bind(this)};this._url=t.url;this._requestId=t.requestRange(e,s,n);this._requests=[];this._queuedChunk=null;this._done=!1;this._storedError=void 0;this.onProgress=null;this.onClosed=null}_close(){this.onClosed?.(this)}_onDone(t){const e=t.chunk;if(this._requests.length>0){this._requests.shift().resolve({value:e,done:!1})}else this._queuedChunk=e;this._done=!0;for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0;this._close()}_onError(t){this._storedError=(0,i.createResponseStatusError)(t,this._url);for(const t of this._requests)t.reject(this._storedError);this._requests.length=0;this._queuedChunk=null}_onProgress(t){this.isStreamingSupported||this.onProgress?.({loaded:t.loaded})}get isStreamingSupported(){return!1}async read(){if(this._storedError)throw this._storedError;if(null!==this._queuedChunk){const t=this._queuedChunk;this._queuedChunk=null;return{value:t,done:!1}}if(this._done)return{value:void 0,done:!0};const t=(0,n.createPromiseCapability)();this._requests.push(t);return t.promise}cancel(t){this._done=!0;for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0;this._manager.isPendingRequest(this._requestId)&&this._manager.abortRequest(this._requestId);this._close()}}},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.PDFFetchStream=void 0;var n=s(1),i=s(32);function createFetchOptions(t,e,s){return{method:"GET",headers:t,signal:s.signal,mode:"cors",credentials:e?"include":"same-origin",redirect:"follow"}}function createHeaders(t){const e=new Headers;for(const s in t){const n=t[s];void 0!==n&&e.append(s,n)}return e}e.PDFFetchStream=class PDFFetchStream{constructor(t){this.source=t;this.isHttp=/^https?:/i.test(t.url);this.httpHeaders=this.isHttp&&t.httpHeaders||{};this._fullRequestReader=null;this._rangeRequestReaders=[]}get _progressiveDataLength(){return this._fullRequestReader?._loaded??0}getFullReader(){(0,n.assert)(!this._fullRequestReader,"PDFFetchStream.getFullReader can only be called once.");this._fullRequestReader=new PDFFetchStreamReader(this);return this._fullRequestReader}getRangeReader(t,e){if(e<=this._progressiveDataLength)return null;const s=new PDFFetchStreamRangeReader(this,t,e);this._rangeRequestReaders.push(s);return s}cancelAllRequests(t){this._fullRequestReader?.cancel(t);for(const e of this._rangeRequestReaders.slice(0))e.cancel(t)}};class PDFFetchStreamReader{constructor(t){this._stream=t;this._reader=null;this._loaded=0;this._filename=null;const e=t.source;this._withCredentials=e.withCredentials||!1;this._contentLength=e.length;this._headersCapability=(0,n.createPromiseCapability)();this._disableRange=e.disableRange||!1;this._rangeChunkSize=e.rangeChunkSize;this._rangeChunkSize||this._disableRange||(this._disableRange=!0);this._abortController=new AbortController;this._isStreamingSupported=!e.disableStream;this._isRangeSupported=!e.disableRange;this._headers=createHeaders(this._stream.httpHeaders);const s=e.url;fetch(s,createFetchOptions(this._headers,this._withCredentials,this._abortController)).then((t=>{if(!(0,i.validateResponseStatus)(t.status))throw(0,i.createResponseStatusError)(t.status,s);this._reader=t.body.getReader();this._headersCapability.resolve();const getResponseHeader=e=>t.headers.get(e),{allowRangeRequests:e,suggestedLength:a}=(0,i.validateRangeRequestCapabilities)({getResponseHeader:getResponseHeader,isHttp:this._stream.isHttp,rangeChunkSize:this._rangeChunkSize,disableRange:this._disableRange});this._isRangeSupported=e;this._contentLength=a||this._contentLength;this._filename=(0,i.extractFilenameFromHeader)(getResponseHeader);!this._isStreamingSupported&&this._isRangeSupported&&this.cancel(new n.AbortException("Streaming is disabled."))})).catch(this._headersCapability.reject);this.onProgress=null}get headersReady(){return this._headersCapability.promise}get filename(){return this._filename}get contentLength(){return this._contentLength}get isRangeSupported(){return this._isRangeSupported}get isStreamingSupported(){return this._isStreamingSupported}async read(){await this._headersCapability.promise;const{value:t,done:e}=await this._reader.read();if(e)return{value:t,done:e};this._loaded+=t.byteLength;this.onProgress?.({loaded:this._loaded,total:this._contentLength});return{value:new Uint8Array(t).buffer,done:!1}}cancel(t){this._reader?.cancel(t);this._abortController.abort()}}class PDFFetchStreamRangeReader{constructor(t,e,s){this._stream=t;this._reader=null;this._loaded=0;const a=t.source;this._withCredentials=a.withCredentials||!1;this._readCapability=(0,n.createPromiseCapability)();this._isStreamingSupported=!a.disableStream;this._abortController=new AbortController;this._headers=createHeaders(this._stream.httpHeaders);this._headers.append("Range",`bytes=${e}-${s-1}`);const r=a.url;fetch(r,createFetchOptions(this._headers,this._withCredentials,this._abortController)).then((t=>{if(!(0,i.validateResponseStatus)(t.status))throw(0,i.createResponseStatusError)(t.status,r);this._readCapability.resolve();this._reader=t.body.getReader()})).catch(this._readCapability.reject);this.onProgress=null}get isStreamingSupported(){return this._isStreamingSupported}async read(){await this._readCapability.promise;const{value:t,done:e}=await this._reader.read();if(e)return{value:t,done:e};this._loaded+=t.byteLength;this.onProgress?.({loaded:this._loaded});return{value:new Uint8Array(t).buffer,done:!1}}cancel(t){this._reader?.cancel(t);this._abortController.abort()}}}],__webpack_module_cache__={};function __w_pdfjs_require__(t){var e=__webpack_module_cache__[t];if(void 0!==e)return e.exports;var s=__webpack_module_cache__[t]={exports:{}};__webpack_modules__[t](s,s.exports,__w_pdfjs_require__);return s.exports}var __webpack_exports__={};(()=>{var t=__webpack_exports__;Object.defineProperty(t,"__esModule",{value:!0});Object.defineProperty(t,"AbortException",{enumerable:!0,get:function(){return e.AbortException}});Object.defineProperty(t,"AnnotationEditorLayer",{enumerable:!0,get:function(){return a.AnnotationEditorLayer}});Object.defineProperty(t,"AnnotationEditorParamsType",{enumerable:!0,get:function(){return e.AnnotationEditorParamsType}});Object.defineProperty(t,"AnnotationEditorType",{enumerable:!0,get:function(){return e.AnnotationEditorType}});Object.defineProperty(t,"AnnotationEditorUIManager",{enumerable:!0,get:function(){return r.AnnotationEditorUIManager}});Object.defineProperty(t,"AnnotationLayer",{enumerable:!0,get:function(){return o.AnnotationLayer}});Object.defineProperty(t,"AnnotationMode",{enumerable:!0,get:function(){return e.AnnotationMode}});Object.defineProperty(t,"CMapCompressionType",{enumerable:!0,get:function(){return e.CMapCompressionType}});Object.defineProperty(t,"GlobalWorkerOptions",{enumerable:!0,get:function(){return l.GlobalWorkerOptions}});Object.defineProperty(t,"InvalidPDFException",{enumerable:!0,get:function(){return e.InvalidPDFException}});Object.defineProperty(t,"MissingPDFException",{enumerable:!0,get:function(){return e.MissingPDFException}});Object.defineProperty(t,"OPS",{enumerable:!0,get:function(){return e.OPS}});Object.defineProperty(t,"PDFDataRangeTransport",{enumerable:!0,get:function(){return s.PDFDataRangeTransport}});Object.defineProperty(t,"PDFDateString",{enumerable:!0,get:function(){return n.PDFDateString}});Object.defineProperty(t,"PDFWorker",{enumerable:!0,get:function(){return s.PDFWorker}});Object.defineProperty(t,"PasswordResponses",{enumerable:!0,get:function(){return e.PasswordResponses}});Object.defineProperty(t,"PermissionFlag",{enumerable:!0,get:function(){return e.PermissionFlag}});Object.defineProperty(t,"PixelsPerInch",{enumerable:!0,get:function(){return n.PixelsPerInch}});Object.defineProperty(t,"RenderingCancelledException",{enumerable:!0,get:function(){return n.RenderingCancelledException}});Object.defineProperty(t,"SVGGraphics",{enumerable:!0,get:function(){return h.SVGGraphics}});Object.defineProperty(t,"UNSUPPORTED_FEATURES",{enumerable:!0,get:function(){return e.UNSUPPORTED_FEATURES}});Object.defineProperty(t,"UnexpectedResponseException",{enumerable:!0,get:function(){return e.UnexpectedResponseException}});Object.defineProperty(t,"Util",{enumerable:!0,get:function(){return e.Util}});Object.defineProperty(t,"VerbosityLevel",{enumerable:!0,get:function(){return e.VerbosityLevel}});Object.defineProperty(t,"XfaLayer",{enumerable:!0,get:function(){return d.XfaLayer}});Object.defineProperty(t,"build",{enumerable:!0,get:function(){return s.build}});Object.defineProperty(t,"createPromiseCapability",{enumerable:!0,get:function(){return e.createPromiseCapability}});Object.defineProperty(t,"createValidAbsoluteUrl",{enumerable:!0,get:function(){return e.createValidAbsoluteUrl}});Object.defineProperty(t,"getDocument",{enumerable:!0,get:function(){return s.getDocument}});Object.defineProperty(t,"getFilenameFromUrl",{enumerable:!0,get:function(){return n.getFilenameFromUrl}});Object.defineProperty(t,"getPdfFilenameFromUrl",{enumerable:!0,get:function(){return n.getPdfFilenameFromUrl}});Object.defineProperty(t,"getXfaPageViewport",{enumerable:!0,get:function(){return n.getXfaPageViewport}});Object.defineProperty(t,"isDataScheme",{enumerable:!0,get:function(){return n.isDataScheme}});Object.defineProperty(t,"isPdfFile",{enumerable:!0,get:function(){return n.isPdfFile}});Object.defineProperty(t,"loadScript",{enumerable:!0,get:function(){return n.loadScript}});Object.defineProperty(t,"renderTextLayer",{enumerable:!0,get:function(){return i.renderTextLayer}});Object.defineProperty(t,"setLayerDimensions",{enumerable:!0,get:function(){return n.setLayerDimensions}});Object.defineProperty(t,"shadow",{enumerable:!0,get:function(){return e.shadow}});Object.defineProperty(t,"updateTextLayer",{enumerable:!0,get:function(){return i.updateTextLayer}});Object.defineProperty(t,"version",{enumerable:!0,get:function(){return s.version}});var e=__w_pdfjs_require__(1),s=__w_pdfjs_require__(2),n=__w_pdfjs_require__(6),i=__w_pdfjs_require__(21),a=__w_pdfjs_require__(22),r=__w_pdfjs_require__(5),o=__w_pdfjs_require__(27),l=__w_pdfjs_require__(14),c=__w_pdfjs_require__(10),h=__w_pdfjs_require__(30),d=__w_pdfjs_require__(29);if(c.isNodeJS){const{PDFNodeStream:t}=__w_pdfjs_require__(31);(0,s.setPDFNetworkStreamFactory)((e=>new t(e)))}else{const{PDFNetworkStream:t}=__w_pdfjs_require__(34),{PDFFetchStream:e}=__w_pdfjs_require__(35);(0,s.setPDFNetworkStreamFactory)((s=>(0,n.isValidFetchUrl)(s.url)?new e(s):new t(s)))}})();return __webpack_exports__})())); \ No newline at end of file diff --git a/assets/sounds/badeep.wav b/assets/sounds/badeep.wav new file mode 100644 index 0000000..475c210 Binary files /dev/null and b/assets/sounds/badeep.wav differ diff --git a/assets/sounds/beepbadeep.wav b/assets/sounds/beepbadeep.wav new file mode 100644 index 0000000..8577b12 Binary files /dev/null and b/assets/sounds/beepbadeep.wav differ diff --git a/assets/sounds/bonk.wav b/assets/sounds/bonk.wav new file mode 100644 index 0000000..3e9236c Binary files /dev/null and b/assets/sounds/bonk.wav differ diff --git a/assets/sounds/boop.wav b/assets/sounds/boop.wav new file mode 100644 index 0000000..df284bc Binary files /dev/null and b/assets/sounds/boop.wav differ diff --git a/build.yaml b/build.yaml index 950fe95..77b9050 100644 --- a/build.yaml +++ b/build.yaml @@ -1,6 +1,9 @@ targets: $default: builders: + freezed: + options: + generic_argument_factories: true json_serializable: options: explicit_to_json: true diff --git a/dev-setup/flutter_config.sh b/dev-setup/flutter_config.sh index 1168d74..a1b8c8d 100755 --- a/dev-setup/flutter_config.sh +++ b/dev-setup/flutter_config.sh @@ -14,13 +14,13 @@ sed -i '' 's/MACOSX_DEPLOYMENT_TARGET = [^;]*/MACOSX_DEPLOYMENT_TARGET = 10.14.6 sed -i '' "s/platform :osx, '[^']*'/platform :osx, '10.14.6'/g" $APPDIR/macos/Podfile # Android: Set NDK version -if [[ "$TMPDIR" != "" ]]; then +if [[ "$TMPDIR" != "" ]]; then ANDTMP=$TMPDIR/andtmp_$(date +%s) -else +else ANDTMP=/tmp/andtmp_$(date +%s) fi cat < $ANDTMP - ndkVersion '26.3.11579264' + ndkVersion '27.0.12077973' EOF sed -i '' -e "/android {/r $ANDTMP" $APPDIR/android/app/build.gradle rm -- $ANDTMP @@ -29,7 +29,7 @@ rm -- $ANDTMP sed -i '' 's/minSdkVersion .*/minSdkVersion Math.max(flutter.minSdkVersion, 24)/g' $APPDIR/android/app/build.gradle # Android: Set gradle plugin version -sed -i '' "s/classpath \'com.android.tools.build:gradle:[^\']*\'/classpath 'com.android.tools.build:gradle:7.2.0'/g" $APPDIR/android/build.gradle +sed -i '' "s/classpath \'com.android.tools.build:gradle:[^\']*\'/classpath 'com.android.tools.build:gradle:8.8.0'/g" $APPDIR/android/build.gradle # Android: Set gradle version -sed -i '' 's/distributionUrl=.*/distributionUrl=https:\/\/services.gradle.org\/distributions\/gradle-7.6.3-all.zip/g' $APPDIR/android/gradle/wrapper/gradle-wrapper.properties +sed -i '' 's/distributionUrl=.*/distributionUrl=https:\/\/services.gradle.org\/distributions\/gradle-8.10.2-all.zip/g' $APPDIR/android/gradle/wrapper/gradle-wrapper.properties diff --git a/devtools_options.yaml b/devtools_options.yaml index 5c27c3e..7093540 100644 --- a/devtools_options.yaml +++ b/devtools_options.yaml @@ -1,2 +1,3 @@ extensions: - - provider: true \ No newline at end of file + - provider: true + - shared_preferences: true \ No newline at end of file diff --git a/flatpak/com.veilid.veilidchat.arm64.yml b/flatpak/com.veilid.veilidchat.arm64.yml new file mode 100644 index 0000000..f1c3d59 --- /dev/null +++ b/flatpak/com.veilid.veilidchat.arm64.yml @@ -0,0 +1,35 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/flatpak/flatpak-builder/main/data/flatpak-manifest.schema.json + +--- +app-id: com.veilid.veilidchat +runtime: org.gnome.Platform +runtime-version: "46" +sdk: org.gnome.Sdk +command: veilidchat +separate-locales: false +finish-args: + - --share=ipc + - --socket=fallback-x11 + - --socket=wayland + - --device=dri + - --socket=pulseaudio + - --share=network + - --talk-name=org.freedesktop.secrets +modules: + - name: VeilidChat + buildsystem: simple + only-arches: + - aarch64 + build-commands: + - "./build-flatpak.sh" + sources: + - type: dir + path: ../build/linux/arm64/release/ + - type: file + path: build-flatpak.sh + - type: file + path: com.veilid.veilidchat.png + - type: file + path: com.veilid.veilidchat.desktop + - type: file + path: com.veilid.veilidchat.metainfo.xml diff --git a/flatpak/com.veilid.veilidchat.yml b/flatpak/com.veilid.veilidchat.yml index af45e4b..3db5a02 100644 --- a/flatpak/com.veilid.veilidchat.yml +++ b/flatpak/com.veilid.veilidchat.yml @@ -3,7 +3,7 @@ --- app-id: com.veilid.veilidchat runtime: org.gnome.Platform -runtime-version: "45" +runtime-version: "46" sdk: org.gnome.Sdk command: veilidchat separate-locales: false diff --git a/flutter_01.png b/flutter_01.png new file mode 100644 index 0000000..e69de29 diff --git a/flutter_02.png b/flutter_02.png new file mode 100644 index 0000000..e69de29 diff --git a/flutter_03.png b/flutter_03.png new file mode 100644 index 0000000..e69de29 diff --git a/ios/Podfile b/ios/Podfile index 2cbcaa2..572283f 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.4' +platform :ios, '15.6' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 1411245..0f5fb0f 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,79 +1,26 @@ PODS: - camera_avfoundation (0.0.1): - Flutter + - file_saver (0.0.1): + - Flutter - Flutter (1.0.0) - - flutter_native_splash (0.0.1): + - flutter_native_splash (2.4.3): - Flutter - - GoogleDataTransport (9.2.5): - - GoogleUtilities/Environment (~> 7.7) - - nanopb (< 2.30910.0, >= 2.30908.0) - - PromisesObjC (< 3.0, >= 1.2) - - GoogleMLKit/BarcodeScanning (4.0.0): - - GoogleMLKit/MLKitCore - - MLKitBarcodeScanning (~> 3.0.0) - - GoogleMLKit/MLKitCore (4.0.0): - - MLKitCommon (~> 9.0.0) - - GoogleToolboxForMac/DebugUtils (2.3.2): - - GoogleToolboxForMac/Defines (= 2.3.2) - - GoogleToolboxForMac/Defines (2.3.2) - - GoogleToolboxForMac/Logger (2.3.2): - - GoogleToolboxForMac/Defines (= 2.3.2) - - "GoogleToolboxForMac/NSData+zlib (2.3.2)": - - GoogleToolboxForMac/Defines (= 2.3.2) - - "GoogleToolboxForMac/NSDictionary+URLArguments (2.3.2)": - - GoogleToolboxForMac/DebugUtils (= 2.3.2) - - GoogleToolboxForMac/Defines (= 2.3.2) - - "GoogleToolboxForMac/NSString+URLArguments (= 2.3.2)" - - "GoogleToolboxForMac/NSString+URLArguments (2.3.2)" - - GoogleUtilities/Environment (7.11.5): - - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/Logger (7.11.5): - - GoogleUtilities/Environment - - GoogleUtilities/UserDefaults (7.11.5): - - GoogleUtilities/Logger - - GoogleUtilitiesComponents (1.1.0): - - GoogleUtilities/Logger - - GTMSessionFetcher/Core (2.3.0) - - MLImage (1.0.0-beta4) - - MLKitBarcodeScanning (3.0.0): - - MLKitCommon (~> 9.0) - - MLKitVision (~> 5.0) - - MLKitCommon (9.0.0): - - GoogleDataTransport (~> 9.0) - - GoogleToolboxForMac/Logger (~> 2.1) - - "GoogleToolboxForMac/NSData+zlib (~> 2.1)" - - "GoogleToolboxForMac/NSDictionary+URLArguments (~> 2.1)" - - GoogleUtilities/UserDefaults (~> 7.0) - - GoogleUtilitiesComponents (~> 1.0) - - GTMSessionFetcher/Core (< 3.0, >= 1.1) - - MLKitVision (5.0.0): - - GoogleToolboxForMac/Logger (~> 2.1) - - "GoogleToolboxForMac/NSData+zlib (~> 2.1)" - - GTMSessionFetcher/Core (< 3.0, >= 1.1) - - MLImage (= 1.0.0-beta4) - - MLKitCommon (~> 9.0) - - mobile_scanner (3.5.6): + - package_info_plus (0.4.5): - Flutter - - GoogleMLKit/BarcodeScanning (~> 4.0.0) - - nanopb (2.30909.0): - - nanopb/decode (= 2.30909.0) - - nanopb/encode (= 2.30909.0) - - nanopb/decode (2.30909.0) - - nanopb/encode (2.30909.0) - pasteboard (0.0.1): - Flutter - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - PromisesObjC (2.3.1) + - printing (1.0.0): + - Flutter - share_plus (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - smart_auth (0.0.1): - - Flutter - - sqflite (0.0.3): + - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS - system_info_plus (0.0.1): @@ -85,55 +32,43 @@ PODS: DEPENDENCIES: - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) + - file_saver (from `.symlinks/plugins/file_saver/ios`) - Flutter (from `Flutter`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - - mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - pasteboard (from `.symlinks/plugins/pasteboard/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - printing (from `.symlinks/plugins/printing/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - smart_auth (from `.symlinks/plugins/smart_auth/ios`) - - sqflite (from `.symlinks/plugins/sqflite/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - system_info_plus (from `.symlinks/plugins/system_info_plus/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - veilid (from `.symlinks/plugins/veilid/ios`) -SPEC REPOS: - trunk: - - GoogleDataTransport - - GoogleMLKit - - GoogleToolboxForMac - - GoogleUtilities - - GoogleUtilitiesComponents - - GTMSessionFetcher - - MLImage - - MLKitBarcodeScanning - - MLKitCommon - - MLKitVision - - nanopb - - PromisesObjC - EXTERNAL SOURCES: camera_avfoundation: :path: ".symlinks/plugins/camera_avfoundation/ios" + file_saver: + :path: ".symlinks/plugins/file_saver/ios" Flutter: :path: Flutter flutter_native_splash: :path: ".symlinks/plugins/flutter_native_splash/ios" - mobile_scanner: - :path: ".symlinks/plugins/mobile_scanner/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" pasteboard: :path: ".symlinks/plugins/pasteboard/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" + printing: + :path: ".symlinks/plugins/printing/ios" share_plus: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - smart_auth: - :path: ".symlinks/plugins/smart_auth/ios" - sqflite: - :path: ".symlinks/plugins/sqflite/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" system_info_plus: :path: ".symlinks/plugins/system_info_plus/ios" url_launcher_ios: @@ -142,32 +77,21 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/veilid/ios" SPEC CHECKSUMS: - camera_avfoundation: 759172d1a77ae7be0de08fc104cfb79738b8a59e + camera_avfoundation: be3be85408cd4126f250386828e9b1dfa40ab436 + file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 - GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2 - GoogleMLKit: 2bd0dc6253c4d4f227aad460f69215a504b2980e - GoogleToolboxForMac: 8bef7c7c5cf7291c687cf5354f39f9db6399ad34 - GoogleUtilities: 13e2c67ede716b8741c7989e26893d151b2b2084 - GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe - GTMSessionFetcher: 3a63d75eecd6aa32c2fc79f578064e1214dfdec2 - MLImage: 7bb7c4264164ade9bf64f679b40fb29c8f33ee9b - MLKitBarcodeScanning: 04e264482c5f3810cb89ebc134ef6b61e67db505 - MLKitCommon: c1b791c3e667091918d91bda4bba69a91011e390 - MLKitVision: 8baa5f46ee3352614169b85250574fde38c36f49 - mobile_scanner: 38dcd8a49d7d485f632b7de65e4900010187aef2 - nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431 - pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0 - path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c - PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4 - share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad - shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 - smart_auth: 4bedbc118723912d0e45a07e8ab34039c19e04f2 - sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec - system_info_plus: 5393c8da281d899950d751713575fbf91c7709aa - url_launcher_ios: 6116280ddcfe98ab8820085d8d76ae7449447586 - veilid: f5c2e662f91907b30cf95762619526ac3e4512fd + flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + printing: 54ff03f28fe9ba3aa93358afb80a8595a071dd07 + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + system_info_plus: 555ce7047fbbf29154726db942ae785c29211740 + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + veilid: 3ce560a4f2b568a77a9fd5e23090f2fa97581019 -PODFILE CHECKSUM: 5d504085cd7c7a4d71ee600d7af087cb60ab75b2 +PODFILE CHECKSUM: c8bf5b16c34712d5790b0b8d2472cc66ac0a8487 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 72c3178..3a96d3e 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -155,6 +155,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { @@ -324,6 +325,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -334,6 +336,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -359,9 +362,11 @@ CLANG_ENABLE_MODULES = YES; DEVELOPMENT_TEAM = XP5LBLT7M7; ENABLE_BITCODE = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = VeilidChat; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -397,6 +402,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -407,6 +413,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -452,6 +459,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -462,6 +470,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -489,9 +498,11 @@ CLANG_ENABLE_MODULES = YES; DEVELOPMENT_TEAM = XP5LBLT7M7; ENABLE_BITCODE = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = VeilidChat; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -513,9 +524,11 @@ CLANG_ENABLE_MODULES = YES; DEVELOPMENT_TEAM = XP5LBLT7M7; ENABLE_BITCODE = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = VeilidChat; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 5e31d3d..a44fb7f 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -26,6 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> @@ -59,6 +62,13 @@ ReferencedContainer = "container:Runner.xcodeproj"> + + + + { + AccountInfoCubit( + {required AccountRepository accountRepository, + required TypedKey superIdentityRecordKey}) + : _accountRepository = accountRepository, + super(accountRepository.getAccountInfo(superIdentityRecordKey)!) { + // Subscribe to streams + _accountRepositorySubscription = _accountRepository.stream.listen((change) { + switch (change) { + case AccountRepositoryChange.activeLocalAccount: + case AccountRepositoryChange.localAccounts: + case AccountRepositoryChange.userLogins: + final acctInfo = + accountRepository.getAccountInfo(superIdentityRecordKey); + if (acctInfo != null) { + emit(acctInfo); + } + } + }); + } + + @override + Future close() async { + await super.close(); + await _accountRepositorySubscription.cancel(); + } + + final AccountRepository _accountRepository; + late final StreamSubscription + _accountRepositorySubscription; +} diff --git a/lib/account_manager/cubits/account_record_cubit.dart b/lib/account_manager/cubits/account_record_cubit.dart index 60ddd88..16ab2e0 100644 --- a/lib/account_manager/cubits/account_record_cubit.dart +++ b/lib/account_manager/cubits/account_record_cubit.dart @@ -1,16 +1,77 @@ import 'dart:async'; +import 'package:async_tools/async_tools.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../proto/proto.dart' as proto; +import '../account_manager.dart'; -class AccountRecordCubit extends DefaultDHTRecordCubit { - AccountRecordCubit({ - required super.open, - }) : super(decodeState: proto.Account.fromBuffer); +typedef AccountRecordState = proto.Account; +typedef _SspUpdateState = ( + AccountSpec accountSpec, + Future Function() onSuccess +); + +/// The saved state of a VeilidChat Account on the DHT +/// Used to synchronize status, profile, and options for a specific account +/// across multiple clients. This DHT record is the 'source of truth' for an +/// account and is privately encrypted with an owned record from the 'userLogin' +/// tabledb-local storage, encrypted by the unlock code for the account. +class AccountRecordCubit extends DefaultDHTRecordCubit { + AccountRecordCubit( + {required LocalAccount localAccount, required UserLogin userLogin}) + : super( + decodeState: proto.Account.fromBuffer, + open: () => _open(localAccount, userLogin)); + + static Future _open( + LocalAccount localAccount, UserLogin userLogin) async { + // Record not yet open, do it + final pool = DHTRecordPool.instance; + final record = await pool.openRecordOwned( + userLogin.accountRecordInfo.accountRecord, + debugName: 'AccountRecordCubit::_open::AccountRecord', + parent: localAccount.superIdentity.currentInstance.recordKey); + + return record; + } @override Future close() async { + await _sspUpdate.close(); await super.close(); } + + //////////////////////////////////////////////////////////////////////////// + // Public Interface + + void updateAccount( + AccountSpec accountSpec, Future Function() onChanged) { + _sspUpdate.updateState((accountSpec, onChanged), (state) async { + await _updateAccountAsync(state.$1, state.$2); + }); + } + + Future _updateAccountAsync( + AccountSpec accountSpec, Future Function() onChanged) async { + var changed = true; + await record?.eventualUpdateProtobuf(proto.Account.fromBuffer, (old) async { + if (old == null) { + return null; + } + + final oldAccountSpec = AccountSpec.fromProto(old); + changed = oldAccountSpec != accountSpec; + if (!changed) { + return null; + } + + return accountSpec.updateProto(old); + }); + if (changed) { + await onChanged(); + } + } + + final _sspUpdate = SingleStateProcessor<_SspUpdateState>(); } diff --git a/lib/account_manager/cubits/active_local_account_cubit.dart b/lib/account_manager/cubits/active_local_account_cubit.dart index 843cc59..8856848 100644 --- a/lib/account_manager/cubits/active_local_account_cubit.dart +++ b/lib/account_manager/cubits/active_local_account_cubit.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:veilid_support/veilid_support.dart'; -import '../repository/account_repository/account_repository.dart'; +import '../repository/account_repository.dart'; class ActiveLocalAccountCubit extends Cubit { ActiveLocalAccountCubit(AccountRepository accountRepository) @@ -14,7 +14,6 @@ class ActiveLocalAccountCubit extends Cubit { switch (change) { case AccountRepositoryChange.activeLocalAccount: emit(_accountRepository.getActiveLocalAccount()); - break; // Ignore these case AccountRepositoryChange.localAccounts: case AccountRepositoryChange.userLogins: diff --git a/lib/account_manager/cubits/cubits.dart b/lib/account_manager/cubits/cubits.dart index 6d2875d..da268ae 100644 --- a/lib/account_manager/cubits/cubits.dart +++ b/lib/account_manager/cubits/cubits.dart @@ -1,4 +1,7 @@ +export 'account_info_cubit.dart'; export 'account_record_cubit.dart'; export 'active_local_account_cubit.dart'; export 'local_accounts_cubit.dart'; +export 'per_account_collection_bloc_map_cubit.dart'; +export 'per_account_collection_cubit.dart'; export 'user_logins_cubit.dart'; diff --git a/lib/account_manager/cubits/local_accounts_cubit.dart b/lib/account_manager/cubits/local_accounts_cubit.dart index 484bdbc..3781297 100644 --- a/lib/account_manager/cubits/local_accounts_cubit.dart +++ b/lib/account_manager/cubits/local_accounts_cubit.dart @@ -1,12 +1,17 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:veilid_support/veilid_support.dart'; import '../models/models.dart'; -import '../repository/account_repository/account_repository.dart'; +import '../repository/account_repository.dart'; -class LocalAccountsCubit extends Cubit> { +typedef LocalAccountsState = IList; + +class LocalAccountsCubit extends Cubit + with StateMapFollowable { LocalAccountsCubit(AccountRepository accountRepository) : _accountRepository = accountRepository, super(accountRepository.getLocalAccounts()) { @@ -15,7 +20,6 @@ class LocalAccountsCubit extends Cubit> { switch (change) { case AccountRepositoryChange.localAccounts: emit(_accountRepository.getLocalAccounts()); - break; // Ignore these case AccountRepositoryChange.userLogins: case AccountRepositoryChange.activeLocalAccount: @@ -30,6 +34,14 @@ class LocalAccountsCubit extends Cubit> { await _accountRepositorySubscription.cancel(); } + /// StateMapFollowable ///////////////////////// + @override + IMap getStateMap(LocalAccountsState state) { + final stateValue = state; + return IMap.fromIterable(stateValue, + keyMapper: (e) => e.superIdentity.recordKey, valueMapper: (e) => e); + } + final AccountRepository _accountRepository; late final StreamSubscription _accountRepositorySubscription; diff --git a/lib/account_manager/cubits/per_account_collection_bloc_map_cubit.dart b/lib/account_manager/cubits/per_account_collection_bloc_map_cubit.dart new file mode 100644 index 0000000..e63b53e --- /dev/null +++ b/lib/account_manager/cubits/per_account_collection_bloc_map_cubit.dart @@ -0,0 +1,63 @@ +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:provider/provider.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; + +typedef PerAccountCollectionBlocMapState + = BlocMapState; + +/// Map of the logged in user accounts to their PerAccountCollectionCubit +/// Ensures there is an single account record cubit for each logged in account +class PerAccountCollectionBlocMapCubit extends BlocMapCubit + with StateMapFollower { + PerAccountCollectionBlocMapCubit({ + required Locator locator, + required AccountRepository accountRepository, + }) : _locator = locator, + _accountRepository = accountRepository { + // Follow the local accounts cubit + follow(locator()); + } + + // Add account record cubit + void _addPerAccountCollectionCubit( + {required TypedKey superIdentityRecordKey}) => + add( + superIdentityRecordKey, + () => PerAccountCollectionCubit( + locator: _locator, + accountInfoCubit: AccountInfoCubit( + accountRepository: _accountRepository, + superIdentityRecordKey: superIdentityRecordKey))); + + /// StateFollower ///////////////////////// + + @override + void removeFromState(TypedKey key) => remove(key); + + @override + void updateState( + TypedKey key, LocalAccount? oldValue, LocalAccount newValue) { + // Don't replace unless this is a totally different account + // The sub-cubit's subscription will update our state later + if (oldValue != null) { + if (oldValue.superIdentity.recordKey != + newValue.superIdentity.recordKey) { + throw StateError( + 'should remove LocalAccount and make a new one, not change it, if ' + 'the superidentity record key has changed'); + } + // This never changes anything that should result in rebuildin the + // sub-cubit + return; + } + _addPerAccountCollectionCubit( + superIdentityRecordKey: newValue.superIdentity.recordKey); + } + + //////////////////////////////////////////////////////////////////////////// + final AccountRepository _accountRepository; + final Locator _locator; +} diff --git a/lib/account_manager/cubits/per_account_collection_cubit.dart b/lib/account_manager/cubits/per_account_collection_cubit.dart new file mode 100644 index 0000000..fc2d447 --- /dev/null +++ b/lib/account_manager/cubits/per_account_collection_cubit.dart @@ -0,0 +1,310 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../chat/chat.dart'; +import '../../chat_list/chat_list.dart'; +import '../../contact_invitation/contact_invitation.dart'; +import '../../contacts/contacts.dart'; +import '../../conversation/conversation.dart'; +import '../../notifications/notifications.dart'; +import '../../proto/proto.dart' as proto; +import '../account_manager.dart'; + +const _kAccountRecordSubscriptionListenKey = + 'accountRecordSubscriptionListenKey'; + +class PerAccountCollectionCubit extends Cubit { + PerAccountCollectionCubit({ + required Locator locator, + required this.accountInfoCubit, + }) : _locator = locator, + super(_initialState(accountInfoCubit)) { + // Async Init + _initWait.add(_init); + } + + @override + Future close() async { + await _initWait(); + + await _processor.close(); + await accountInfoCubit.close(); + await _accountRecordSubscription?.cancel(); + await serialFutureClose((this, _kAccountRecordSubscriptionListenKey)); + await accountRecordCubit?.close(); + + await activeSingleContactChatBlocMapCubitUpdater.close(); + await activeConversationsBlocMapCubitUpdater.close(); + await activeChatCubitUpdater.close(); + await waitingInvitationsBlocMapCubitUpdater.close(); + await chatListCubitUpdater.close(); + await contactListCubitUpdater.close(); + await contactInvitationListCubitUpdater.close(); + + await super.close(); + } + + Future _init(Completer _cancel) async { + // subscribe to accountInfo changes + _processor.follow(accountInfoCubit.stream, accountInfoCubit.state, + _followAccountInfoState); + } + + static PerAccountCollectionState _initialState( + AccountInfoCubit accountInfoCubit) => + PerAccountCollectionState( + accountInfo: accountInfoCubit.state, + avAccountRecordState: const AsyncValue.loading(), + contactInvitationListCubit: null, + accountInfoCubit: null, + accountRecordCubit: null, + contactListCubit: null, + waitingInvitationsBlocMapCubit: null, + activeChatCubit: null, + chatListCubit: null, + activeConversationsBlocMapCubit: null, + activeSingleContactChatBlocMapCubit: null); + + Future _followAccountInfoState(AccountInfo accountInfo) async { + // Get the next state + var nextState = state.copyWith(accountInfo: accountInfo); + + // Update AccountRecordCubit + if (accountInfo.userLogin == null) { + /////////////// Not logged in ///////////////// + + // Unsubscribe AccountRecordCubit + await _accountRecordSubscription?.cancel(); + _accountRecordSubscription = null; + + // Close AccountRecordCubit + await accountRecordCubit?.close(); + accountRecordCubit = null; + + // Update state to 'loading' + nextState = await _updateAccountRecordState(nextState, null); + emit(nextState); + } else { + ///////////////// Logged in /////////////////// + + // Create AccountRecordCubit + accountRecordCubit ??= AccountRecordCubit( + localAccount: accountInfo.localAccount, + userLogin: accountInfo.userLogin!); + + // Update state to value + nextState = + await _updateAccountRecordState(nextState, accountRecordCubit!.state); + emit(nextState); + + // Subscribe AccountRecordCubit + _accountRecordSubscription ??= + accountRecordCubit!.stream.listen((avAccountRecordState) { + serialFuture((this, _kAccountRecordSubscriptionListenKey), () async { + emit(await _updateAccountRecordState(state, avAccountRecordState)); + }); + }); + } + } + + Future _updateAccountRecordState( + PerAccountCollectionState prevState, + AsyncValue? avAccountRecordState) async { + // Get next state + final nextState = + prevState.copyWith(avAccountRecordState: avAccountRecordState); + + // Get bloc parameters + final accountInfo = nextState.accountInfo; + + // ContactInvitationListCubit + final contactInvitationListRecordPointer = nextState + .avAccountRecordState?.asData?.value.contactInvitationRecords + .toVeilid(); + + final contactInvitationListCubit = await contactInvitationListCubitUpdater + .update(accountInfo.userLogin == null || + contactInvitationListRecordPointer == null + ? null + : (accountInfo, contactInvitationListRecordPointer)); + + // ContactListCubit + final contactListRecordPointer = + nextState.avAccountRecordState?.asData?.value.contactList.toVeilid(); + + final contactListCubit = await contactListCubitUpdater.update( + accountInfo.userLogin == null || contactListRecordPointer == null + ? null + : (accountInfo, contactListRecordPointer)); + + // WaitingInvitationsBlocMapCubit + final waitingInvitationsBlocMapCubit = + await waitingInvitationsBlocMapCubitUpdater.update( + accountInfo.userLogin == null || + contactInvitationListCubit == null || + contactListCubit == null + ? null + : ( + accountInfo, + accountRecordCubit!, + contactInvitationListCubit, + contactListCubit, + _locator(), + )); + + // ActiveChatCubit + final activeChatCubit = await activeChatCubitUpdater + .update((accountInfo.userLogin == null) ? null : true); + + // ChatListCubit + final chatListRecordPointer = + nextState.avAccountRecordState?.asData?.value.chatList.toVeilid(); + + final chatListCubit = await chatListCubitUpdater.update( + accountInfo.userLogin == null || + chatListRecordPointer == null || + activeChatCubit == null + ? null + : (accountInfo, chatListRecordPointer, activeChatCubit)); + + // ActiveConversationsBlocMapCubit + final activeConversationsBlocMapCubit = + await activeConversationsBlocMapCubitUpdater.update( + accountRecordCubit == null || + chatListCubit == null || + contactListCubit == null + ? null + : ( + accountInfo, + accountRecordCubit!, + chatListCubit, + contactListCubit + )); + + // ActiveSingleContactChatBlocMapCubit + final activeSingleContactChatBlocMapCubit = + await activeSingleContactChatBlocMapCubitUpdater.update( + accountInfo.userLogin == null || + activeConversationsBlocMapCubit == null + ? null + : ( + accountInfo, + activeConversationsBlocMapCubit, + )); + + // Update available blocs in our state + return nextState.copyWith( + contactInvitationListCubit: contactInvitationListCubit, + accountInfoCubit: accountInfoCubit, + accountRecordCubit: accountRecordCubit, + contactListCubit: contactListCubit, + waitingInvitationsBlocMapCubit: waitingInvitationsBlocMapCubit, + activeChatCubit: activeChatCubit, + chatListCubit: chatListCubit, + activeConversationsBlocMapCubit: activeConversationsBlocMapCubit, + activeSingleContactChatBlocMapCubit: + activeSingleContactChatBlocMapCubit); + } + + T collectionLocator() { + if (T is AccountInfoCubit) { + return accountInfoCubit as T; + } + if (T is AccountRecordCubit) { + return accountRecordCubit! as T; + } + if (T is ContactInvitationListCubit) { + return contactInvitationListCubitUpdater.bloc! as T; + } + if (T is ContactListCubit) { + return contactListCubitUpdater.bloc! as T; + } + if (T is WaitingInvitationsBlocMapCubit) { + return waitingInvitationsBlocMapCubitUpdater.bloc! as T; + } + if (T is ActiveChatCubit) { + return activeChatCubitUpdater.bloc! as T; + } + if (T is ChatListCubit) { + return chatListCubitUpdater.bloc! as T; + } + if (T is ActiveConversationsBlocMapCubit) { + return activeConversationsBlocMapCubitUpdater.bloc! as T; + } + if (T is ActiveSingleContactChatBlocMapCubit) { + return activeSingleContactChatBlocMapCubitUpdater.bloc! as T; + } + return _locator(); + } + + final Locator _locator; + final _processor = SingleStateProcessor(); + final _initWait = WaitSet(); + + // Per-account cubits regardless of login state + final AccountInfoCubit accountInfoCubit; + + // Per logged-in account cubits + AccountRecordCubit? accountRecordCubit; + StreamSubscription>? + _accountRecordSubscription; + final contactInvitationListCubitUpdater = BlocUpdater< + ContactInvitationListCubit, (AccountInfo, OwnedDHTRecordPointer)>( + create: (params) => ContactInvitationListCubit( + accountInfo: params.$1, + contactInvitationListRecordPointer: params.$2, + )); + final contactListCubitUpdater = + BlocUpdater( + create: (params) => ContactListCubit( + accountInfo: params.$1, + contactListRecordPointer: params.$2, + )); + final waitingInvitationsBlocMapCubitUpdater = BlocUpdater< + WaitingInvitationsBlocMapCubit, + ( + AccountInfo, + AccountRecordCubit, + ContactInvitationListCubit, + ContactListCubit, + NotificationsCubit, + )>( + create: (params) => WaitingInvitationsBlocMapCubit( + accountInfo: params.$1, + accountRecordCubit: params.$2, + contactInvitationListCubit: params.$3, + contactListCubit: params.$4, + notificationsCubit: params.$5, + )); + final activeChatCubitUpdater = + BlocUpdater(create: (_) => ActiveChatCubit(null)); + final chatListCubitUpdater = BlocUpdater( + create: (params) => ChatListCubit( + accountInfo: params.$1, + chatListRecordPointer: params.$2, + activeChatCubit: params.$3)); + final activeConversationsBlocMapCubitUpdater = BlocUpdater< + ActiveConversationsBlocMapCubit, + (AccountInfo, AccountRecordCubit, ChatListCubit, ContactListCubit)>( + create: (params) => ActiveConversationsBlocMapCubit( + accountInfo: params.$1, + accountRecordCubit: params.$2, + chatListCubit: params.$3, + contactListCubit: params.$4)); + final activeSingleContactChatBlocMapCubitUpdater = BlocUpdater< + ActiveSingleContactChatBlocMapCubit, + ( + AccountInfo, + ActiveConversationsBlocMapCubit, + )>( + create: (params) => ActiveSingleContactChatBlocMapCubit( + accountInfo: params.$1, + activeConversationsBlocMapCubit: params.$2, + )); +} diff --git a/lib/account_manager/cubits/user_logins_cubit.dart b/lib/account_manager/cubits/user_logins_cubit.dart index 9fa6974..5623a34 100644 --- a/lib/account_manager/cubits/user_logins_cubit.dart +++ b/lib/account_manager/cubits/user_logins_cubit.dart @@ -4,9 +4,11 @@ import 'package:bloc/bloc.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import '../models/models.dart'; -import '../repository/account_repository/account_repository.dart'; +import '../repository/account_repository.dart'; -class UserLoginsCubit extends Cubit> { +typedef UserLoginsState = IList; + +class UserLoginsCubit extends Cubit { UserLoginsCubit(AccountRepository accountRepository) : _accountRepository = accountRepository, super(accountRepository.getUserLogins()) { @@ -15,7 +17,6 @@ class UserLoginsCubit extends Cubit> { switch (change) { case AccountRepositoryChange.userLogins: emit(_accountRepository.getUserLogins()); - break; // Ignore these case AccountRepositoryChange.localAccounts: case AccountRepositoryChange.activeLocalAccount: @@ -29,6 +30,7 @@ class UserLoginsCubit extends Cubit> { await super.close(); await _accountRepositorySubscription.cancel(); } + //////////////////////////////////////////////////////////////////////////// final AccountRepository _accountRepository; late final StreamSubscription diff --git a/lib/account_manager/models/account_info.dart b/lib/account_manager/models/account_info.dart index 7f2e058..8f57add 100644 --- a/lib/account_manager/models/account_info.dart +++ b/lib/account_manager/models/account_info.dart @@ -1,23 +1,69 @@ -import 'package:meta/meta.dart'; +import 'dart:convert'; -import 'active_account_info.dart'; +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../account_manager.dart'; enum AccountInfoStatus { - noAccount, accountInvalid, accountLocked, - accountReady, + accountUnlocked, } @immutable -class AccountInfo { +class AccountInfo extends Equatable implements ToDebugMap { const AccountInfo({ required this.status, - required this.active, - required this.activeAccountInfo, + required this.localAccount, + required this.userLogin, }); final AccountInfoStatus status; - final bool active; - final ActiveAccountInfo? activeAccountInfo; + final LocalAccount localAccount; + final UserLogin? userLogin; + + @override + List get props => [ + status, + localAccount, + userLogin, + ]; + + @override + Map toDebugMap() => { + 'status': status, + 'localAccount': localAccount, + 'userLogin': userLogin, + }; +} + +extension AccountInfoExt on AccountInfo { + TypedKey get superIdentityRecordKey => localAccount.superIdentity.recordKey; + TypedKey get accountRecordKey => + userLogin!.accountRecordInfo.accountRecord.recordKey; + TypedKey get identityTypedPublicKey => + localAccount.superIdentity.currentInstance.typedPublicKey; + PublicKey get identityPublicKey => + localAccount.superIdentity.currentInstance.publicKey; + SecretKey get identitySecretKey => userLogin!.identitySecret.value; + KeyPair get identityWriter => + KeyPair(key: identityPublicKey, secret: identitySecretKey); + Future get identityCryptoSystem => + localAccount.superIdentity.currentInstance.cryptoSystem; + + Future makeConversationCrypto( + TypedKey remoteIdentityPublicKey) async { + final identitySecret = userLogin!.identitySecret; + final cs = await Veilid.instance.getCryptoSystem(identitySecret.kind); + final sharedSecret = await cs.generateSharedSecret( + remoteIdentityPublicKey.value, + identitySecret.value, + utf8.encode('VeilidChat Conversation')); + + final messagesCrypto = await VeilidCryptoPrivate.fromSharedSecret( + identitySecret.kind, sharedSecret); + return messagesCrypto; + } } diff --git a/lib/account_manager/models/account_spec.dart b/lib/account_manager/models/account_spec.dart new file mode 100644 index 0000000..918e192 --- /dev/null +++ b/lib/account_manager/models/account_spec.dart @@ -0,0 +1,121 @@ +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; +import 'package:protobuf/protobuf.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../proto/proto.dart' as proto; + +/// Profile and Account configurable fields +/// Some are publicly visible via the proto.Profile +/// Some are privately held as proto.Account configurations +@immutable +class AccountSpec extends Equatable { + const AccountSpec( + {required this.name, + required this.pronouns, + required this.about, + required this.availability, + required this.invisible, + required this.freeMessage, + required this.awayMessage, + required this.busyMessage, + required this.avatar, + required this.autoAway, + required this.autoAwayTimeout}); + + const AccountSpec.empty() + : name = '', + pronouns = '', + about = '', + availability = proto.Availability.AVAILABILITY_FREE, + invisible = false, + freeMessage = '', + awayMessage = '', + busyMessage = '', + avatar = null, + autoAway = false, + autoAwayTimeout = 15; + + AccountSpec.fromProto(proto.Account p) + : name = p.profile.name, + pronouns = p.profile.pronouns, + about = p.profile.about, + availability = p.profile.availability, + invisible = p.invisible, + freeMessage = p.freeMessage, + awayMessage = p.awayMessage, + busyMessage = p.busyMessage, + avatar = p.profile.hasAvatar() ? p.profile.avatar : null, + autoAway = p.autodetectAway, + autoAwayTimeout = p.autoAwayTimeoutMin; + + String get status { + late final String status; + switch (availability) { + case proto.Availability.AVAILABILITY_AWAY: + status = awayMessage; + case proto.Availability.AVAILABILITY_BUSY: + status = busyMessage; + case proto.Availability.AVAILABILITY_FREE: + status = freeMessage; + case proto.Availability.AVAILABILITY_UNSPECIFIED: + case proto.Availability.AVAILABILITY_OFFLINE: + status = ''; + } + return status; + } + + Future updateProto(proto.Account old) async { + final newProto = old.deepCopy() + ..profile.name = name + ..profile.pronouns = pronouns + ..profile.about = about + ..profile.availability = availability + ..profile.status = status + ..profile.timestamp = Veilid.instance.now().toInt64() + ..invisible = invisible + ..autodetectAway = autoAway + ..autoAwayTimeoutMin = autoAwayTimeout + ..freeMessage = freeMessage + ..awayMessage = awayMessage + ..busyMessage = busyMessage; + + final newAvatar = avatar; + if (newAvatar != null) { + newProto.profile.avatar = newAvatar; + } else { + newProto.profile.clearAvatar(); + } + + return newProto; + } + + //////////////////////////////////////////////////////////////////////////// + + final String name; + final String pronouns; + final String about; + final proto.Availability availability; + final bool invisible; + final String freeMessage; + final String awayMessage; + final String busyMessage; + final proto.DataReference? avatar; + final bool autoAway; + final int autoAwayTimeout; + + @override + List get props => [ + name, + pronouns, + about, + availability, + invisible, + freeMessage, + awayMessage, + busyMessage, + avatar, + autoAway, + autoAwayTimeout + ]; +} diff --git a/lib/account_manager/models/active_account_info.dart b/lib/account_manager/models/active_account_info.dart deleted file mode 100644 index 5f69e32..0000000 --- a/lib/account_manager/models/active_account_info.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'dart:convert'; - -import 'package:meta/meta.dart'; -import 'package:veilid_support/veilid_support.dart'; - -import 'local_account/local_account.dart'; -import 'user_login/user_login.dart'; - -@immutable -class ActiveAccountInfo { - const ActiveAccountInfo({ - required this.localAccount, - required this.userLogin, - }); - // - - TypedKey get superIdentityRecordKey => localAccount.superIdentity.recordKey; - TypedKey get accountRecordKey => - userLogin.accountRecordInfo.accountRecord.recordKey; - TypedKey get identityTypedPublicKey => - localAccount.superIdentity.currentInstance.typedPublicKey; - PublicKey get identityPublicKey => - localAccount.superIdentity.currentInstance.publicKey; - SecretKey get identitySecretKey => userLogin.identitySecret.value; - KeyPair get identityWriter => - KeyPair(key: identityPublicKey, secret: identitySecretKey); - Future get identityCryptoSystem => - localAccount.superIdentity.currentInstance.cryptoSystem; - - Future makeConversationCrypto( - TypedKey remoteIdentityPublicKey) async { - final identitySecret = userLogin.identitySecret; - final cs = await Veilid.instance.getCryptoSystem(identitySecret.kind); - final sharedSecret = await cs.generateSharedSecret( - remoteIdentityPublicKey.value, - identitySecret.value, - utf8.encode('VeilidChat Conversation')); - - final messagesCrypto = await VeilidCryptoPrivate.fromSharedSecret( - identitySecret.kind, sharedSecret); - return messagesCrypto; - } - - // - final LocalAccount localAccount; - final UserLogin userLogin; -} diff --git a/lib/account_manager/models/local_account/local_account.dart b/lib/account_manager/models/local_account/local_account.dart index 76070ae..81cfb8c 100644 --- a/lib/account_manager/models/local_account/local_account.dart +++ b/lib/account_manager/models/local_account/local_account.dart @@ -15,8 +15,9 @@ part 'local_account.freezed.dart'; // and the identitySecretKey optionally encrypted by an unlock code // This is the root of the account information tree for VeilidChat // -@freezed -class LocalAccount with _$LocalAccount { +@Freezed(toJson: true) +abstract class LocalAccount with _$LocalAccount { + @JsonSerializable() const factory LocalAccount({ // The super identity key record for the account, // containing the publicKey in the currentIdentity @@ -40,6 +41,13 @@ class LocalAccount with _$LocalAccount { required String name, }) = _LocalAccount; - factory LocalAccount.fromJson(dynamic json) => - _$LocalAccountFromJson(json as Map); + factory LocalAccount.fromJson(dynamic json) { + try { + return _$LocalAccountFromJson(json as Map); + // Need to catch any errors here too + // ignore: avoid_catches_without_on_clauses + } catch (e, st) { + throw Exception('invalid local account: $e\n$st'); + } + } } diff --git a/lib/account_manager/models/local_account/local_account.freezed.dart b/lib/account_manager/models/local_account/local_account.freezed.dart index 92e376f..8d7aed1 100644 --- a/lib/account_manager/models/local_account/local_account.freezed.dart +++ b/lib/account_manager/models/local_account/local_account.freezed.dart @@ -1,3 +1,4 @@ +// dart format width=80 // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint @@ -9,45 +10,78 @@ part of 'local_account.dart'; // FreezedGenerator // ************************************************************************** +// dart format off T _$identity(T value) => value; -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -LocalAccount _$LocalAccountFromJson(Map json) { - return _LocalAccount.fromJson(json); -} - /// @nodoc mixin _$LocalAccount { // The super identity key record for the account, // containing the publicKey in the currentIdentity - SuperIdentity get superIdentity => - throw _privateConstructorUsedError; // The encrypted currentIdentity secret that goes with + SuperIdentity + get superIdentity; // The encrypted currentIdentity secret that goes with // the identityPublicKey with appended salt @Uint8ListJsonConverter() - Uint8List get identitySecretBytes => - throw _privateConstructorUsedError; // The kind of encryption input used on the account - EncryptionKeyType get encryptionKeyType => - throw _privateConstructorUsedError; // If account is not hidden, password can be retrieved via - bool get biometricsEnabled => - throw _privateConstructorUsedError; // Keep account hidden unless account password is entered + Uint8List + get identitySecretBytes; // The kind of encryption input used on the account + EncryptionKeyType + get encryptionKeyType; // If account is not hidden, password can be retrieved via + bool + get biometricsEnabled; // Keep account hidden unless account password is entered // (tries all hidden accounts with auth method (no biometrics)) - bool get hiddenAccount => - throw _privateConstructorUsedError; // Display name for account until it is unlocked - String get name => throw _privateConstructorUsedError; + bool get hiddenAccount; // Display name for account until it is unlocked + String get name; - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) + /// Create a copy of LocalAccount + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') $LocalAccountCopyWith get copyWith => - throw _privateConstructorUsedError; + _$LocalAccountCopyWithImpl( + this as LocalAccount, _$identity); + + /// Serializes this LocalAccount to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is LocalAccount && + (identical(other.superIdentity, superIdentity) || + other.superIdentity == superIdentity) && + const DeepCollectionEquality() + .equals(other.identitySecretBytes, identitySecretBytes) && + (identical(other.encryptionKeyType, encryptionKeyType) || + other.encryptionKeyType == encryptionKeyType) && + (identical(other.biometricsEnabled, biometricsEnabled) || + other.biometricsEnabled == biometricsEnabled) && + (identical(other.hiddenAccount, hiddenAccount) || + other.hiddenAccount == hiddenAccount) && + (identical(other.name, name) || other.name == name)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + superIdentity, + const DeepCollectionEquality().hash(identitySecretBytes), + encryptionKeyType, + biometricsEnabled, + hiddenAccount, + name); + + @override + String toString() { + return 'LocalAccount(superIdentity: $superIdentity, identitySecretBytes: $identitySecretBytes, encryptionKeyType: $encryptionKeyType, biometricsEnabled: $biometricsEnabled, hiddenAccount: $hiddenAccount, name: $name)'; + } } /// @nodoc -abstract class $LocalAccountCopyWith<$Res> { +abstract mixin class $LocalAccountCopyWith<$Res> { factory $LocalAccountCopyWith( - LocalAccount value, $Res Function(LocalAccount) then) = - _$LocalAccountCopyWithImpl<$Res, LocalAccount>; + LocalAccount value, $Res Function(LocalAccount) _then) = + _$LocalAccountCopyWithImpl; @useResult $Res call( {SuperIdentity superIdentity, @@ -61,15 +95,14 @@ abstract class $LocalAccountCopyWith<$Res> { } /// @nodoc -class _$LocalAccountCopyWithImpl<$Res, $Val extends LocalAccount> - implements $LocalAccountCopyWith<$Res> { - _$LocalAccountCopyWithImpl(this._value, this._then); +class _$LocalAccountCopyWithImpl<$Res> implements $LocalAccountCopyWith<$Res> { + _$LocalAccountCopyWithImpl(this._self, this._then); - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; + final LocalAccount _self; + final $Res Function(LocalAccount) _then; + /// Create a copy of LocalAccount + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -80,114 +113,50 @@ class _$LocalAccountCopyWithImpl<$Res, $Val extends LocalAccount> Object? hiddenAccount = null, Object? name = null, }) { - return _then(_value.copyWith( + return _then(_self.copyWith( superIdentity: null == superIdentity - ? _value.superIdentity + ? _self.superIdentity : superIdentity // ignore: cast_nullable_to_non_nullable as SuperIdentity, identitySecretBytes: null == identitySecretBytes - ? _value.identitySecretBytes + ? _self.identitySecretBytes : identitySecretBytes // ignore: cast_nullable_to_non_nullable as Uint8List, encryptionKeyType: null == encryptionKeyType - ? _value.encryptionKeyType + ? _self.encryptionKeyType : encryptionKeyType // ignore: cast_nullable_to_non_nullable as EncryptionKeyType, biometricsEnabled: null == biometricsEnabled - ? _value.biometricsEnabled + ? _self.biometricsEnabled : biometricsEnabled // ignore: cast_nullable_to_non_nullable as bool, hiddenAccount: null == hiddenAccount - ? _value.hiddenAccount + ? _self.hiddenAccount : hiddenAccount // ignore: cast_nullable_to_non_nullable as bool, name: null == name - ? _value.name + ? _self.name : name // ignore: cast_nullable_to_non_nullable as String, - ) as $Val); + )); } + /// Create a copy of LocalAccount + /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') $SuperIdentityCopyWith<$Res> get superIdentity { - return $SuperIdentityCopyWith<$Res>(_value.superIdentity, (value) { - return _then(_value.copyWith(superIdentity: value) as $Val); + return $SuperIdentityCopyWith<$Res>(_self.superIdentity, (value) { + return _then(_self.copyWith(superIdentity: value)); }); } } /// @nodoc -abstract class _$$LocalAccountImplCopyWith<$Res> - implements $LocalAccountCopyWith<$Res> { - factory _$$LocalAccountImplCopyWith( - _$LocalAccountImpl value, $Res Function(_$LocalAccountImpl) then) = - __$$LocalAccountImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {SuperIdentity superIdentity, - @Uint8ListJsonConverter() Uint8List identitySecretBytes, - EncryptionKeyType encryptionKeyType, - bool biometricsEnabled, - bool hiddenAccount, - String name}); - @override - $SuperIdentityCopyWith<$Res> get superIdentity; -} - -/// @nodoc -class __$$LocalAccountImplCopyWithImpl<$Res> - extends _$LocalAccountCopyWithImpl<$Res, _$LocalAccountImpl> - implements _$$LocalAccountImplCopyWith<$Res> { - __$$LocalAccountImplCopyWithImpl( - _$LocalAccountImpl _value, $Res Function(_$LocalAccountImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? superIdentity = null, - Object? identitySecretBytes = null, - Object? encryptionKeyType = null, - Object? biometricsEnabled = null, - Object? hiddenAccount = null, - Object? name = null, - }) { - return _then(_$LocalAccountImpl( - superIdentity: null == superIdentity - ? _value.superIdentity - : superIdentity // ignore: cast_nullable_to_non_nullable - as SuperIdentity, - identitySecretBytes: null == identitySecretBytes - ? _value.identitySecretBytes - : identitySecretBytes // ignore: cast_nullable_to_non_nullable - as Uint8List, - encryptionKeyType: null == encryptionKeyType - ? _value.encryptionKeyType - : encryptionKeyType // ignore: cast_nullable_to_non_nullable - as EncryptionKeyType, - biometricsEnabled: null == biometricsEnabled - ? _value.biometricsEnabled - : biometricsEnabled // ignore: cast_nullable_to_non_nullable - as bool, - hiddenAccount: null == hiddenAccount - ? _value.hiddenAccount - : hiddenAccount // ignore: cast_nullable_to_non_nullable - as bool, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - )); - } -} - -/// @nodoc @JsonSerializable() -class _$LocalAccountImpl implements _LocalAccount { - const _$LocalAccountImpl( +class _LocalAccount implements LocalAccount { + const _LocalAccount( {required this.superIdentity, @Uint8ListJsonConverter() required this.identitySecretBytes, required this.encryptionKeyType, @@ -195,9 +164,6 @@ class _$LocalAccountImpl implements _LocalAccount { required this.hiddenAccount, required this.name}); - factory _$LocalAccountImpl.fromJson(Map json) => - _$$LocalAccountImplFromJson(json); - // The super identity key record for the account, // containing the publicKey in the currentIdentity @override @@ -221,16 +187,26 @@ class _$LocalAccountImpl implements _LocalAccount { @override final String name; + /// Create a copy of LocalAccount + /// with the given fields replaced by the non-null parameter values. @override - String toString() { - return 'LocalAccount(superIdentity: $superIdentity, identitySecretBytes: $identitySecretBytes, encryptionKeyType: $encryptionKeyType, biometricsEnabled: $biometricsEnabled, hiddenAccount: $hiddenAccount, name: $name)'; + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$LocalAccountCopyWith<_LocalAccount> get copyWith => + __$LocalAccountCopyWithImpl<_LocalAccount>(this, _$identity); + + @override + Map toJson() { + return _$LocalAccountToJson( + this, + ); } @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$LocalAccountImpl && + other is _LocalAccount && (identical(other.superIdentity, superIdentity) || other.superIdentity == superIdentity) && const DeepCollectionEquality() @@ -244,7 +220,7 @@ class _$LocalAccountImpl implements _LocalAccount { (identical(other.name, name) || other.name == name)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash( runtimeType, @@ -255,50 +231,89 @@ class _$LocalAccountImpl implements _LocalAccount { hiddenAccount, name); - @JsonKey(ignore: true) @override - @pragma('vm:prefer-inline') - _$$LocalAccountImplCopyWith<_$LocalAccountImpl> get copyWith => - __$$LocalAccountImplCopyWithImpl<_$LocalAccountImpl>(this, _$identity); - - @override - Map toJson() { - return _$$LocalAccountImplToJson( - this, - ); + String toString() { + return 'LocalAccount(superIdentity: $superIdentity, identitySecretBytes: $identitySecretBytes, encryptionKeyType: $encryptionKeyType, biometricsEnabled: $biometricsEnabled, hiddenAccount: $hiddenAccount, name: $name)'; } } -abstract class _LocalAccount implements LocalAccount { - const factory _LocalAccount( - {required final SuperIdentity superIdentity, - @Uint8ListJsonConverter() required final Uint8List identitySecretBytes, - required final EncryptionKeyType encryptionKeyType, - required final bool biometricsEnabled, - required final bool hiddenAccount, - required final String name}) = _$LocalAccountImpl; - - factory _LocalAccount.fromJson(Map json) = - _$LocalAccountImpl.fromJson; - - @override // The super identity key record for the account, -// containing the publicKey in the currentIdentity - SuperIdentity get superIdentity; - @override // The encrypted currentIdentity secret that goes with -// the identityPublicKey with appended salt - @Uint8ListJsonConverter() - Uint8List get identitySecretBytes; - @override // The kind of encryption input used on the account - EncryptionKeyType get encryptionKeyType; - @override // If account is not hidden, password can be retrieved via - bool get biometricsEnabled; - @override // Keep account hidden unless account password is entered -// (tries all hidden accounts with auth method (no biometrics)) - bool get hiddenAccount; - @override // Display name for account until it is unlocked - String get name; +/// @nodoc +abstract mixin class _$LocalAccountCopyWith<$Res> + implements $LocalAccountCopyWith<$Res> { + factory _$LocalAccountCopyWith( + _LocalAccount value, $Res Function(_LocalAccount) _then) = + __$LocalAccountCopyWithImpl; @override - @JsonKey(ignore: true) - _$$LocalAccountImplCopyWith<_$LocalAccountImpl> get copyWith => - throw _privateConstructorUsedError; + @useResult + $Res call( + {SuperIdentity superIdentity, + @Uint8ListJsonConverter() Uint8List identitySecretBytes, + EncryptionKeyType encryptionKeyType, + bool biometricsEnabled, + bool hiddenAccount, + String name}); + + @override + $SuperIdentityCopyWith<$Res> get superIdentity; } + +/// @nodoc +class __$LocalAccountCopyWithImpl<$Res> + implements _$LocalAccountCopyWith<$Res> { + __$LocalAccountCopyWithImpl(this._self, this._then); + + final _LocalAccount _self; + final $Res Function(_LocalAccount) _then; + + /// Create a copy of LocalAccount + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? superIdentity = null, + Object? identitySecretBytes = null, + Object? encryptionKeyType = null, + Object? biometricsEnabled = null, + Object? hiddenAccount = null, + Object? name = null, + }) { + return _then(_LocalAccount( + superIdentity: null == superIdentity + ? _self.superIdentity + : superIdentity // ignore: cast_nullable_to_non_nullable + as SuperIdentity, + identitySecretBytes: null == identitySecretBytes + ? _self.identitySecretBytes + : identitySecretBytes // ignore: cast_nullable_to_non_nullable + as Uint8List, + encryptionKeyType: null == encryptionKeyType + ? _self.encryptionKeyType + : encryptionKeyType // ignore: cast_nullable_to_non_nullable + as EncryptionKeyType, + biometricsEnabled: null == biometricsEnabled + ? _self.biometricsEnabled + : biometricsEnabled // ignore: cast_nullable_to_non_nullable + as bool, + hiddenAccount: null == hiddenAccount + ? _self.hiddenAccount + : hiddenAccount // ignore: cast_nullable_to_non_nullable + as bool, + name: null == name + ? _self.name + : name // ignore: cast_nullable_to_non_nullable + as String, + )); + } + + /// Create a copy of LocalAccount + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $SuperIdentityCopyWith<$Res> get superIdentity { + return $SuperIdentityCopyWith<$Res>(_self.superIdentity, (value) { + return _then(_self.copyWith(superIdentity: value)); + }); + } +} + +// dart format on diff --git a/lib/account_manager/models/local_account/local_account.g.dart b/lib/account_manager/models/local_account/local_account.g.dart index b60c226..40d55e5 100644 --- a/lib/account_manager/models/local_account/local_account.g.dart +++ b/lib/account_manager/models/local_account/local_account.g.dart @@ -6,8 +6,8 @@ part of 'local_account.dart'; // JsonSerializableGenerator // ************************************************************************** -_$LocalAccountImpl _$$LocalAccountImplFromJson(Map json) => - _$LocalAccountImpl( +_LocalAccount _$LocalAccountFromJson(Map json) => + _LocalAccount( superIdentity: SuperIdentity.fromJson(json['super_identity']), identitySecretBytes: const Uint8ListJsonConverter() .fromJson(json['identity_secret_bytes']), @@ -18,7 +18,7 @@ _$LocalAccountImpl _$$LocalAccountImplFromJson(Map json) => name: json['name'] as String, ); -Map _$$LocalAccountImplToJson(_$LocalAccountImpl instance) => +Map _$LocalAccountToJson(_LocalAccount instance) => { 'super_identity': instance.superIdentity.toJson(), 'identity_secret_bytes': diff --git a/lib/account_manager/models/models.dart b/lib/account_manager/models/models.dart index d4b0ab5..1a0c809 100644 --- a/lib/account_manager/models/models.dart +++ b/lib/account_manager/models/models.dart @@ -1,6 +1,6 @@ export 'account_info.dart'; -export 'active_account_info.dart'; +export 'account_spec.dart'; export 'encryption_key_type.dart'; export 'local_account/local_account.dart'; -export 'new_profile_spec.dart'; +export 'per_account_collection_state/per_account_collection_state.dart'; export 'user_login/user_login.dart'; diff --git a/lib/account_manager/models/new_profile_spec.dart b/lib/account_manager/models/new_profile_spec.dart deleted file mode 100644 index 173a382..0000000 --- a/lib/account_manager/models/new_profile_spec.dart +++ /dev/null @@ -1,5 +0,0 @@ -class NewProfileSpec { - NewProfileSpec({required this.name, required this.pronouns}); - String name; - String pronouns; -} diff --git a/lib/account_manager/models/per_account_collection_state/per_account_collection_state.dart b/lib/account_manager/models/per_account_collection_state/per_account_collection_state.dart new file mode 100644 index 0000000..24a394c --- /dev/null +++ b/lib/account_manager/models/per_account_collection_state/per_account_collection_state.dart @@ -0,0 +1,89 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../../chat/chat.dart'; +import '../../../chat_list/chat_list.dart'; +import '../../../contact_invitation/contact_invitation.dart'; +import '../../../contacts/contacts.dart'; +import '../../../conversation/conversation.dart'; +import '../../../proto/proto.dart' show Account; +import '../../account_manager.dart'; + +part 'per_account_collection_state.freezed.dart'; + +@freezed +sealed class PerAccountCollectionState + with _$PerAccountCollectionState + implements ToDebugMap { + const factory PerAccountCollectionState({ + required AccountInfo accountInfo, + required AsyncValue? avAccountRecordState, + required AccountInfoCubit? accountInfoCubit, + required AccountRecordCubit? accountRecordCubit, + required ContactInvitationListCubit? contactInvitationListCubit, + required ContactListCubit? contactListCubit, + required WaitingInvitationsBlocMapCubit? waitingInvitationsBlocMapCubit, + required ActiveChatCubit? activeChatCubit, + required ChatListCubit? chatListCubit, + required ActiveConversationsBlocMapCubit? activeConversationsBlocMapCubit, + required ActiveSingleContactChatBlocMapCubit? + activeSingleContactChatBlocMapCubit, + }) = _PerAccountCollectionState; + const PerAccountCollectionState._(); + + @override + Map toDebugMap() => { + 'accountInfo': accountInfo, + 'avAccountRecordState': avAccountRecordState, + 'accountInfoCubit': accountInfoCubit, + 'accountRecordCubit': accountRecordCubit, + 'contactInvitationListCubit': contactInvitationListCubit, + 'contactListCubit': contactListCubit, + 'waitingInvitationsBlocMapCubit': waitingInvitationsBlocMapCubit, + 'activeChatCubit': activeChatCubit, + 'chatListCubit': chatListCubit, + 'activeConversationsBlocMapCubit': activeConversationsBlocMapCubit, + 'activeSingleContactChatBlocMapCubit': + activeSingleContactChatBlocMapCubit, + }; +} + +extension PerAccountCollectionStateExt on PerAccountCollectionState { + // Returns if the account is ready and logged in + bool get isReady => + avAccountRecordState != null && + avAccountRecordState!.isData && + accountInfoCubit != null && + accountRecordCubit != null && + contactInvitationListCubit != null && + contactListCubit != null && + waitingInvitationsBlocMapCubit != null && + activeChatCubit != null && + chatListCubit != null && + activeConversationsBlocMapCubit != null && + activeSingleContactChatBlocMapCubit != null; + + /// If we have a selected account and it is ready and not locked, + /// this will provide the unlocked account's cubits to the context + Widget provideReady({required Widget child}) { + if (isReady) { + return MultiBlocProvider(providers: [ + BlocProvider.value(value: accountInfoCubit!), + BlocProvider.value(value: accountRecordCubit!), + BlocProvider.value(value: contactInvitationListCubit!), + BlocProvider.value(value: contactListCubit!), + BlocProvider.value(value: waitingInvitationsBlocMapCubit!), + BlocProvider.value(value: activeChatCubit!), + BlocProvider.value(value: chatListCubit!), + BlocProvider.value(value: activeConversationsBlocMapCubit!), + BlocProvider.value(value: activeSingleContactChatBlocMapCubit!), + ], child: child); + } else { + // Otherwise we just provide the child + return child; + } + } +} diff --git a/lib/account_manager/models/per_account_collection_state/per_account_collection_state.freezed.dart b/lib/account_manager/models/per_account_collection_state/per_account_collection_state.freezed.dart new file mode 100644 index 0000000..ac1254b --- /dev/null +++ b/lib/account_manager/models/per_account_collection_state/per_account_collection_state.freezed.dart @@ -0,0 +1,436 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'per_account_collection_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$PerAccountCollectionState { + AccountInfo get accountInfo; + AsyncValue? get avAccountRecordState; + AccountInfoCubit? get accountInfoCubit; + AccountRecordCubit? get accountRecordCubit; + ContactInvitationListCubit? get contactInvitationListCubit; + ContactListCubit? get contactListCubit; + WaitingInvitationsBlocMapCubit? get waitingInvitationsBlocMapCubit; + ActiveChatCubit? get activeChatCubit; + ChatListCubit? get chatListCubit; + ActiveConversationsBlocMapCubit? get activeConversationsBlocMapCubit; + ActiveSingleContactChatBlocMapCubit? get activeSingleContactChatBlocMapCubit; + + /// Create a copy of PerAccountCollectionState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $PerAccountCollectionStateCopyWith get copyWith => + _$PerAccountCollectionStateCopyWithImpl( + this as PerAccountCollectionState, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is PerAccountCollectionState && + (identical(other.accountInfo, accountInfo) || + other.accountInfo == accountInfo) && + (identical(other.avAccountRecordState, avAccountRecordState) || + other.avAccountRecordState == avAccountRecordState) && + (identical(other.accountInfoCubit, accountInfoCubit) || + other.accountInfoCubit == accountInfoCubit) && + (identical(other.accountRecordCubit, accountRecordCubit) || + other.accountRecordCubit == accountRecordCubit) && + (identical(other.contactInvitationListCubit, + contactInvitationListCubit) || + other.contactInvitationListCubit == + contactInvitationListCubit) && + (identical(other.contactListCubit, contactListCubit) || + other.contactListCubit == contactListCubit) && + (identical(other.waitingInvitationsBlocMapCubit, + waitingInvitationsBlocMapCubit) || + other.waitingInvitationsBlocMapCubit == + waitingInvitationsBlocMapCubit) && + (identical(other.activeChatCubit, activeChatCubit) || + other.activeChatCubit == activeChatCubit) && + (identical(other.chatListCubit, chatListCubit) || + other.chatListCubit == chatListCubit) && + (identical(other.activeConversationsBlocMapCubit, + activeConversationsBlocMapCubit) || + other.activeConversationsBlocMapCubit == + activeConversationsBlocMapCubit) && + (identical(other.activeSingleContactChatBlocMapCubit, + activeSingleContactChatBlocMapCubit) || + other.activeSingleContactChatBlocMapCubit == + activeSingleContactChatBlocMapCubit)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + accountInfo, + avAccountRecordState, + accountInfoCubit, + accountRecordCubit, + contactInvitationListCubit, + contactListCubit, + waitingInvitationsBlocMapCubit, + activeChatCubit, + chatListCubit, + activeConversationsBlocMapCubit, + activeSingleContactChatBlocMapCubit); + + @override + String toString() { + return 'PerAccountCollectionState(accountInfo: $accountInfo, avAccountRecordState: $avAccountRecordState, accountInfoCubit: $accountInfoCubit, accountRecordCubit: $accountRecordCubit, contactInvitationListCubit: $contactInvitationListCubit, contactListCubit: $contactListCubit, waitingInvitationsBlocMapCubit: $waitingInvitationsBlocMapCubit, activeChatCubit: $activeChatCubit, chatListCubit: $chatListCubit, activeConversationsBlocMapCubit: $activeConversationsBlocMapCubit, activeSingleContactChatBlocMapCubit: $activeSingleContactChatBlocMapCubit)'; + } +} + +/// @nodoc +abstract mixin class $PerAccountCollectionStateCopyWith<$Res> { + factory $PerAccountCollectionStateCopyWith(PerAccountCollectionState value, + $Res Function(PerAccountCollectionState) _then) = + _$PerAccountCollectionStateCopyWithImpl; + @useResult + $Res call( + {AccountInfo accountInfo, + AsyncValue? avAccountRecordState, + AccountInfoCubit? accountInfoCubit, + AccountRecordCubit? accountRecordCubit, + ContactInvitationListCubit? contactInvitationListCubit, + ContactListCubit? contactListCubit, + WaitingInvitationsBlocMapCubit? waitingInvitationsBlocMapCubit, + ActiveChatCubit? activeChatCubit, + ChatListCubit? chatListCubit, + ActiveConversationsBlocMapCubit? activeConversationsBlocMapCubit, + ActiveSingleContactChatBlocMapCubit? + activeSingleContactChatBlocMapCubit}); + + $AsyncValueCopyWith? get avAccountRecordState; +} + +/// @nodoc +class _$PerAccountCollectionStateCopyWithImpl<$Res> + implements $PerAccountCollectionStateCopyWith<$Res> { + _$PerAccountCollectionStateCopyWithImpl(this._self, this._then); + + final PerAccountCollectionState _self; + final $Res Function(PerAccountCollectionState) _then; + + /// Create a copy of PerAccountCollectionState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? accountInfo = null, + Object? avAccountRecordState = freezed, + Object? accountInfoCubit = freezed, + Object? accountRecordCubit = freezed, + Object? contactInvitationListCubit = freezed, + Object? contactListCubit = freezed, + Object? waitingInvitationsBlocMapCubit = freezed, + Object? activeChatCubit = freezed, + Object? chatListCubit = freezed, + Object? activeConversationsBlocMapCubit = freezed, + Object? activeSingleContactChatBlocMapCubit = freezed, + }) { + return _then(_self.copyWith( + accountInfo: null == accountInfo + ? _self.accountInfo + : accountInfo // ignore: cast_nullable_to_non_nullable + as AccountInfo, + avAccountRecordState: freezed == avAccountRecordState + ? _self.avAccountRecordState + : avAccountRecordState // ignore: cast_nullable_to_non_nullable + as AsyncValue?, + accountInfoCubit: freezed == accountInfoCubit + ? _self.accountInfoCubit + : accountInfoCubit // ignore: cast_nullable_to_non_nullable + as AccountInfoCubit?, + accountRecordCubit: freezed == accountRecordCubit + ? _self.accountRecordCubit + : accountRecordCubit // ignore: cast_nullable_to_non_nullable + as AccountRecordCubit?, + contactInvitationListCubit: freezed == contactInvitationListCubit + ? _self.contactInvitationListCubit + : contactInvitationListCubit // ignore: cast_nullable_to_non_nullable + as ContactInvitationListCubit?, + contactListCubit: freezed == contactListCubit + ? _self.contactListCubit + : contactListCubit // ignore: cast_nullable_to_non_nullable + as ContactListCubit?, + waitingInvitationsBlocMapCubit: freezed == waitingInvitationsBlocMapCubit + ? _self.waitingInvitationsBlocMapCubit + : waitingInvitationsBlocMapCubit // ignore: cast_nullable_to_non_nullable + as WaitingInvitationsBlocMapCubit?, + activeChatCubit: freezed == activeChatCubit + ? _self.activeChatCubit + : activeChatCubit // ignore: cast_nullable_to_non_nullable + as ActiveChatCubit?, + chatListCubit: freezed == chatListCubit + ? _self.chatListCubit + : chatListCubit // ignore: cast_nullable_to_non_nullable + as ChatListCubit?, + activeConversationsBlocMapCubit: freezed == + activeConversationsBlocMapCubit + ? _self.activeConversationsBlocMapCubit + : activeConversationsBlocMapCubit // ignore: cast_nullable_to_non_nullable + as ActiveConversationsBlocMapCubit?, + activeSingleContactChatBlocMapCubit: freezed == + activeSingleContactChatBlocMapCubit + ? _self.activeSingleContactChatBlocMapCubit + : activeSingleContactChatBlocMapCubit // ignore: cast_nullable_to_non_nullable + as ActiveSingleContactChatBlocMapCubit?, + )); + } + + /// Create a copy of PerAccountCollectionState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $AsyncValueCopyWith? get avAccountRecordState { + if (_self.avAccountRecordState == null) { + return null; + } + + return $AsyncValueCopyWith(_self.avAccountRecordState!, + (value) { + return _then(_self.copyWith(avAccountRecordState: value)); + }); + } +} + +/// @nodoc + +class _PerAccountCollectionState extends PerAccountCollectionState { + const _PerAccountCollectionState( + {required this.accountInfo, + required this.avAccountRecordState, + required this.accountInfoCubit, + required this.accountRecordCubit, + required this.contactInvitationListCubit, + required this.contactListCubit, + required this.waitingInvitationsBlocMapCubit, + required this.activeChatCubit, + required this.chatListCubit, + required this.activeConversationsBlocMapCubit, + required this.activeSingleContactChatBlocMapCubit}) + : super._(); + + @override + final AccountInfo accountInfo; + @override + final AsyncValue? avAccountRecordState; + @override + final AccountInfoCubit? accountInfoCubit; + @override + final AccountRecordCubit? accountRecordCubit; + @override + final ContactInvitationListCubit? contactInvitationListCubit; + @override + final ContactListCubit? contactListCubit; + @override + final WaitingInvitationsBlocMapCubit? waitingInvitationsBlocMapCubit; + @override + final ActiveChatCubit? activeChatCubit; + @override + final ChatListCubit? chatListCubit; + @override + final ActiveConversationsBlocMapCubit? activeConversationsBlocMapCubit; + @override + final ActiveSingleContactChatBlocMapCubit? + activeSingleContactChatBlocMapCubit; + + /// Create a copy of PerAccountCollectionState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$PerAccountCollectionStateCopyWith<_PerAccountCollectionState> + get copyWith => + __$PerAccountCollectionStateCopyWithImpl<_PerAccountCollectionState>( + this, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _PerAccountCollectionState && + (identical(other.accountInfo, accountInfo) || + other.accountInfo == accountInfo) && + (identical(other.avAccountRecordState, avAccountRecordState) || + other.avAccountRecordState == avAccountRecordState) && + (identical(other.accountInfoCubit, accountInfoCubit) || + other.accountInfoCubit == accountInfoCubit) && + (identical(other.accountRecordCubit, accountRecordCubit) || + other.accountRecordCubit == accountRecordCubit) && + (identical(other.contactInvitationListCubit, + contactInvitationListCubit) || + other.contactInvitationListCubit == + contactInvitationListCubit) && + (identical(other.contactListCubit, contactListCubit) || + other.contactListCubit == contactListCubit) && + (identical(other.waitingInvitationsBlocMapCubit, + waitingInvitationsBlocMapCubit) || + other.waitingInvitationsBlocMapCubit == + waitingInvitationsBlocMapCubit) && + (identical(other.activeChatCubit, activeChatCubit) || + other.activeChatCubit == activeChatCubit) && + (identical(other.chatListCubit, chatListCubit) || + other.chatListCubit == chatListCubit) && + (identical(other.activeConversationsBlocMapCubit, + activeConversationsBlocMapCubit) || + other.activeConversationsBlocMapCubit == + activeConversationsBlocMapCubit) && + (identical(other.activeSingleContactChatBlocMapCubit, + activeSingleContactChatBlocMapCubit) || + other.activeSingleContactChatBlocMapCubit == + activeSingleContactChatBlocMapCubit)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + accountInfo, + avAccountRecordState, + accountInfoCubit, + accountRecordCubit, + contactInvitationListCubit, + contactListCubit, + waitingInvitationsBlocMapCubit, + activeChatCubit, + chatListCubit, + activeConversationsBlocMapCubit, + activeSingleContactChatBlocMapCubit); + + @override + String toString() { + return 'PerAccountCollectionState(accountInfo: $accountInfo, avAccountRecordState: $avAccountRecordState, accountInfoCubit: $accountInfoCubit, accountRecordCubit: $accountRecordCubit, contactInvitationListCubit: $contactInvitationListCubit, contactListCubit: $contactListCubit, waitingInvitationsBlocMapCubit: $waitingInvitationsBlocMapCubit, activeChatCubit: $activeChatCubit, chatListCubit: $chatListCubit, activeConversationsBlocMapCubit: $activeConversationsBlocMapCubit, activeSingleContactChatBlocMapCubit: $activeSingleContactChatBlocMapCubit)'; + } +} + +/// @nodoc +abstract mixin class _$PerAccountCollectionStateCopyWith<$Res> + implements $PerAccountCollectionStateCopyWith<$Res> { + factory _$PerAccountCollectionStateCopyWith(_PerAccountCollectionState value, + $Res Function(_PerAccountCollectionState) _then) = + __$PerAccountCollectionStateCopyWithImpl; + @override + @useResult + $Res call( + {AccountInfo accountInfo, + AsyncValue? avAccountRecordState, + AccountInfoCubit? accountInfoCubit, + AccountRecordCubit? accountRecordCubit, + ContactInvitationListCubit? contactInvitationListCubit, + ContactListCubit? contactListCubit, + WaitingInvitationsBlocMapCubit? waitingInvitationsBlocMapCubit, + ActiveChatCubit? activeChatCubit, + ChatListCubit? chatListCubit, + ActiveConversationsBlocMapCubit? activeConversationsBlocMapCubit, + ActiveSingleContactChatBlocMapCubit? + activeSingleContactChatBlocMapCubit}); + + @override + $AsyncValueCopyWith? get avAccountRecordState; +} + +/// @nodoc +class __$PerAccountCollectionStateCopyWithImpl<$Res> + implements _$PerAccountCollectionStateCopyWith<$Res> { + __$PerAccountCollectionStateCopyWithImpl(this._self, this._then); + + final _PerAccountCollectionState _self; + final $Res Function(_PerAccountCollectionState) _then; + + /// Create a copy of PerAccountCollectionState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? accountInfo = null, + Object? avAccountRecordState = freezed, + Object? accountInfoCubit = freezed, + Object? accountRecordCubit = freezed, + Object? contactInvitationListCubit = freezed, + Object? contactListCubit = freezed, + Object? waitingInvitationsBlocMapCubit = freezed, + Object? activeChatCubit = freezed, + Object? chatListCubit = freezed, + Object? activeConversationsBlocMapCubit = freezed, + Object? activeSingleContactChatBlocMapCubit = freezed, + }) { + return _then(_PerAccountCollectionState( + accountInfo: null == accountInfo + ? _self.accountInfo + : accountInfo // ignore: cast_nullable_to_non_nullable + as AccountInfo, + avAccountRecordState: freezed == avAccountRecordState + ? _self.avAccountRecordState + : avAccountRecordState // ignore: cast_nullable_to_non_nullable + as AsyncValue?, + accountInfoCubit: freezed == accountInfoCubit + ? _self.accountInfoCubit + : accountInfoCubit // ignore: cast_nullable_to_non_nullable + as AccountInfoCubit?, + accountRecordCubit: freezed == accountRecordCubit + ? _self.accountRecordCubit + : accountRecordCubit // ignore: cast_nullable_to_non_nullable + as AccountRecordCubit?, + contactInvitationListCubit: freezed == contactInvitationListCubit + ? _self.contactInvitationListCubit + : contactInvitationListCubit // ignore: cast_nullable_to_non_nullable + as ContactInvitationListCubit?, + contactListCubit: freezed == contactListCubit + ? _self.contactListCubit + : contactListCubit // ignore: cast_nullable_to_non_nullable + as ContactListCubit?, + waitingInvitationsBlocMapCubit: freezed == waitingInvitationsBlocMapCubit + ? _self.waitingInvitationsBlocMapCubit + : waitingInvitationsBlocMapCubit // ignore: cast_nullable_to_non_nullable + as WaitingInvitationsBlocMapCubit?, + activeChatCubit: freezed == activeChatCubit + ? _self.activeChatCubit + : activeChatCubit // ignore: cast_nullable_to_non_nullable + as ActiveChatCubit?, + chatListCubit: freezed == chatListCubit + ? _self.chatListCubit + : chatListCubit // ignore: cast_nullable_to_non_nullable + as ChatListCubit?, + activeConversationsBlocMapCubit: freezed == + activeConversationsBlocMapCubit + ? _self.activeConversationsBlocMapCubit + : activeConversationsBlocMapCubit // ignore: cast_nullable_to_non_nullable + as ActiveConversationsBlocMapCubit?, + activeSingleContactChatBlocMapCubit: freezed == + activeSingleContactChatBlocMapCubit + ? _self.activeSingleContactChatBlocMapCubit + : activeSingleContactChatBlocMapCubit // ignore: cast_nullable_to_non_nullable + as ActiveSingleContactChatBlocMapCubit?, + )); + } + + /// Create a copy of PerAccountCollectionState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $AsyncValueCopyWith? get avAccountRecordState { + if (_self.avAccountRecordState == null) { + return null; + } + + return $AsyncValueCopyWith(_self.avAccountRecordState!, + (value) { + return _then(_self.copyWith(avAccountRecordState: value)); + }); + } +} + +// dart format on diff --git a/lib/account_manager/models/user_login/user_login.dart b/lib/account_manager/models/user_login/user_login.dart index 7c024cf..4e2f680 100644 --- a/lib/account_manager/models/user_login/user_login.dart +++ b/lib/account_manager/models/user_login/user_login.dart @@ -8,8 +8,9 @@ part 'user_login.g.dart'; // Represents a currently logged in account // User logins are stored in the user_logins tablestore table // indexed by the accountSuperIdentityRecordKey -@freezed -class UserLogin with _$UserLogin { +@Freezed(toJson: true) +sealed class UserLogin with _$UserLogin { + @JsonSerializable() const factory UserLogin({ // SuperIdentity record key for the user // used to index the local accounts table @@ -23,6 +24,13 @@ class UserLogin with _$UserLogin { required Timestamp lastActive, }) = _UserLogin; - factory UserLogin.fromJson(dynamic json) => - _$UserLoginFromJson(json as Map); + factory UserLogin.fromJson(dynamic json) { + try { + return _$UserLoginFromJson(json as Map); + // Need to catch any errors here too + // ignore: avoid_catches_without_on_clauses + } catch (e, st) { + throw Exception('invalid user login: $e\n$st'); + } + } } diff --git a/lib/account_manager/models/user_login/user_login.freezed.dart b/lib/account_manager/models/user_login/user_login.freezed.dart index c93ee7b..914afb8 100644 --- a/lib/account_manager/models/user_login/user_login.freezed.dart +++ b/lib/account_manager/models/user_login/user_login.freezed.dart @@ -1,3 +1,4 @@ +// dart format width=80 // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint @@ -9,185 +10,36 @@ part of 'user_login.dart'; // FreezedGenerator // ************************************************************************** +// dart format off T _$identity(T value) => value; -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -UserLogin _$UserLoginFromJson(Map json) { - return _UserLogin.fromJson(json); -} - /// @nodoc mixin _$UserLogin { // SuperIdentity record key for the user // used to index the local accounts table - Typed get superIdentityRecordKey => - throw _privateConstructorUsedError; // The identity secret as unlocked from the local accounts table - Typed get identitySecret => - throw _privateConstructorUsedError; // The account record key, owner key and secret pulled from the identity - AccountRecordInfo get accountRecordInfo => - throw _privateConstructorUsedError; // The time this login was most recently used - Timestamp get lastActive => throw _privateConstructorUsedError; + TypedKey + get superIdentityRecordKey; // The identity secret as unlocked from the local accounts table + TypedSecret + get identitySecret; // The account record key, owner key and secret pulled from the identity + AccountRecordInfo + get accountRecordInfo; // The time this login was most recently used + Timestamp get lastActive; - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) + /// Create a copy of UserLogin + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') $UserLoginCopyWith get copyWith => - throw _privateConstructorUsedError; -} + _$UserLoginCopyWithImpl(this as UserLogin, _$identity); -/// @nodoc -abstract class $UserLoginCopyWith<$Res> { - factory $UserLoginCopyWith(UserLogin value, $Res Function(UserLogin) then) = - _$UserLoginCopyWithImpl<$Res, UserLogin>; - @useResult - $Res call( - {Typed superIdentityRecordKey, - Typed identitySecret, - AccountRecordInfo accountRecordInfo, - Timestamp lastActive}); - - $AccountRecordInfoCopyWith<$Res> get accountRecordInfo; -} - -/// @nodoc -class _$UserLoginCopyWithImpl<$Res, $Val extends UserLogin> - implements $UserLoginCopyWith<$Res> { - _$UserLoginCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? superIdentityRecordKey = null, - Object? identitySecret = null, - Object? accountRecordInfo = null, - Object? lastActive = null, - }) { - return _then(_value.copyWith( - superIdentityRecordKey: null == superIdentityRecordKey - ? _value.superIdentityRecordKey - : superIdentityRecordKey // ignore: cast_nullable_to_non_nullable - as Typed, - identitySecret: null == identitySecret - ? _value.identitySecret - : identitySecret // ignore: cast_nullable_to_non_nullable - as Typed, - accountRecordInfo: null == accountRecordInfo - ? _value.accountRecordInfo - : accountRecordInfo // ignore: cast_nullable_to_non_nullable - as AccountRecordInfo, - lastActive: null == lastActive - ? _value.lastActive - : lastActive // ignore: cast_nullable_to_non_nullable - as Timestamp, - ) as $Val); - } - - @override - @pragma('vm:prefer-inline') - $AccountRecordInfoCopyWith<$Res> get accountRecordInfo { - return $AccountRecordInfoCopyWith<$Res>(_value.accountRecordInfo, (value) { - return _then(_value.copyWith(accountRecordInfo: value) as $Val); - }); - } -} - -/// @nodoc -abstract class _$$UserLoginImplCopyWith<$Res> - implements $UserLoginCopyWith<$Res> { - factory _$$UserLoginImplCopyWith( - _$UserLoginImpl value, $Res Function(_$UserLoginImpl) then) = - __$$UserLoginImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {Typed superIdentityRecordKey, - Typed identitySecret, - AccountRecordInfo accountRecordInfo, - Timestamp lastActive}); - - @override - $AccountRecordInfoCopyWith<$Res> get accountRecordInfo; -} - -/// @nodoc -class __$$UserLoginImplCopyWithImpl<$Res> - extends _$UserLoginCopyWithImpl<$Res, _$UserLoginImpl> - implements _$$UserLoginImplCopyWith<$Res> { - __$$UserLoginImplCopyWithImpl( - _$UserLoginImpl _value, $Res Function(_$UserLoginImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? superIdentityRecordKey = null, - Object? identitySecret = null, - Object? accountRecordInfo = null, - Object? lastActive = null, - }) { - return _then(_$UserLoginImpl( - superIdentityRecordKey: null == superIdentityRecordKey - ? _value.superIdentityRecordKey - : superIdentityRecordKey // ignore: cast_nullable_to_non_nullable - as Typed, - identitySecret: null == identitySecret - ? _value.identitySecret - : identitySecret // ignore: cast_nullable_to_non_nullable - as Typed, - accountRecordInfo: null == accountRecordInfo - ? _value.accountRecordInfo - : accountRecordInfo // ignore: cast_nullable_to_non_nullable - as AccountRecordInfo, - lastActive: null == lastActive - ? _value.lastActive - : lastActive // ignore: cast_nullable_to_non_nullable - as Timestamp, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$UserLoginImpl implements _UserLogin { - const _$UserLoginImpl( - {required this.superIdentityRecordKey, - required this.identitySecret, - required this.accountRecordInfo, - required this.lastActive}); - - factory _$UserLoginImpl.fromJson(Map json) => - _$$UserLoginImplFromJson(json); - -// SuperIdentity record key for the user -// used to index the local accounts table - @override - final Typed superIdentityRecordKey; -// The identity secret as unlocked from the local accounts table - @override - final Typed identitySecret; -// The account record key, owner key and secret pulled from the identity - @override - final AccountRecordInfo accountRecordInfo; -// The time this login was most recently used - @override - final Timestamp lastActive; - - @override - String toString() { - return 'UserLogin(superIdentityRecordKey: $superIdentityRecordKey, identitySecret: $identitySecret, accountRecordInfo: $accountRecordInfo, lastActive: $lastActive)'; - } + /// Serializes this UserLogin to a JSON map. + Map toJson(); @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$UserLoginImpl && + other is UserLogin && (identical(other.superIdentityRecordKey, superIdentityRecordKey) || other.superIdentityRecordKey == superIdentityRecordKey) && (identical(other.identitySecret, identitySecret) || @@ -198,46 +50,208 @@ class _$UserLoginImpl implements _UserLogin { other.lastActive == lastActive)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType, superIdentityRecordKey, identitySecret, accountRecordInfo, lastActive); - @JsonKey(ignore: true) @override - @pragma('vm:prefer-inline') - _$$UserLoginImplCopyWith<_$UserLoginImpl> get copyWith => - __$$UserLoginImplCopyWithImpl<_$UserLoginImpl>(this, _$identity); - - @override - Map toJson() { - return _$$UserLoginImplToJson( - this, - ); + String toString() { + return 'UserLogin(superIdentityRecordKey: $superIdentityRecordKey, identitySecret: $identitySecret, accountRecordInfo: $accountRecordInfo, lastActive: $lastActive)'; } } -abstract class _UserLogin implements UserLogin { - const factory _UserLogin( - {required final Typed superIdentityRecordKey, - required final Typed identitySecret, - required final AccountRecordInfo accountRecordInfo, - required final Timestamp lastActive}) = _$UserLoginImpl; +/// @nodoc +abstract mixin class $UserLoginCopyWith<$Res> { + factory $UserLoginCopyWith(UserLogin value, $Res Function(UserLogin) _then) = + _$UserLoginCopyWithImpl; + @useResult + $Res call( + {TypedKey superIdentityRecordKey, + TypedSecret identitySecret, + AccountRecordInfo accountRecordInfo, + Timestamp lastActive}); - factory _UserLogin.fromJson(Map json) = - _$UserLoginImpl.fromJson; - - @override // SuperIdentity record key for the user -// used to index the local accounts table - Typed get superIdentityRecordKey; - @override // The identity secret as unlocked from the local accounts table - Typed get identitySecret; - @override // The account record key, owner key and secret pulled from the identity - AccountRecordInfo get accountRecordInfo; - @override // The time this login was most recently used - Timestamp get lastActive; - @override - @JsonKey(ignore: true) - _$$UserLoginImplCopyWith<_$UserLoginImpl> get copyWith => - throw _privateConstructorUsedError; + $AccountRecordInfoCopyWith<$Res> get accountRecordInfo; } + +/// @nodoc +class _$UserLoginCopyWithImpl<$Res> implements $UserLoginCopyWith<$Res> { + _$UserLoginCopyWithImpl(this._self, this._then); + + final UserLogin _self; + final $Res Function(UserLogin) _then; + + /// Create a copy of UserLogin + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? superIdentityRecordKey = null, + Object? identitySecret = null, + Object? accountRecordInfo = null, + Object? lastActive = null, + }) { + return _then(_self.copyWith( + superIdentityRecordKey: null == superIdentityRecordKey + ? _self.superIdentityRecordKey + : superIdentityRecordKey // ignore: cast_nullable_to_non_nullable + as TypedKey, + identitySecret: null == identitySecret + ? _self.identitySecret + : identitySecret // ignore: cast_nullable_to_non_nullable + as TypedSecret, + accountRecordInfo: null == accountRecordInfo + ? _self.accountRecordInfo + : accountRecordInfo // ignore: cast_nullable_to_non_nullable + as AccountRecordInfo, + lastActive: null == lastActive + ? _self.lastActive + : lastActive // ignore: cast_nullable_to_non_nullable + as Timestamp, + )); + } + + /// Create a copy of UserLogin + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $AccountRecordInfoCopyWith<$Res> get accountRecordInfo { + return $AccountRecordInfoCopyWith<$Res>(_self.accountRecordInfo, (value) { + return _then(_self.copyWith(accountRecordInfo: value)); + }); + } +} + +/// @nodoc + +@JsonSerializable() +class _UserLogin implements UserLogin { + const _UserLogin( + {required this.superIdentityRecordKey, + required this.identitySecret, + required this.accountRecordInfo, + required this.lastActive}); + +// SuperIdentity record key for the user +// used to index the local accounts table + @override + final TypedKey superIdentityRecordKey; +// The identity secret as unlocked from the local accounts table + @override + final TypedSecret identitySecret; +// The account record key, owner key and secret pulled from the identity + @override + final AccountRecordInfo accountRecordInfo; +// The time this login was most recently used + @override + final Timestamp lastActive; + + /// Create a copy of UserLogin + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$UserLoginCopyWith<_UserLogin> get copyWith => + __$UserLoginCopyWithImpl<_UserLogin>(this, _$identity); + + @override + Map toJson() { + return _$UserLoginToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _UserLogin && + (identical(other.superIdentityRecordKey, superIdentityRecordKey) || + other.superIdentityRecordKey == superIdentityRecordKey) && + (identical(other.identitySecret, identitySecret) || + other.identitySecret == identitySecret) && + (identical(other.accountRecordInfo, accountRecordInfo) || + other.accountRecordInfo == accountRecordInfo) && + (identical(other.lastActive, lastActive) || + other.lastActive == lastActive)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, superIdentityRecordKey, + identitySecret, accountRecordInfo, lastActive); + + @override + String toString() { + return 'UserLogin(superIdentityRecordKey: $superIdentityRecordKey, identitySecret: $identitySecret, accountRecordInfo: $accountRecordInfo, lastActive: $lastActive)'; + } +} + +/// @nodoc +abstract mixin class _$UserLoginCopyWith<$Res> + implements $UserLoginCopyWith<$Res> { + factory _$UserLoginCopyWith( + _UserLogin value, $Res Function(_UserLogin) _then) = + __$UserLoginCopyWithImpl; + @override + @useResult + $Res call( + {TypedKey superIdentityRecordKey, + TypedSecret identitySecret, + AccountRecordInfo accountRecordInfo, + Timestamp lastActive}); + + @override + $AccountRecordInfoCopyWith<$Res> get accountRecordInfo; +} + +/// @nodoc +class __$UserLoginCopyWithImpl<$Res> implements _$UserLoginCopyWith<$Res> { + __$UserLoginCopyWithImpl(this._self, this._then); + + final _UserLogin _self; + final $Res Function(_UserLogin) _then; + + /// Create a copy of UserLogin + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? superIdentityRecordKey = null, + Object? identitySecret = null, + Object? accountRecordInfo = null, + Object? lastActive = null, + }) { + return _then(_UserLogin( + superIdentityRecordKey: null == superIdentityRecordKey + ? _self.superIdentityRecordKey + : superIdentityRecordKey // ignore: cast_nullable_to_non_nullable + as TypedKey, + identitySecret: null == identitySecret + ? _self.identitySecret + : identitySecret // ignore: cast_nullable_to_non_nullable + as TypedSecret, + accountRecordInfo: null == accountRecordInfo + ? _self.accountRecordInfo + : accountRecordInfo // ignore: cast_nullable_to_non_nullable + as AccountRecordInfo, + lastActive: null == lastActive + ? _self.lastActive + : lastActive // ignore: cast_nullable_to_non_nullable + as Timestamp, + )); + } + + /// Create a copy of UserLogin + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $AccountRecordInfoCopyWith<$Res> get accountRecordInfo { + return $AccountRecordInfoCopyWith<$Res>(_self.accountRecordInfo, (value) { + return _then(_self.copyWith(accountRecordInfo: value)); + }); + } +} + +// dart format on diff --git a/lib/account_manager/models/user_login/user_login.g.dart b/lib/account_manager/models/user_login/user_login.g.dart index 173d853..fa5314b 100644 --- a/lib/account_manager/models/user_login/user_login.g.dart +++ b/lib/account_manager/models/user_login/user_login.g.dart @@ -6,8 +6,7 @@ part of 'user_login.dart'; // JsonSerializableGenerator // ************************************************************************** -_$UserLoginImpl _$$UserLoginImplFromJson(Map json) => - _$UserLoginImpl( +_UserLogin _$UserLoginFromJson(Map json) => _UserLogin( superIdentityRecordKey: Typed.fromJson( json['super_identity_record_key']), identitySecret: @@ -17,7 +16,7 @@ _$UserLoginImpl _$$UserLoginImplFromJson(Map json) => lastActive: Timestamp.fromJson(json['last_active']), ); -Map _$$UserLoginImplToJson(_$UserLoginImpl instance) => +Map _$UserLoginToJson(_UserLogin instance) => { 'super_identity_record_key': instance.superIdentityRecordKey.toJson(), 'identity_secret': instance.identitySecret.toJson(), diff --git a/lib/account_manager/repository/account_repository/account_repository.dart b/lib/account_manager/repository/account_repository.dart similarity index 77% rename from lib/account_manager/repository/account_repository/account_repository.dart rename to lib/account_manager/repository/account_repository.dart index ac29913..c5058ba 100644 --- a/lib/account_manager/repository/account_repository/account_repository.dart +++ b/lib/account_manager/repository/account_repository.dart @@ -3,11 +3,11 @@ import 'dart:async'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:veilid_support/veilid_support.dart'; -import '../../../../proto/proto.dart' as proto; -import '../../../tools/tools.dart'; -import '../../models/models.dart'; +import '../../../proto/proto.dart' as proto; +import '../../tools/tools.dart'; +import '../models/models.dart'; -const String veilidChatAccountKey = 'com.veilid.veilidchat'; +const String veilidChatApplicationId = 'com.veilid.veilidchat'; enum AccountRepositoryChange { localAccounts, userLogins, activeLocalAccount } @@ -45,19 +45,6 @@ class AccountRepository { valueToJson: (val) => val?.toJson(), makeInitialValue: () => null); - ////////////////////////////////////////////////////////////// - /// Fields - - final TableDBValue> _localAccounts; - final TableDBValue> _userLogins; - final TableDBValue _activeLocalAccount; - final StreamController _streamController; - - ////////////////////////////////////////////////////////////// - /// Singleton initialization - - static AccountRepository instance = AccountRepository._(); - Future init() async { await _localAccounts.get(); await _userLogins.get(); @@ -71,12 +58,10 @@ class AccountRepository { } ////////////////////////////////////////////////////////////// - /// Streams - + /// Public Interface + /// Stream get stream => _streamController.stream; - ////////////////////////////////////////////////////////////// - /// Selectors IList getLocalAccounts() => _localAccounts.value; TypedKey? getActiveLocalAccount() => _activeLocalAccount.value; IList getUserLogins() => _userLogins.value; @@ -107,29 +92,11 @@ class AccountRepository { return userLogins[idx]; } - AccountInfo getAccountInfo(TypedKey? superIdentityRecordKey) { - // Get active account if we have one - final activeLocalAccount = getActiveLocalAccount(); - if (superIdentityRecordKey == null) { - if (activeLocalAccount == null) { - // No user logged in - return const AccountInfo( - status: AccountInfoStatus.noAccount, - active: false, - activeAccountInfo: null); - } - superIdentityRecordKey = activeLocalAccount; - } - final active = superIdentityRecordKey == activeLocalAccount; - + AccountInfo? getAccountInfo(TypedKey superIdentityRecordKey) { // Get which local account we want to fetch the profile for final localAccount = fetchLocalAccount(superIdentityRecordKey); if (localAccount == null) { - // account does not exist - return AccountInfo( - status: AccountInfoStatus.noAccount, - active: active, - activeAccountInfo: null); + return null; } // See if we've logged into this account or if it is locked @@ -137,23 +104,20 @@ class AccountRepository { if (userLogin == null) { // Account was locked return AccountInfo( - status: AccountInfoStatus.accountLocked, - active: active, - activeAccountInfo: null); + status: AccountInfoStatus.accountLocked, + localAccount: localAccount, + userLogin: null, + ); } // Got account, decrypted and decoded return AccountInfo( - status: AccountInfoStatus.accountReady, - active: active, - activeAccountInfo: - ActiveAccountInfo(localAccount: localAccount, userLogin: userLogin), + status: AccountInfoStatus.accountUnlocked, + localAccount: localAccount, + userLogin: userLogin, ); } - ////////////////////////////////////////////////////////////// - /// Mutators - /// Reorder accounts Future reorderAccount(int oldIndex, int newIndex) async { final localAccounts = await _localAccounts.get(); @@ -168,31 +132,121 @@ class AccountRepository { /// Creates a new super identity, an identity instance, an account associated /// with the identity instance, stores the account in the identity key and /// then logs into that account with no password set at this time - Future createWithNewSuperIdentity(NewProfileSpec newProfileSpec) async { + Future createWithNewSuperIdentity( + AccountSpec accountSpec) async { log.debug('Creating super identity'); final wsi = await WritableSuperIdentity.create(); try { final localAccount = await _newLocalAccount( superIdentity: wsi.superIdentity, identitySecret: wsi.identitySecret, - newProfileSpec: newProfileSpec); + accountSpec: accountSpec); // Log in the new account by default with no pin final ok = await login( localAccount.superIdentity.recordKey, EncryptionKeyType.none, ''); assert(ok, 'login with none should never fail'); + + return wsi; } on Exception catch (_) { await wsi.delete(); rethrow; } } + Future updateLocalAccount( + TypedKey superIdentityRecordKey, AccountSpec accountSpec) async { + final localAccounts = await _localAccounts.get(); + + final newLocalAccounts = localAccounts.replaceFirstWhere( + (x) => x.superIdentity.recordKey == superIdentityRecordKey, + (localAccount) => localAccount!.copyWith(name: accountSpec.name)); + + await _localAccounts.set(newLocalAccounts); + _streamController.add(AccountRepositoryChange.localAccounts); + } + + /// Remove an account and wipe the messages for this account from this device + Future deleteLocalAccount(TypedKey superIdentityRecordKey, + OwnedDHTRecordPointer? accountRecord) async { + // Delete the account record locally which causes a deep delete + // of all the contacts, invites, chats, and messages in the dht record + // pool + if (accountRecord != null) { + await DHTRecordPool.instance.deleteRecord(accountRecord.recordKey); + } + + await logout(superIdentityRecordKey); + + final localAccounts = await _localAccounts.get(); + final newLocalAccounts = localAccounts.removeWhere( + (la) => la.superIdentity.recordKey == superIdentityRecordKey); + + await _localAccounts.set(newLocalAccounts); + _streamController.add(AccountRepositoryChange.localAccounts); + + return true; + } + + /// Import an account from another VeilidChat instance + + /// Recover an account with the master identity secret + + /// Delete an account from all devices + Future destroyAccount(TypedKey superIdentityRecordKey, + OwnedDHTRecordPointer accountRecord) async { + // Get which local account we want to fetch the profile for + final localAccount = fetchLocalAccount(superIdentityRecordKey); + if (localAccount == null) { + return false; + } + + // See if we've logged into this account or if it is locked + final userLogin = fetchUserLogin(superIdentityRecordKey); + if (userLogin == null) { + return false; + } + + final success = await localAccount.superIdentity.currentInstance + .removeAccount( + superRecordKey: localAccount.superIdentity.recordKey, + secretKey: userLogin.identitySecret.value, + applicationId: veilidChatApplicationId, + removeAccountCallback: (accountRecordInfos) async => + accountRecordInfos.singleOrNull); + if (!success) { + return false; + } + + return deleteLocalAccount(superIdentityRecordKey, accountRecord); + } + + Future switchToAccount(TypedKey? superIdentityRecordKey) async { + final activeLocalAccount = await _activeLocalAccount.get(); + + if (activeLocalAccount == superIdentityRecordKey) { + // Nothing to do + return; + } + + if (superIdentityRecordKey != null) { + // Assert the specified record key can be found, will throw if not + final _ = _userLogins.value.firstWhere( + (ul) => ul.superIdentityRecordKey == superIdentityRecordKey); + } + await _activeLocalAccount.set(superIdentityRecordKey); + _streamController.add(AccountRepositoryChange.activeLocalAccount); + } + + ////////////////////////////////////////////////////////////// + /// Internal Implementation + /// Creates a new Account associated with the current instance of the identity /// Adds a logged-out LocalAccount to track its existence on this device Future _newLocalAccount( {required SuperIdentity superIdentity, required SecretKey identitySecret, - required NewProfileSpec newProfileSpec, + required AccountSpec accountSpec, EncryptionKeyType encryptionKeyType = EncryptionKeyType.none, String encryptionKey = ''}) async { log.debug('Creating new local account'); @@ -203,7 +257,7 @@ class AccountRepository { await superIdentity.currentInstance.addAccount( superRecordKey: superIdentity.recordKey, secretKey: identitySecret, - accountKey: veilidChatAccountKey, + applicationId: veilidChatApplicationId, createAccountCallback: (parent) async { // Make empty contact list log.debug('Creating contacts list'); @@ -227,14 +281,33 @@ class AccountRepository { parent: parent)) .scope((r) async => r.recordPointer); + final groupChatRecords = await (await DHTShortArray.create( + debugName: 'AccountRepository::_newLocalAccount::GroupChats', + parent: parent)) + .scope((r) async => r.recordPointer); + // Make account object + final profile = proto.Profile() + ..name = accountSpec.name + ..pronouns = accountSpec.pronouns + ..about = accountSpec.about + ..status = accountSpec.status + ..availability = accountSpec.availability + ..timestamp = Veilid.instance.now().toInt64(); + final account = proto.Account() - ..profile = (proto.Profile() - ..name = newProfileSpec.name - ..pronouns = newProfileSpec.pronouns) + ..profile = profile + ..invisible = accountSpec.invisible + ..autoAwayTimeoutMin = accountSpec.autoAwayTimeout ..contactList = contactList.toProto() ..contactInvitationRecords = contactInvitationRecords.toProto() - ..chatList = chatRecords.toProto(); + ..chatList = chatRecords.toProto() + ..groupChatList = groupChatRecords.toProto() + ..freeMessage = accountSpec.freeMessage + ..awayMessage = accountSpec.awayMessage + ..busyMessage = accountSpec.busyMessage + ..autodetectAway = accountSpec.autoAway; + return account.writeToBuffer(); }); @@ -255,7 +328,7 @@ class AccountRepository { encryptionKeyType: encryptionKeyType, biometricsEnabled: false, hiddenAccount: false, - name: newProfileSpec.name, + name: accountSpec.name, ); // Add local account object to internal store @@ -268,45 +341,6 @@ class AccountRepository { return localAccount; } - /// Remove an account and wipe the messages for this account from this device - Future deleteLocalAccount(TypedKey superIdentityRecordKey) async { - await logout(superIdentityRecordKey); - - final localAccounts = await _localAccounts.get(); - final newLocalAccounts = localAccounts.removeWhere( - (la) => la.superIdentity.recordKey == superIdentityRecordKey); - - await _localAccounts.set(newLocalAccounts); - _streamController.add(AccountRepositoryChange.localAccounts); - - // TO DO: wipe messages - - return true; - } - - /// Import an account from another VeilidChat instance - - /// Recover an account with the master identity secret - - /// Delete an account from all devices - - Future switchToAccount(TypedKey? superIdentityRecordKey) async { - final activeLocalAccount = await _activeLocalAccount.get(); - - if (activeLocalAccount == superIdentityRecordKey) { - // Nothing to do - return; - } - - if (superIdentityRecordKey != null) { - // Assert the specified record key can be found, will throw if not - final _ = _userLogins.value.firstWhere( - (ul) => ul.superIdentityRecordKey == superIdentityRecordKey); - } - await _activeLocalAccount.set(superIdentityRecordKey); - _streamController.add(AccountRepositoryChange.activeLocalAccount); - } - Future _decryptedLogin( SuperIdentity superIdentity, SecretKey identitySecret) async { // Verify identity secret works and return the valid cryptosystem @@ -318,7 +352,7 @@ class AccountRepository { .readAccount( superRecordKey: superIdentity.recordKey, secretKey: identitySecret, - accountKey: veilidChatAccountKey); + applicationId: veilidChatApplicationId); if (accountRecordInfoList.length > 1) { throw IdentityException.limitExceeded; } else if (accountRecordInfoList.isEmpty) { @@ -386,6 +420,11 @@ class AccountRepository { return; } + if (logoutUser == activeLocalAccount) { + await switchToAccount( + _localAccounts.value.firstOrNull?.superIdentity.recordKey); + } + final logoutUserLogin = fetchUserLogin(logoutUser); if (logoutUserLogin == null) { // Already logged out @@ -399,16 +438,13 @@ class AccountRepository { _streamController.add(AccountRepositoryChange.userLogins); } - Future openAccountRecord(UserLogin userLogin) async { - final localAccount = fetchLocalAccount(userLogin.superIdentityRecordKey)!; + ////////////////////////////////////////////////////////////// + /// Fields - // Record not yet open, do it - final pool = DHTRecordPool.instance; - final record = await pool.openRecordOwned( - userLogin.accountRecordInfo.accountRecord, - debugName: 'AccountRepository::openAccountRecord::AccountRecord', - parent: localAccount.superIdentity.currentInstance.recordKey); + static AccountRepository instance = AccountRepository._(); - return record; - } + final TableDBValue> _localAccounts; + final TableDBValue> _userLogins; + final TableDBValue _activeLocalAccount; + final StreamController _streamController; } diff --git a/lib/account_manager/repository/repository.dart b/lib/account_manager/repository/repository.dart index 9d1b9fe..74bf9f8 100644 --- a/lib/account_manager/repository/repository.dart +++ b/lib/account_manager/repository/repository.dart @@ -1 +1 @@ -export 'account_repository/account_repository.dart'; +export 'account_repository.dart'; diff --git a/lib/account_manager/views/edit_account_page.dart b/lib/account_manager/views/edit_account_page.dart new file mode 100644 index 0000000..5622c1f --- /dev/null +++ b/lib/account_manager/views/edit_account_page.dart @@ -0,0 +1,325 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:go_router/go_router.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../layout/default_app_bar.dart'; +import '../../notifications/notifications.dart'; +import '../../theme/theme.dart'; +import '../../tools/tools.dart'; +import '../../veilid_processor/veilid_processor.dart'; +import '../account_manager.dart'; +import 'edit_profile_form.dart'; + +const _kDoBackArrow = 'doBackArrow'; + +class EditAccountPage extends StatefulWidget { + const EditAccountPage( + {required this.superIdentityRecordKey, + required this.initialValue, + required this.accountRecord, + super.key}); + + @override + State createState() => _EditAccountPageState(); + + final TypedKey superIdentityRecordKey; + final AccountSpec initialValue; + final OwnedDHTRecordPointer accountRecord; + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty( + 'superIdentityRecordKey', superIdentityRecordKey)) + ..add(DiagnosticsProperty('initialValue', initialValue)) + ..add(DiagnosticsProperty( + 'accountRecord', accountRecord)); + } +} + +class _EditAccountPageState extends WindowSetupState { + _EditAccountPageState() + : super( + titleBarStyle: TitleBarStyle.normal, + orientationCapability: OrientationCapability.portraitOnly); + + EditProfileForm _editAccountForm(BuildContext context) => EditProfileForm( + header: translate('edit_account_page.header'), + instructions: translate('edit_account_page.instructions'), + submitText: translate('button.update'), + submitDisabledText: translate('button.waiting_for_network'), + onSubmit: _onSubmit, + onModifiedState: _onModifiedState, + initialValue: widget.initialValue, + ); + + Future _onRemoveAccount() async { + // dismiss the keyboard by unfocusing the textfield + FocusScope.of(context).unfocus(); + await asyncSleep(const Duration(milliseconds: 250)); + if (!mounted) { + return; + } + + final confirmed = await StyledDialog.show( + context: context, + title: translate('edit_account_page.remove_account_confirm'), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Text(translate('edit_account_page.remove_account_confirm_message')) + .paddingLTRB(24.scaled(context), 24.scaled(context), + 24.scaled(context), 0), + Text(translate('confirmation.are_you_sure')) + .paddingAll(8.scaled(context)), + Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.cancel, size: 16.scaled(context)) + .paddingLTRB(0, 0, 4.scaled(context), 0), + Text(translate('button.no')).paddingLTRB(0, 0, 4, 0) + ])), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.check, size: 16.scaled(context)) + .paddingLTRB(0, 0, 4.scaled(context), 0), + Text(translate('button.yes')) + .paddingLTRB(0, 0, 4.scaled(context), 0) + ])) + ]).paddingAll(24.scaled(context)) + ])); + if (confirmed != null && confirmed) { + try { + setState(() { + _isInAsyncCall = true; + }); + try { + final success = await AccountRepository.instance.deleteLocalAccount( + widget.superIdentityRecordKey, widget.accountRecord); + if (mounted) { + if (success) { + context + .read() + .info(text: translate('edit_account_page.account_removed')); + GoRouterHelper(context).pop(); + } else { + context.read().error( + title: translate('edit_account_page.failed_to_remove_title'), + text: translate('edit_account_page.try_again_network')); + } + } + } finally { + setState(() { + _isInAsyncCall = false; + }); + } + } on Exception catch (e, st) { + if (mounted) { + await showErrorStacktraceModal( + context: context, error: e, stackTrace: st); + } + } + } + } + + Future _onDestroyAccount() async { + // dismiss the keyboard by unfocusing the textfield + FocusScope.of(context).unfocus(); + await asyncSleep(const Duration(milliseconds: 250)); + if (!mounted) { + return; + } + + final confirmed = await StyledDialog.show( + context: context, + title: translate('edit_account_page.destroy_account_confirm'), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Text(translate('edit_account_page.destroy_account_confirm_message')) + .paddingLTRB(24.scaled(context), 24.scaled(context), + 24.scaled(context), 0), + Text(translate( + 'edit_account_page.destroy_account_confirm_message_details')) + .paddingLTRB(24.scaled(context), 24.scaled(context), + 24.scaled(context), 0), + Text(translate('confirmation.are_you_sure')) + .paddingAll(24.scaled(context)), + Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.cancel, size: 16.scaled(context)) + .paddingLTRB(0, 0, 4.scaled(context), 0), + Text(translate('button.no')) + .paddingLTRB(0, 0, 4.scaled(context), 0) + ])), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.check, size: 16.scaled(context)) + .paddingLTRB(0, 0, 4.scaled(context), 0), + Text(translate('button.yes')) + .paddingLTRB(0, 0, 4.scaled(context), 0) + ])) + ]).paddingAll(24.scaled(context)) + ])); + if (confirmed != null && confirmed) { + try { + setState(() { + _isInAsyncCall = true; + }); + try { + final success = await AccountRepository.instance.destroyAccount( + widget.superIdentityRecordKey, widget.accountRecord); + if (mounted) { + if (success) { + context + .read() + .info(text: translate('edit_account_page.account_destroyed')); + GoRouterHelper(context).pop(); + } else { + context.read().error( + title: translate('edit_account_page.failed_to_destroy_title'), + text: translate('edit_account_page.try_again_network')); + } + } + } finally { + setState(() { + _isInAsyncCall = false; + }); + } + } on Exception catch (e, st) { + if (mounted) { + await showErrorStacktraceModal( + context: context, error: e, stackTrace: st); + } + } + } + } + + void _onModifiedState(bool isModified) { + setState(() { + _isModified = isModified; + }); + } + + Future _onSubmit(AccountSpec accountSpec) async { + try { + setState(() { + _isInAsyncCall = true; + }); + try { + // Look up account cubit for this specific account + final perAccountCollectionBlocMapCubit = + context.read(); + final accountRecordCubit = perAccountCollectionBlocMapCubit + .entry(widget.superIdentityRecordKey) + ?.accountRecordCubit; + if (accountRecordCubit == null) { + return false; + } + + // Update account profile DHT record + // This triggers ConversationCubits to update + accountRecordCubit.updateAccount(accountSpec, () async { + // Update local account profile + await AccountRepository.instance + .updateLocalAccount(widget.superIdentityRecordKey, accountSpec); + }); + + return true; + } finally { + setState(() { + _isInAsyncCall = false; + }); + } + } on Exception catch (e, st) { + if (mounted) { + await showErrorStacktraceModal( + context: context, error: e, stackTrace: st); + } + } + return false; + } + + @override + Widget build(BuildContext context) { + final displayModalHUD = _isInAsyncCall; + + return StyledScaffold( + appBar: DefaultAppBar( + context: context, + title: Text(translate('edit_account_page.titlebar')), + leading: Navigator.canPop(context) + ? IconButton( + icon: const Icon(Icons.arrow_back), + iconSize: 24.scaled(context), + onPressed: () { + singleFuture((this, _kDoBackArrow), () async { + if (_isModified) { + final ok = await showConfirmModal( + context: context, + title: + translate('confirmation.discard_changes'), + text: translate( + 'confirmation.are_you_sure_discard')); + if (!ok) { + return; + } + } + if (context.mounted) { + Navigator.pop(context); + } + }); + }) + : null, + actions: [ + const SignalStrengthMeterWidget(), + IconButton( + icon: const Icon(Icons.settings), + iconSize: 24.scaled(context), + tooltip: translate('menu.settings_tooltip'), + onPressed: () async { + await GoRouterHelper(context).push('/settings'); + }) + ]), + body: SingleChildScrollView( + child: Column(children: [ + _editAccountForm(context).paddingLTRB(0, 0, 0, 32), + StyledButtonBox( + instructions: + translate('edit_account_page.remove_account_description'), + buttonIcon: Icons.person_remove_alt_1, + buttonText: translate('edit_account_page.remove_account'), + onClick: _onRemoveAccount, + ), + StyledButtonBox( + instructions: + translate('edit_account_page.destroy_account_description'), + buttonIcon: Icons.person_off, + buttonText: translate('edit_account_page.destroy_account'), + onClick: _onDestroyAccount, + ) + ]).paddingSymmetric(horizontal: 24, vertical: 8))) + .withModalHUD(context, displayModalHUD); + } + + //////////////////////////////////////////////////////////////////////////// + + bool _isInAsyncCall = false; + bool _isModified = false; +} diff --git a/lib/account_manager/views/edit_profile_form.dart b/lib/account_manager/views/edit_profile_form.dart new file mode 100644 index 0000000..c4f84da --- /dev/null +++ b/lib/account_manager/views/edit_profile_form.dart @@ -0,0 +1,363 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; + +import '../../contacts/contacts.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import '../../veilid_processor/veilid_processor.dart'; +import '../models/models.dart'; + +const _kDoSubmitEditProfile = 'doSubmitEditProfile'; + +class EditProfileForm extends StatefulWidget { + const EditProfileForm({ + required this.header, + required this.instructions, + required this.submitText, + required this.submitDisabledText, + required this.initialValue, + required this.onSubmit, + this.onModifiedState, + super.key, + }); + + @override + State createState() => _EditProfileFormState(); + + final String header; + final String instructions; + final Future Function(AccountSpec) onSubmit; + final void Function(bool)? onModifiedState; + final String submitText; + final String submitDisabledText; + final AccountSpec initialValue; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(StringProperty('header', header)) + ..add(StringProperty('instructions', instructions)) + ..add(StringProperty('submitText', submitText)) + ..add(StringProperty('submitDisabledText', submitDisabledText)) + ..add(ObjectFlagProperty Function(AccountSpec)>.has( + 'onSubmit', onSubmit)) + ..add(ObjectFlagProperty.has( + 'onModifiedState', onModifiedState)) + ..add(DiagnosticsProperty('initialValue', initialValue)); + } + + static const formFieldName = 'name'; + static const formFieldPronouns = 'pronouns'; + static const formFieldAbout = 'about'; + static const formFieldAvailability = 'availability'; + static const formFieldFreeMessage = 'free_message'; + static const formFieldAwayMessage = 'away_message'; + static const formFieldBusyMessage = 'busy_message'; + static const formFieldAvatar = 'avatar'; + static const formFieldAutoAway = 'auto_away'; + static const formFieldAutoAwayTimeout = 'auto_away_timeout'; +} + +class _EditProfileFormState extends State { + final _formKey = GlobalKey(); + + @override + void initState() { + _savedValue = widget.initialValue; + _currentValueName = widget.initialValue.name; + _currentValueAutoAway = widget.initialValue.autoAway; + + super.initState(); + } + + FormBuilderDropdown _availabilityDropDown( + BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + + final initialValue = + _savedValue.availability == proto.Availability.AVAILABILITY_UNSPECIFIED + ? proto.Availability.AVAILABILITY_FREE + : _savedValue.availability; + + final availabilities = [ + proto.Availability.AVAILABILITY_FREE, + proto.Availability.AVAILABILITY_AWAY, + proto.Availability.AVAILABILITY_BUSY, + proto.Availability.AVAILABILITY_OFFLINE, + ]; + + return FormBuilderDropdown( + name: EditProfileForm.formFieldAvailability, + initialValue: initialValue, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8).scaled(context), + floatingLabelBehavior: FloatingLabelBehavior.always, + labelText: translate('account.form_availability'), + hintText: translate('account.empty_busy_message')), + items: availabilities + .map((availability) => DropdownMenuItem( + value: availability, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + AvailabilityWidget.availabilityIcon( + context, availability, scale.primaryScale.appText), + Text(availability == proto.Availability.AVAILABILITY_OFFLINE + ? translate('availability.always_show_offline') + : AvailabilityWidget.availabilityName(availability)) + .paddingLTRB(8.scaled(context), 0, 0, 0), + ]))) + .toList(), + ); + } + + AccountSpec _makeAccountSpec() { + final name = _formKey + .currentState!.fields[EditProfileForm.formFieldName]!.value as String; + final pronouns = _formKey.currentState! + .fields[EditProfileForm.formFieldPronouns]!.value as String; + final about = _formKey + .currentState!.fields[EditProfileForm.formFieldAbout]!.value as String; + final availability = _formKey + .currentState! + .fields[EditProfileForm.formFieldAvailability]! + .value as proto.Availability; + + final invisible = availability == proto.Availability.AVAILABILITY_OFFLINE; + final freeMessage = _formKey.currentState! + .fields[EditProfileForm.formFieldFreeMessage]!.value as String; + final awayMessage = _formKey.currentState! + .fields[EditProfileForm.formFieldAwayMessage]!.value as String; + final busyMessage = _formKey.currentState! + .fields[EditProfileForm.formFieldBusyMessage]!.value as String; + + const proto.DataReference? avatar = null; + // final avatar = _formKey.currentState! + // .fields[EditProfileForm.formFieldAvatar]!.value + //as proto.DataReference?; + + final autoAway = _formKey + .currentState!.fields[EditProfileForm.formFieldAutoAway]!.value as bool; + final autoAwayTimeoutString = _formKey.currentState! + .fields[EditProfileForm.formFieldAutoAwayTimeout]!.value as String; + final autoAwayTimeout = int.parse(autoAwayTimeoutString); + + return AccountSpec( + name: name, + pronouns: pronouns, + about: about, + availability: availability, + invisible: invisible, + freeMessage: freeMessage, + awayMessage: awayMessage, + busyMessage: busyMessage, + avatar: avatar, + autoAway: autoAway, + autoAwayTimeout: autoAwayTimeout); + } + + // Check if everything is the same and update state + void _onChanged() { + final currentValue = _makeAccountSpec(); + _isModified = currentValue != _savedValue; + final onModifiedState = widget.onModifiedState; + if (onModifiedState != null) { + onModifiedState(_isModified); + } + } + + Widget _editProfileForm( + BuildContext context, + ) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + + return FormBuilder( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + onChanged: _onChanged, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 8, + children: [ + Row(children: [ + const Spacer(), + StyledAvatar( + name: _currentValueName, + size: 128.scaled(context), + ).paddingLTRB(0, 0, 0, 16), + const Spacer() + ]), + FormBuilderTextField( + autofocus: true, + name: EditProfileForm.formFieldName, + initialValue: _savedValue.name, + onChanged: (x) { + setState(() { + _currentValueName = x ?? ''; + }); + }, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8).scaled(context), + floatingLabelBehavior: FloatingLabelBehavior.always, + labelText: translate('account.form_name'), + hintText: translate('account.empty_name')), + maxLength: 64, + // The validator receives the text that the user has entered. + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(), + ]), + textInputAction: TextInputAction.next, + ), + FormBuilderTextField( + name: EditProfileForm.formFieldPronouns, + initialValue: _savedValue.pronouns, + maxLength: 64, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8).scaled(context), + floatingLabelBehavior: FloatingLabelBehavior.always, + labelText: translate('account.form_pronouns'), + hintText: translate('account.empty_pronouns')), + textInputAction: TextInputAction.next, + ), + FormBuilderTextField( + name: EditProfileForm.formFieldAbout, + initialValue: _savedValue.about, + maxLength: 1024, + maxLines: 8, + minLines: 1, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8).scaled(context), + floatingLabelBehavior: FloatingLabelBehavior.always, + labelText: translate('account.form_about'), + hintText: translate('account.empty_about')), + textInputAction: TextInputAction.newline, + ), + _availabilityDropDown(context).paddingLTRB(0, 0, 0, 16), + FormBuilderTextField( + name: EditProfileForm.formFieldFreeMessage, + initialValue: _savedValue.freeMessage, + maxLength: 128, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8).scaled(context), + floatingLabelBehavior: FloatingLabelBehavior.always, + labelText: translate('account.form_free_message'), + hintText: translate('account.empty_free_message')), + textInputAction: TextInputAction.next, + ), + FormBuilderTextField( + name: EditProfileForm.formFieldAwayMessage, + initialValue: _savedValue.awayMessage, + maxLength: 128, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8).scaled(context), + floatingLabelBehavior: FloatingLabelBehavior.always, + labelText: translate('account.form_away_message'), + hintText: translate('account.empty_away_message')), + textInputAction: TextInputAction.next, + ), + FormBuilderTextField( + name: EditProfileForm.formFieldBusyMessage, + initialValue: _savedValue.busyMessage, + maxLength: 128, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8).scaled(context), + floatingLabelBehavior: FloatingLabelBehavior.always, + labelText: translate('account.form_busy_message'), + hintText: translate('account.empty_busy_message')), + textInputAction: TextInputAction.next, + ), + FormBuilderCheckbox( + name: EditProfileForm.formFieldAutoAway, + initialValue: _savedValue.autoAway, + title: Text(translate('account.form_auto_away'), + style: textTheme.labelMedium), + onChanged: (v) { + setState(() { + _currentValueAutoAway = v ?? false; + }); + }, + ).paddingLTRB(0, 0, 0, 16.scaled(context)), + FormBuilderTextField( + name: EditProfileForm.formFieldAutoAwayTimeout, + enabled: _currentValueAutoAway, + initialValue: _savedValue.autoAwayTimeout.toString(), + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8).scaled(context), + labelText: translate('account.form_auto_away_timeout'), + ), + validator: FormBuilderValidators.positiveNumber(), + textInputAction: TextInputAction.next, + ), + Row(children: [ + const Spacer(), + Text(widget.instructions).toCenter().flexible(flex: 6), + const Spacer(), + ]).paddingSymmetric(vertical: 16.scaled(context)), + Row(children: [ + const Spacer(), + Builder(builder: (context) { + final networkReady = context + .watch() + .state + .asData + ?.value + .isPublicInternetReady ?? + false; + + return ElevatedButton( + onPressed: (networkReady && _isModified) ? _doSubmit : null, + child: Padding( + padding: EdgeInsetsGeometry.all(4.scaled(context)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(networkReady ? Icons.check : Icons.hourglass_empty, + size: 16.scaled(context)) + .paddingLTRB(0, 0, 4.scaled(context), 0), + Text(networkReady + ? widget.submitText + : widget.submitDisabledText) + .paddingLTRB(0, 0, 4.scaled(context), 0) + ]), + )); + }), + const Spacer() + ]) + ], + ), + ); + } + + void _doSubmit() { + final onSubmit = widget.onSubmit; + if (_formKey.currentState?.saveAndValidate() ?? false) { + singleFuture((this, _kDoSubmitEditProfile), () async { + final updatedAccountSpec = _makeAccountSpec(); + final saved = await onSubmit(updatedAccountSpec); + if (saved) { + setState(() { + _savedValue = updatedAccountSpec; + }); + _onChanged(); + } + }); + } + } + + @override + Widget build(BuildContext context) => _editProfileForm( + context, + ); + + /////////////////////////////////////////////////////////////////////////// + late AccountSpec _savedValue; + late bool _currentValueAutoAway; + late String _currentValueName; + var _isModified = false; +} diff --git a/lib/account_manager/views/new_account_page.dart b/lib/account_manager/views/new_account_page.dart index 7e15a32..5012527 100644 --- a/lib/account_manager/views/new_account_page.dart +++ b/lib/account_manager/views/new_account_page.dart @@ -1,161 +1,132 @@ +import 'dart:async'; + import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import 'package:form_builder_validators/form_builder_validators.dart'; import 'package:go_router/go_router.dart'; import '../../layout/default_app_bar.dart'; +import '../../notifications/cubits/notifications_cubit.dart'; import '../../theme/theme.dart'; import '../../tools/tools.dart'; import '../../veilid_processor/veilid_processor.dart'; import '../account_manager.dart'; +import 'edit_profile_form.dart'; class NewAccountPage extends StatefulWidget { const NewAccountPage({super.key}); @override - NewAccountPageState createState() => NewAccountPageState(); + State createState() => _NewAccountPageState(); } -class NewAccountPageState extends State { - final _formKey = GlobalKey(); - late bool isInAsyncCall = false; - static const String formFieldName = 'name'; - static const String formFieldPronouns = 'pronouns'; +class _NewAccountPageState extends WindowSetupState { + _NewAccountPageState() + : super( + titleBarStyle: TitleBarStyle.normal, + orientationCapability: OrientationCapability.portraitOnly); - @override - void initState() { - super.initState(); + Widget _newAccountForm( + BuildContext context, + ) => + EditProfileForm( + header: translate('new_account_page.header'), + instructions: translate('new_account_page.instructions'), + submitText: translate('new_account_page.create'), + submitDisabledText: translate('button.waiting_for_network'), + initialValue: const AccountSpec.empty(), + onSubmit: _onSubmit); - WidgetsBinding.instance.addPostFrameCallback((_) async { - await changeWindowSetup( - TitleBarStyle.normal, OrientationCapability.portraitOnly); - }); - } + Future _onSubmit(AccountSpec accountSpec) async { + // dismiss the keyboard by unfocusing the textfield + FocusScope.of(context).unfocus(); - Widget _newAccountForm(BuildContext context, - {required Future Function(GlobalKey) onSubmit}) { - final networkReady = context - .watch() - .state - .asData - ?.value - .isPublicInternetReady ?? - false; - final canSubmit = networkReady; + try { + setState(() { + _isInAsyncCall = true; + }); + try { + final networkReady = context + .read() + .state + .asData + ?.value + .isPublicInternetReady ?? + false; - return FormBuilder( - key: _formKey, - child: ListView( - children: [ - Text(translate('new_account_page.header')) - .textStyle(context.headlineSmall) - .paddingSymmetric(vertical: 16), - FormBuilderTextField( - autofocus: true, - name: formFieldName, - decoration: - InputDecoration(labelText: translate('account.form_name')), - maxLength: 64, - // The validator receives the text that the user has entered. - validator: FormBuilderValidators.compose([ - FormBuilderValidators.required(), - ]), - textInputAction: TextInputAction.next, - ), - FormBuilderTextField( - name: formFieldPronouns, - maxLength: 64, - decoration: - InputDecoration(labelText: translate('account.form_pronouns')), - textInputAction: TextInputAction.next, - ), - Row(children: [ - const Spacer(), - Text(translate('new_account_page.instructions')) - .toCenter() - .flexible(flex: 6), - const Spacer(), - ]).paddingSymmetric(vertical: 4), - ElevatedButton( - onPressed: !canSubmit - ? null - : () async { - if (_formKey.currentState?.saveAndValidate() ?? false) { - setState(() { - isInAsyncCall = true; - }); - try { - await onSubmit(_formKey); - } finally { - if (mounted) { - setState(() { - isInAsyncCall = false; - }); - } - } - } - }, - child: Text(translate(!networkReady - ? 'button.waiting_for_network' - : 'new_account_page.create')), - ).paddingSymmetric(vertical: 4).alignAtCenterRight(), - ], - ), - ); + final canSubmit = networkReady; + if (!canSubmit) { + context.read().error( + text: translate('new_account_page.network_is_offline'), + title: translate('new_account_page.error')); + return false; + } + + final isFirstAccount = + AccountRepository.instance.getLocalAccounts().isEmpty; + + final writableSuperIdentity = await AccountRepository.instance + .createWithNewSuperIdentity(accountSpec); + GoRouterHelper(context).pushReplacement('/new_account/recovery_key', + extra: [writableSuperIdentity, accountSpec.name, isFirstAccount]); + + return true; + } finally { + if (mounted) { + setState(() { + _isInAsyncCall = false; + }); + } + } + } on Exception catch (e, st) { + if (mounted) { + await showErrorStacktraceModal( + context: context, error: e, stackTrace: st); + } + } + return false; } @override Widget build(BuildContext context) { - final displayModalHUD = isInAsyncCall; + final displayModalHUD = _isInAsyncCall; - return Scaffold( - // resizeToAvoidBottomInset: false, + return StyledScaffold( appBar: DefaultAppBar( + context: context, title: Text(translate('new_account_page.titlebar')), + leading: GoRouterHelper(context).canPop() + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + if (GoRouterHelper(context).canPop()) { + GoRouterHelper(context).pop(); + } else { + GoRouterHelper(context).go('/'); + } + }, + ) + : null, actions: [ const SignalStrengthMeterWidget(), IconButton( icon: const Icon(Icons.settings), - tooltip: translate('app_bar.settings_tooltip'), + iconSize: 24.scaled(context), + tooltip: translate('menu.settings_tooltip'), onPressed: () async { await GoRouterHelper(context).push('/settings'); }) ]), - body: _newAccountForm( - context, - onSubmit: (formKey) async { - // dismiss the keyboard by unfocusing the textfield - FocusScope.of(context).unfocus(); - - try { - final name = - _formKey.currentState!.fields[formFieldName]!.value as String; - final pronouns = _formKey.currentState!.fields[formFieldPronouns]! - .value as String? ?? - ''; - final newProfileSpec = - NewProfileSpec(name: name, pronouns: pronouns); - - await AccountRepository.instance - .createWithNewSuperIdentity(newProfileSpec); - } on Exception catch (e) { - if (context.mounted) { - await showErrorModal(context, translate('new_account_page.error'), - 'Exception: $e'); - } - } - }, - ).paddingSymmetric(horizontal: 24, vertical: 8), + body: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: _newAccountForm( + context, + )).paddingAll(2), ).withModalHUD(context, displayModalHUD); } - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('isInAsyncCall', isInAsyncCall)); - } + //////////////////////////////////////////////////////////////////////////// + + bool _isInAsyncCall = false; } diff --git a/lib/account_manager/views/profile_widget.dart b/lib/account_manager/views/profile_widget.dart index ecb7c3d..8217414 100644 --- a/lib/account_manager/views/profile_widget.dart +++ b/lib/account_manager/views/profile_widget.dart @@ -7,40 +7,67 @@ import '../../theme/theme.dart'; class ProfileWidget extends StatelessWidget { const ProfileWidget({ required proto.Profile profile, + String? byline, super.key, - }) : _profile = profile; - - // - - final proto.Profile _profile; - - // + }) : _profile = profile, + _byline = byline; @override - // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { final theme = Theme.of(context); final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + final textTheme = theme.textTheme; return DecoratedBox( decoration: ShapeDecoration( - color: scale.primaryScale.border, - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))), - child: Column(children: [ - Text( - _profile.name, - style: textTheme.headlineSmall! - .copyWith(color: scale.primaryScale.borderText), - textAlign: TextAlign.left, - ).paddingAll(4), - if (_profile.pronouns.isNotEmpty) - Text(_profile.pronouns, - style: textTheme.bodyMedium! - .copyWith(color: scale.primaryScale.borderText)) - .paddingLTRB(4, 0, 4, 4), - ]), + color: scaleConfig.preferBorders + ? scale.primaryScale.elementBackground + : scale.primaryScale.border, + shape: RoundedRectangleBorder( + side: !scaleConfig.useVisualIndicators + ? BorderSide.none + : BorderSide( + strokeAlign: BorderSide.strokeAlignCenter, + color: scaleConfig.preferBorders + ? scale.primaryScale.border + : scale.primaryScale.borderText, + width: 2), + borderRadius: BorderRadius.all( + Radius.circular(8 * scaleConfig.borderRadiusScale))), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 8.scaled(context), + children: [ + Text( + _profile.name, + style: textTheme.titleMedium!.copyWith( + color: scaleConfig.preferBorders + ? scale.primaryScale.border + : scale.primaryScale.borderText), + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + maxLines: 1, + ), + if (_byline != null) + Text( + _byline, + style: textTheme.bodySmall!.copyWith( + color: scaleConfig.preferBorders + ? scale.primaryScale.border + : scale.primaryScale.primary), + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + maxLines: 1, + ), + ]).paddingAll(8.scaled(context)), ); } + + //////////////////////////////////////////////////////////////////////////// + + final proto.Profile _profile; + final String? _byline; } diff --git a/lib/account_manager/views/show_recovery_key_page.dart b/lib/account_manager/views/show_recovery_key_page.dart new file mode 100644 index 0000000..5423543 --- /dev/null +++ b/lib/account_manager/views/show_recovery_key_page.dart @@ -0,0 +1,270 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:file_saver/file_saver.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:go_router/go_router.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:printing/printing.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:screenshot/screenshot.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../layout/default_app_bar.dart'; +import '../../theme/theme.dart'; +import '../../tools/tools.dart'; +import '../../veilid_processor/veilid_processor.dart'; + +class ShowRecoveryKeyPage extends StatefulWidget { + const ShowRecoveryKeyPage( + {required WritableSuperIdentity writableSuperIdentity, + required String name, + required bool isFirstAccount, + super.key}) + : _writableSuperIdentity = writableSuperIdentity, + _name = name, + _isFirstAccount = isFirstAccount; + + @override + State createState() => _ShowRecoveryKeyPageState(); + + final WritableSuperIdentity _writableSuperIdentity; + final String _name; + final bool _isFirstAccount; +} + +class _ShowRecoveryKeyPageState extends WindowSetupState { + _ShowRecoveryKeyPageState() + : super( + titleBarStyle: TitleBarStyle.normal, + orientationCapability: OrientationCapability.portraitOnly); + + Future _shareRecoveryKey( + BuildContext context, Uint8List recoveryKey, String name) async { + setState(() { + _isInAsyncCall = true; + }); + + final screenshotController = ScreenshotController(); + final bytes = await screenshotController.captureFromWidget( + Container( + color: Colors.white, + width: 400, + height: 400, + child: _recoveryKeyWidget(context, recoveryKey, name)), + ); + + setState(() { + _isInAsyncCall = false; + }); + + if (!kIsWeb && Platform.isLinux) { + // Share plus doesn't do Linux yet + await FileSaver.instance.saveFile(name: 'recovery_key.png', bytes: bytes); + } else { + final xfile = XFile.fromData( + bytes, + mimeType: 'image/png', + name: 'recovery_key.png', + ); + await Share.shareXFiles([xfile]); + } + } + + static Future _printRecoveryKey( + BuildContext context, Uint8List recoveryKey, String name) async { + final wrapped = await WidgetWrapper.fromWidget( + context: context, + widget: SizedBox( + width: 400, + height: 400, + child: _recoveryKeyWidget(context, recoveryKey, name)), + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 400), + pixelRatio: 3); + + final doc = pw.Document() + ..addPage(pw.Page( + build: (context) => + pw.Center(child: pw.Image(wrapped, width: 400)) // Center + )); // Page + + await Printing.layoutPdf(onLayout: (format) async => doc.save()); + } + + static Widget _recoveryKeyWidget( + BuildContext context, Uint8List recoveryKey, String name) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + //final scaleConfig = theme.extension()!; + + return Column(mainAxisSize: MainAxisSize.min, children: [ + Text( + style: textTheme.headlineSmall!.copyWith( + color: Colors.black, + fontWeight: FontWeight.bold, + ), + translate('show_recovery_key_page.recovery_key')) + .paddingLTRB(16, 16, 16, 0), + FittedBox( + child: QrImageView.withQr( + size: 300, + qr: QrCode.fromUint8List( + data: recoveryKey, + errorCorrectLevel: QrErrorCorrectLevel.L))) + .paddingLTRB(16, 16, 16, 8) + .expanded(), + Text( + style: textTheme.labelMedium!.copyWith( + color: Colors.black, + fontWeight: FontWeight.bold, + ), + name) + .paddingLTRB(16, 8, 16, 24), + ]); + } + + static Widget _recoveryKeyDialog( + BuildContext context, Uint8List recoveryKey, String name) { + final theme = Theme.of(context); + //final textTheme = theme.textTheme; + final scaleConfig = theme.extension()!; + + final cardsize = + min(MediaQuery.of(context).size.shortestSide - 48.0, 400); + + return Dialog( + shape: RoundedRectangleBorder( + side: const BorderSide(width: 2), + borderRadius: + BorderRadius.circular(16 * scaleConfig.borderRadiusScale)), + backgroundColor: Colors.white, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: cardsize, + maxWidth: cardsize, + minHeight: cardsize + 16, + maxHeight: cardsize + 16), + child: _recoveryKeyWidget(context, recoveryKey, name))); + } + + @override + // ignore: prefer_expression_function_bodies + Widget build(BuildContext context) { + final theme = Theme.of(context); + // final scale = theme.extension()!; + // final scaleConfig = theme.extension()!; + + final displayModalHUD = _isInAsyncCall; + + return StyledScaffold( + appBar: DefaultAppBar( + context: context, + title: Text(translate('show_recovery_key_page.titlebar')), + actions: [ + const SignalStrengthMeterWidget(), + IconButton( + icon: const Icon(Icons.settings), + tooltip: translate('menu.settings_tooltip'), + onPressed: () async { + await GoRouterHelper(context).push('/settings'); + }) + ]), + body: SingleChildScrollView( + child: Column(children: [ + Text( + style: theme.textTheme.headlineSmall, + textAlign: TextAlign.center, + translate('show_recovery_key_page.instructions')) + .paddingAll(24), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Text( + softWrap: true, + textAlign: TextAlign.center, + translate('show_recovery_key_page.instructions_details'))) + .toCenter() + .paddingLTRB(24, 0, 24, 24), + Text( + textAlign: TextAlign.center, + translate('show_recovery_key_page.instructions_options')) + .paddingLTRB(12, 0, 12, 24), + StyledButtonBox( + instructions: + translate('show_recovery_key_page.instructions_print'), + buttonIcon: Icons.print, + buttonText: translate('show_recovery_key_page.print'), + onClick: () { + // + singleFuture(this, () async { + await _printRecoveryKey(context, + widget._writableSuperIdentity.recoveryKey, widget._name); + }); + + setState(() { + _codeHandled = true; + }); + }), + StyledButtonBox( + instructions: + translate('show_recovery_key_page.instructions_view'), + buttonIcon: Icons.edit_document, + buttonText: translate('show_recovery_key_page.view'), + onClick: () { + // + singleFuture(this, () async { + await showDialog( + context: context, + builder: (context) => _recoveryKeyDialog( + context, + widget._writableSuperIdentity.recoveryKey, + widget._name)); + }); + + setState(() { + _codeHandled = true; + }); + }), + StyledButtonBox( + instructions: + translate('show_recovery_key_page.instructions_share'), + buttonIcon: Icons.ios_share, + buttonText: translate('show_recovery_key_page.share'), + onClick: () { + // + singleFuture(this, () async { + await _shareRecoveryKey(context, + widget._writableSuperIdentity.recoveryKey, widget._name); + }); + + setState(() { + _codeHandled = true; + }); + }), + Offstage( + offstage: !_codeHandled, + child: ElevatedButton( + onPressed: () { + if (context.mounted) { + if (widget._isFirstAccount) { + GoRouterHelper(context).go('/'); + } else { + GoRouterHelper(context).canPop() + ? GoRouterHelper(context).pop() + : GoRouterHelper(context).go('/'); + } + } + }, + child: Text(translate('button.finish')).paddingAll(8)) + .paddingAll(12)) + ]))).withModalHUD(context, displayModalHUD); + } + + bool _codeHandled = false; + bool _isInAsyncCall = false; +} diff --git a/lib/account_manager/views/views.dart b/lib/account_manager/views/views.dart index 2acc537..f554e88 100644 --- a/lib/account_manager/views/views.dart +++ b/lib/account_manager/views/views.dart @@ -1,2 +1,4 @@ +export 'edit_account_page.dart'; export 'new_account_page.dart'; export 'profile_widget.dart'; +export 'show_recovery_key_page.dart'; diff --git a/lib/app.dart b/lib/app.dart index aaa0190..5f4d6dc 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,31 +1,28 @@ import 'package:animated_theme_switcher/animated_theme_switcher.dart'; -import 'package:async_tools/async_tools.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:form_builder_validators/form_builder_validators.dart'; import 'package:provider/provider.dart'; -import 'package:veilid_support/veilid_support.dart'; import 'account_manager/account_manager.dart'; import 'init.dart'; import 'layout/splash.dart'; +import 'notifications/notifications.dart'; import 'router/router.dart'; import 'settings/settings.dart'; -import 'theme/models/theme_preference.dart'; +import 'theme/theme.dart'; import 'tick.dart'; -import 'tools/loggy.dart'; import 'veilid_processor/veilid_processor.dart'; -class ReloadThemeIntent extends Intent { - const ReloadThemeIntent(); -} - -class AttachDetachThemeIntent extends Intent { - const AttachDetachThemeIntent(); +class ScrollBehaviorModified extends ScrollBehavior { + const ScrollBehaviorModified(); + @override + ScrollPhysics getScrollPhysics(BuildContext context) => + const ClampingScrollPhysics(); } class VeilidChatApp extends StatelessWidget { @@ -34,66 +31,102 @@ class VeilidChatApp extends StatelessWidget { super.key, }); - static const String name = 'VeilidChat'; + static const name = 'VeilidChat'; final ThemeData initialThemeData; - void _reloadTheme(BuildContext context) { - log.info('Reloading theme'); - final theme = - PreferencesRepository.instance.value.themePreferences.themeData(); - ThemeSwitcher.of(context).changeTheme(theme: theme); + Widget appBuilder( + BuildContext context, LocalizationDelegate localizationDelegate) => + ThemeProvider( + initTheme: initialThemeData, + builder: (context, theme) => LocalizationProvider( + state: LocalizationProvider.of(context).state, + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => + PreferencesCubit(PreferencesRepository.instance), + ), + BlocProvider( + create: (context) => NotificationsCubit( + const NotificationsState(queue: IList.empty()))), + BlocProvider( + create: (context) => + ConnectionStateCubit(ProcessorRepository.instance)), + BlocProvider( + create: (context) => RouterCubit(AccountRepository.instance), + ), + BlocProvider( + create: (context) => + LocalAccountsCubit(AccountRepository.instance), + ), + BlocProvider( + create: (context) => + UserLoginsCubit(AccountRepository.instance), + ), + BlocProvider( + create: (context) => + ActiveLocalAccountCubit(AccountRepository.instance), + ), + BlocProvider( + create: (context) => PerAccountCollectionBlocMapCubit( + accountRepository: AccountRepository.instance, + locator: context.read)), + ], + child: BackgroundTicker(child: Builder(builder: (context) { + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; - // Hack to reload translations - final localizationDelegate = LocalizedApp.of(context).delegate; - singleFuture(this, () async { - await LocalizationDelegate.create( - fallbackLocale: localizationDelegate.fallbackLocale.toString(), - supportedLocales: localizationDelegate.supportedLocales - .map((x) => x.toString()) - .toList()); - }); - } + final gradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: scaleConfig.preferBorders && + theme.brightness == Brightness.light + ? [ + scale.grayScale.hoverElementBackground, + scale.grayScale.subtleBackground, + ] + : [ + scale.primaryScale.hoverElementBackground, + scale.primaryScale.subtleBackground, + ]); - void _attachDetachTheme(BuildContext context) { - singleFuture(this, () async { - if (ProcessorRepository.instance.processorConnectionState.isAttached) { - log.info('Detaching'); - await Veilid.instance.detach(); - } else if (ProcessorRepository - .instance.processorConnectionState.isDetached) { - log.info('Attaching'); - await Veilid.instance.attach(); - } - }); - } + final wallpaper = PreferencesRepository + .instance.value.themePreference + .wallpaper(); - Widget _buildShortcuts( - {required BuildContext context, - required Widget Function(BuildContext) builder}) => - ThemeSwitcher( - builder: (context) => Shortcuts( - shortcuts: { - LogicalKeySet( - LogicalKeyboardKey.alt, LogicalKeyboardKey.keyR): - const ReloadThemeIntent(), - LogicalKeySet( - LogicalKeyboardKey.alt, LogicalKeyboardKey.keyD): - const AttachDetachThemeIntent(), - }, - child: Actions(actions: >{ - ReloadThemeIntent: CallbackAction( - onInvoke: (intent) => _reloadTheme(context)), - AttachDetachThemeIntent: - CallbackAction( - onInvoke: (intent) => _attachDetachTheme(context)), - }, child: Focus(autofocus: true, child: builder(context))))); + return Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + wallpaper ?? + DecoratedBox( + decoration: BoxDecoration(gradient: gradient)), + MaterialApp.router( + scrollBehavior: const ScrollBehaviorModified(), + debugShowCheckedModeBanner: false, + routerConfig: context.read().router(), + title: translate('app.title'), + theme: theme, + localizationsDelegates: [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + FormBuilderLocalizations.delegate, + localizationDelegate + ], + supportedLocales: localizationDelegate.supportedLocales, + locale: localizationDelegate.currentLocale, + ) + ]); + })), + )), + ); @override Widget build(BuildContext context) => FutureProvider( initialData: null, - create: (context) async => VeilidChatGlobalInit.initialize(), - builder: (context, child) { + create: (context) => VeilidChatGlobalInit.initialize(), + builder: (context, __) { final globalInit = context.watch(); if (globalInit == null) { // Splash screen until we're done with init @@ -101,57 +134,8 @@ class VeilidChatApp extends StatelessWidget { } // Once init is done, we proceed with the app final localizationDelegate = LocalizedApp.of(context).delegate; - return ThemeProvider( - initTheme: initialThemeData, - builder: (_, theme) => LocalizationProvider( - state: LocalizationProvider.of(context).state, - child: MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => - ConnectionStateCubit(ProcessorRepository.instance)), - BlocProvider( - create: (context) => - RouterCubit(AccountRepository.instance), - ), - BlocProvider( - create: (context) => - LocalAccountsCubit(AccountRepository.instance), - ), - BlocProvider( - create: (context) => - UserLoginsCubit(AccountRepository.instance), - ), - BlocProvider( - create: (context) => - ActiveLocalAccountCubit(AccountRepository.instance), - ), - BlocProvider( - create: (context) => - PreferencesCubit(PreferencesRepository.instance), - ) - ], - child: BackgroundTicker( - child: _buildShortcuts( - context: context, - builder: (context) => MaterialApp.router( - debugShowCheckedModeBanner: false, - routerConfig: - context.watch().router(), - title: translate('app.title'), - theme: theme, - localizationsDelegates: [ - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - FormBuilderLocalizations.delegate, - localizationDelegate - ], - supportedLocales: - localizationDelegate.supportedLocales, - locale: localizationDelegate.currentLocale, - ))), - )), - ); + + return SafeArea(child: appBuilder(context, localizationDelegate)); }); @override diff --git a/lib/chat/cubits/chat_component_cubit.dart b/lib/chat/cubits/chat_component_cubit.dart index bc7431c..c0473be 100644 --- a/lib/chat/cubits/chat_component_cubit.dart +++ b/lib/chat/cubits/chat_component_cubit.dart @@ -4,92 +4,100 @@ import 'dart:typed_data'; import 'package:async_tools/async_tools.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:fixnum/fixnum.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; -import 'package:flutter_chat_ui/flutter_chat_ui.dart'; -import 'package:scroll_to_index/scroll_to_index.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; import 'package:veilid_support/veilid_support.dart'; import '../../account_manager/account_manager.dart'; -import '../../chat_list/chat_list.dart'; +import '../../contacts/contacts.dart'; +import '../../conversation/conversation.dart'; import '../../proto/proto.dart' as proto; +import '../../tools/tools.dart'; import '../models/chat_component_state.dart'; import '../models/message_state.dart'; import '../models/window_state.dart'; +import '../views/chat_component_widget.dart'; import 'cubits.dart'; const metadataKeyIdentityPublicKey = 'identityPublicKey'; const metadataKeyExpirationDuration = 'expiration'; const metadataKeyViewLimit = 'view_limit'; const metadataKeyAttachments = 'attachments'; +const _sfChangedContacts = 'changedContacts'; class ChatComponentCubit extends Cubit { ChatComponentCubit._({ + required AccountInfo accountInfo, + required AccountRecordCubit accountRecordCubit, + required ContactListCubit contactListCubit, + required List conversationCubits, required SingleContactMessagesCubit messagesCubit, - required types.User localUser, - required IMap remoteUsers, - }) : _messagesCubit = messagesCubit, - super(ChatComponentState( - chatKey: GlobalKey(), - scrollController: AutoScrollController(), - localUser: localUser, - remoteUsers: remoteUsers, - messageWindow: const AsyncLoading(), + }) : _accountInfo = accountInfo, + _accountRecordCubit = accountRecordCubit, + _contactListCubit = contactListCubit, + _conversationCubits = conversationCubits, + _messagesCubit = messagesCubit, + super(const ChatComponentState( + localUser: null, + remoteUsers: IMap.empty(), + historicalRemoteUsers: IMap.empty(), + unknownUsers: IMap.empty(), + messageWindow: AsyncLoading(), title: '', )) { + // Immediate Init + _init(); + // Async Init - _initWait.add(_init); + _initWait.add(_initAsync); } - // ignore: prefer_constructors_over_static_methods - static ChatComponentCubit singleContact( - {required ActiveAccountInfo activeAccountInfo, - required proto.Account accountRecordInfo, - required ActiveConversationState activeConversationState, - required SingleContactMessagesCubit messagesCubit}) { - // Make local 'User' - final localUserIdentityKey = activeAccountInfo.identityTypedPublicKey; - final localUser = types.User( - id: localUserIdentityKey.toString(), - firstName: accountRecordInfo.profile.name, - metadata: {metadataKeyIdentityPublicKey: localUserIdentityKey}); - // Make remote 'User's - final remoteUsers = { - activeConversationState.contact.identityPublicKey.toVeilid(): types.User( - id: activeConversationState.contact.identityPublicKey - .toVeilid() - .toString(), - firstName: activeConversationState.contact.editedProfile.name, - metadata: { - metadataKeyIdentityPublicKey: - activeConversationState.contact.identityPublicKey.toVeilid() - }) - }.toIMap(); + factory ChatComponentCubit.singleContact( + {required AccountInfo accountInfo, + required AccountRecordCubit accountRecordCubit, + required ContactListCubit contactListCubit, + required ActiveConversationCubit activeConversationCubit, + required SingleContactMessagesCubit messagesCubit}) => + ChatComponentCubit._( + accountInfo: accountInfo, + accountRecordCubit: accountRecordCubit, + contactListCubit: contactListCubit, + conversationCubits: [activeConversationCubit], + messagesCubit: messagesCubit, + ); - return ChatComponentCubit._( - messagesCubit: messagesCubit, - localUser: localUser, - remoteUsers: remoteUsers, - ); + void _init() { + // Get local user info and account record cubit + _localUserIdentityKey = _accountInfo.identityTypedPublicKey; + + // Subscribe to local user info + _accountRecordSubscription = + _accountRecordCubit.stream.listen(_onChangedAccountRecord); + _onChangedAccountRecord(_accountRecordCubit.state); + + // Subscribe to messages + _messagesSubscription = _messagesCubit.stream.listen(_onChangedMessages); + _onChangedMessages(_messagesCubit.state); + + // Subscribe to contact list changes + _contactListSubscription = + _contactListCubit.stream.listen(_onChangedContacts); + _onChangedContacts(_contactListCubit.state); } - Future _init() async { - _messagesSubscription = _messagesCubit.stream.listen((messagesState) { - emit(state.copyWith( - messageWindow: _convertMessages(messagesState), - )); - }); - emit(state.copyWith( - messageWindow: _convertMessages(_messagesCubit.state), - title: _getTitle(), - )); + Future _initAsync(Completer cancel) async { + // Subscribe to remote user info + await _updateConversationSubscriptions(); } @override Future close() async { await _initWait(); + await _contactListSubscription.cancel(); + await _accountRecordSubscription.cancel(); await _messagesSubscription.cancel(); + await _conversationSubscriptions.values.map((v) => v.cancel()).wait; + await super.close(); } @@ -110,32 +118,15 @@ class ChatComponentCubit extends Cubit { } // Send a message - void sendMessage(types.PartialText message) { - final text = message.text; - - final replyId = (message.repliedMessage != null) - ? base64UrlNoPadDecode(message.repliedMessage!.id) + void sendMessage( + {required String text, + String? replyToMessageId, + Timestamp? expiration, + int? viewLimit, + List? attachments}) { + final replyId = (replyToMessageId != null) + ? base64UrlNoPadDecode(replyToMessageId) : null; - Timestamp? expiration; - int? viewLimit; - List? attachments; - final metadata = message.metadata; - if (metadata != null) { - final expirationValue = - metadata[metadataKeyExpirationDuration] as TimestampDuration?; - if (expirationValue != null) { - expiration = Veilid.instance.now().offset(expirationValue); - } - final viewLimitValue = metadata[metadataKeyViewLimit] as int?; - if (viewLimitValue != null) { - viewLimit = viewLimitValue; - } - final attachmentsValue = - metadata[metadataKeyAttachments] as List?; - if (attachmentsValue != null) { - attachments = attachmentsValue; - } - } _addTextMessage( text: text, @@ -153,46 +144,199 @@ class ChatComponentCubit extends Cubit { //////////////////////////////////////////////////////////////////////////// // Private Implementation - String _getTitle() { - if (state.remoteUsers.length == 1) { - final remoteUser = state.remoteUsers.values.first; - return remoteUser.firstName ?? ''; - } else { - return ''; + void _onChangedAccountRecord(AsyncValue avAccount) { + // Update local 'User' + final account = avAccount.asData?.value; + if (account == null) { + emit(state.copyWith(localUser: null)); + return; } + final localUser = core.User( + id: _localUserIdentityKey.toString(), + name: account.profile.name, + metadata: {metadataKeyIdentityPublicKey: _localUserIdentityKey}); + emit(state.copyWith(localUser: localUser)); } - types.Message? _messageStateToChatMessage(MessageState message) { - final authorIdentityPublicKey = message.content.author.toVeilid(); - final author = - state.remoteUsers[authorIdentityPublicKey] ?? state.localUser; + void _onChangedMessages( + AsyncValue> avMessagesState) { + emit(_convertMessages(state, avMessagesState)); + } - types.Status? status; - if (message.sendState != null) { - assert(author == state.localUser, - 'send state should only be on sent messages'); - switch (message.sendState!) { - case MessageSendState.sending: - status = types.Status.sending; - case MessageSendState.sent: - status = types.Status.sent; - case MessageSendState.delivered: - status = types.Status.delivered; + void _onChangedContacts(DHTShortArrayCubitState bavContacts) { + // Rewrite users when contacts change + singleFuture((this, _sfChangedContacts), _updateConversationSubscriptions); + } + + void _onChangedConversation( + TypedKey remoteIdentityPublicKey, + AsyncValue avConversationState, + ) { + // Update remote 'User' + final activeConversationState = avConversationState.asData?.value; + if (activeConversationState == null) { + // Don't change user information on loading state + return; + } + + final remoteUser = + _convertRemoteUser(remoteIdentityPublicKey, activeConversationState); + + emit(_updateTitle(state.copyWith( + remoteUsers: state.remoteUsers.add(remoteUser.id, remoteUser)))); + } + + static ChatComponentState _updateTitle(ChatComponentState currentState) { + if (currentState.remoteUsers.length == 0) { + return currentState.copyWith(title: 'Empty Chat'); + } + if (currentState.remoteUsers.length == 1) { + final remoteUser = currentState.remoteUsers.values.first; + return currentState.copyWith(title: remoteUser.name ?? ''); + } + return currentState.copyWith( + title: ''); + } + + core.User _convertRemoteUser(TypedKey remoteIdentityPublicKey, + ActiveConversationState activeConversationState) { + // See if we have a contact for this remote user + final contacts = _contactListCubit.state.state.asData?.value; + if (contacts != null) { + final contactIdx = contacts.indexWhere((x) => + x.value.identityPublicKey.toVeilid() == remoteIdentityPublicKey); + if (contactIdx != -1) { + final contact = contacts[contactIdx].value; + return core.User( + id: remoteIdentityPublicKey.toString(), + name: contact.displayName, + metadata: {metadataKeyIdentityPublicKey: remoteIdentityPublicKey}); } } + return core.User( + id: remoteIdentityPublicKey.toString(), + name: activeConversationState.remoteConversation?.profile.name ?? + '', + metadata: {metadataKeyIdentityPublicKey: remoteIdentityPublicKey}); + } + + core.User _convertUnknownUser(TypedKey remoteIdentityPublicKey) => core.User( + id: remoteIdentityPublicKey.toString(), + name: '<$remoteIdentityPublicKey>', + metadata: {metadataKeyIdentityPublicKey: remoteIdentityPublicKey}); + + Future _updateConversationSubscriptions() async { + // Get existing subscription keys and state + final existing = _conversationSubscriptions.keys.toList(); + var currentRemoteUsersState = state.remoteUsers; + + // Process cubit list + for (final cc in _conversationCubits) { + // Get the remote identity key + final remoteIdentityPublicKey = cc.input.remoteIdentityPublicKey; + + // If the cubit is already being listened to we have nothing to do + if (existing.remove(remoteIdentityPublicKey)) { + // If the cubit is not already being listened to we should do that + _conversationSubscriptions[remoteIdentityPublicKey] = cc.stream.listen( + (avConv) => + _onChangedConversation(remoteIdentityPublicKey, avConv)); + } + + final activeConversationState = cc.state.asData?.value; + if (activeConversationState != null) { + final remoteUser = _convertRemoteUser( + remoteIdentityPublicKey, activeConversationState); + currentRemoteUsersState = + currentRemoteUsersState.add(remoteUser.id, remoteUser); + } + } + // Purge remote users we didn't see in the cubit list any more + final cancels = >[]; + for (final deadUser in existing) { + currentRemoteUsersState = + currentRemoteUsersState.remove(deadUser.toString()); + cancels.add(_conversationSubscriptions.remove(deadUser)!.cancel()); + } + await cancels.wait; + + // Emit change to remote users state + emit(_updateTitle(state.copyWith(remoteUsers: currentRemoteUsersState))); + } + + (ChatComponentState, core.Message?) _messageStateToChatMessage( + ChatComponentState currentState, MessageState message) { + final authorIdentityPublicKey = message.content.author.toVeilid(); + final authorUserId = authorIdentityPublicKey.toString(); + + late final core.User author; + if (authorIdentityPublicKey == _localUserIdentityKey && + currentState.localUser != null) { + author = currentState.localUser!; + } else { + final remoteUser = currentState.remoteUsers[authorUserId]; + if (remoteUser != null) { + author = remoteUser; + } else { + final historicalRemoteUser = + currentState.historicalRemoteUsers[authorUserId]; + if (historicalRemoteUser != null) { + author = historicalRemoteUser; + } else { + final unknownRemoteUser = currentState.unknownUsers[authorUserId]; + if (unknownRemoteUser != null) { + author = unknownRemoteUser; + } else { + final unknownUser = _convertUnknownUser(authorIdentityPublicKey); + currentState = currentState.copyWith( + unknownUsers: + currentState.unknownUsers.add(authorUserId, unknownUser)); + author = unknownUser; + } + } + } + } + + // types.Status? status; + // if (message.sendState != null) { + // assert(author.id == _localUserIdentityKey.toString(), + // 'send state should only be on sent messages'); + // switch (message.sendState!) { + // case MessageSendState.sending: + // status = types.Status.sending; + // case MessageSendState.sent: + // status = types.Status.sent; + // case MessageSendState.delivered: + // status = types.Status.delivered; + // } + // } + + final reconciledAt = message.reconciledTimestamp == null + ? null + : DateTime.fromMicrosecondsSinceEpoch( + message.reconciledTimestamp!.value.toInt()); + + // print('message seqid: ${message.seqId}'); + switch (message.content.whichKind()) { case proto.Message_Kind.text: - final contextText = message.content.text; - final textMessage = types.TextMessage( - author: author, - createdAt: - (message.sentTimestamp.value ~/ BigInt.from(1000)).toInt(), - id: message.content.authorUniqueIdString, - text: contextText.text, - showStatus: status != null, - status: status); - return textMessage; + final reconciledId = message.content.authorUniqueIdString; + final contentText = message.content.text; + final textMessage = core.TextMessage( + authorId: author.id, + createdAt: DateTime.fromMicrosecondsSinceEpoch( + message.sentTimestamp.value.toInt()), + sentAt: reconciledAt, + id: reconciledId, + //text: '${contentText.text} (${message.seqId})', + text: contentText.text, + metadata: { + kSeqId: message.seqId, + kSending: message.sendState == MessageSendState.sending, + if (core.isOnlyEmoji(contentText.text)) 'isOnlyEmoji': true, + }); + return (currentState, textMessage); case proto.Message_Kind.secret: case proto.Message_Kind.delete: case proto.Message_Kind.erase: @@ -201,43 +345,55 @@ class ChatComponentCubit extends Cubit { case proto.Message_Kind.membership: case proto.Message_Kind.moderation: case proto.Message_Kind.notSet: - return null; + case proto.Message_Kind.readReceipt: + return (currentState, null); } } - AsyncValue> _convertMessages( + ChatComponentState _convertMessages(ChatComponentState currentState, AsyncValue> avMessagesState) { + // Clear out unknown users + currentState = state.copyWith(unknownUsers: const IMap.empty()); + final asError = avMessagesState.asError; if (asError != null) { - return AsyncValue.error(asError.error, asError.stackTrace); + addError(asError.error, asError.stackTrace); + return currentState.copyWith( + unknownUsers: const IMap.empty(), + messageWindow: AsyncValue.error(asError.error, asError.stackTrace)); } else if (avMessagesState.asLoading != null) { - return const AsyncValue.loading(); + return currentState.copyWith( + unknownUsers: const IMap.empty(), + messageWindow: const AsyncValue.loading()); } final messagesState = avMessagesState.asData!.value; // Convert protobuf messages to chat messages - final chatMessages = []; + final chatMessages = []; final tsSet = {}; for (final message in messagesState.window) { - final chatMessage = _messageStateToChatMessage(message); + final (newState, chatMessage) = + _messageStateToChatMessage(currentState, message); + currentState = newState; if (chatMessage == null) { continue; } - chatMessages.insert(0, chatMessage); if (!tsSet.add(chatMessage.id)) { - // ignore: avoid_print - print('duplicate id found: ${chatMessage.id}:\n' - 'Messages:\n${messagesState.window}\n' - 'ChatMessages:\n$chatMessages'); - assert(false, 'should not have duplicate id'); + log.error('duplicate id found: ${chatMessage.id}' + // '\nMessages:\n${messagesState.window}' + // '\nChatMessages:\n$chatMessages' + ); + } else { + chatMessages.add(chatMessage); } } - return AsyncValue.data(WindowState( - window: chatMessages.toIList(), - length: messagesState.length, - windowTail: messagesState.windowTail, - windowCount: messagesState.windowCount, - follow: messagesState.follow)); + return currentState.copyWith( + messageWindow: AsyncValue.data(WindowState( + window: chatMessages.toIList(), + length: messagesState.length, + windowTail: messagesState.windowTail, + windowCount: messagesState.windowCount, + follow: messagesState.follow))); } void _addTextMessage( @@ -264,8 +420,20 @@ class ChatComponentCubit extends Cubit { //////////////////////////////////////////////////////////////////////////// - final _initWait = WaitSet(); + final _initWait = WaitSet(); + final AccountInfo _accountInfo; + final AccountRecordCubit _accountRecordCubit; + final ContactListCubit _contactListCubit; + final List _conversationCubits; final SingleContactMessagesCubit _messagesCubit; + + late final TypedKey _localUserIdentityKey; + late final StreamSubscription> + _accountRecordSubscription; + final Map>> + _conversationSubscriptions = {}; late StreamSubscription _messagesSubscription; + late StreamSubscription> + _contactListSubscription; double scrollOffset = 0; } diff --git a/lib/chat/cubits/reconciliation/author_input_queue.dart b/lib/chat/cubits/reconciliation/author_input_queue.dart index d7be3eb..f750e77 100644 --- a/lib/chat/cubits/reconciliation/author_input_queue.dart +++ b/lib/chat/cubits/reconciliation/author_input_queue.dart @@ -1,105 +1,130 @@ import 'dart:async'; +import 'dart:math'; import 'package:veilid_support/veilid_support.dart'; import '../../../proto/proto.dart' as proto; +import '../../../tools/tools.dart'; import 'author_input_source.dart'; import 'message_integrity.dart'; -import 'output_position.dart'; class AuthorInputQueue { AuthorInputQueue._({ required TypedKey author, required AuthorInputSource inputSource, - required OutputPosition? outputPosition, + required int inputPosition, + required proto.Message? previousMessage, required void Function(Object, StackTrace?) onError, required MessageIntegrity messageIntegrity, }) : _author = author, _onError = onError, _inputSource = inputSource, - _outputPosition = outputPosition, - _lastMessage = outputPosition?.message.content, + _previousMessage = previousMessage, _messageIntegrity = messageIntegrity, - _currentPosition = inputSource.currentWindow.last; + _inputPosition = inputPosition; static Future create({ required TypedKey author, required AuthorInputSource inputSource, - required OutputPosition? outputPosition, + required proto.Message? previousMessage, required void Function(Object, StackTrace?) onError, }) async { + // Get ending input position + final inputPosition = await inputSource.getTailPosition() - 1; + + // Create an input queue for the input source final queue = AuthorInputQueue._( author: author, inputSource: inputSource, - outputPosition: outputPosition, + inputPosition: inputPosition, + previousMessage: previousMessage, onError: onError, messageIntegrity: await MessageIntegrity.create(author: author)); - if (!await queue._findStartOfWork()) { + + // Rewind the queue's 'inputPosition' to the first unreconciled message + if (!await queue._rewindInputToAfterLastMessage()) { return null; } + return queue; } //////////////////////////////////////////////////////////////////////////// // Public interface - // Check if there are no messages left in this queue to reconcile - bool get isDone => _isDone; + /// Get the input source for this queue + AuthorInputSource get inputSource => _inputSource; - // Get the current message that needs reconciliation - proto.Message? get current => _currentMessage; - - // Get the earliest output position to start inserting - OutputPosition? get outputPosition => _outputPosition; - - // Get the author of this queue + /// Get the author of this queue TypedKey get author => _author; - // Remove a reconciled message and move to the next message - // Returns true if there is more work to do - Future consume() async { - if (_isDone) { - return false; - } - while (true) { - _lastMessage = _currentMessage; + /// Get the current message that needs reconciliation + Future getCurrentMessage() async { + try { + // if we have a current message already, return it + if (_currentMessage != null) { + return _currentMessage; + } - _currentPosition++; + // Get the window + final currentWindow = await _updateWindow(clampInputPosition: false); + if (currentWindow == null) { + return null; + } + final currentElement = + currentWindow.elements[_inputPosition - currentWindow.firstPosition]; + return _currentMessage = currentElement.value; + // Catch everything so we can avoid ParallelWaitError + // ignore: avoid_catches_without_on_clauses + } catch (e, st) { + log.error('Exception getting current message: $e:\n$st\n'); + _currentMessage = null; + return null; + } + } + + /// Move the reconciliation cursor (_inputPosition) forward on the input + /// queue and tees up the next message for processing + /// Returns true if there is more work to do + /// Returns false if there are no more messages to reconcile in this queue + Future advance() async { + // Move current message to previous + _previousMessage = await getCurrentMessage(); + _currentMessage = null; + + while (true) { + // Advance to next position + _inputPosition++; // Get more window if we need to - if (!await _updateWindow()) { - // Window is not available so this queue can't work right now - _isDone = true; + final currentMessage = await getCurrentMessage(); + if (currentMessage == null) { return false; } - final nextMessage = _inputSource.currentWindow - .elements[_currentPosition - _inputSource.currentWindow.first]; - // Drop the 'offline' elements because we don't reconcile - // anything until it has been confirmed to be committed to the DHT - // if (nextMessage.isOffline) { - // continue; - // } - - if (_lastMessage != null) { + if (_previousMessage != null) { // Ensure the timestamp is not moving backward - if (nextMessage.value.timestamp < _lastMessage!.timestamp) { + if (currentMessage.timestamp < _previousMessage!.timestamp) { + log.warning('timestamp backward: ${currentMessage.timestamp}' + ' < ${_previousMessage!.timestamp}'); continue; } } // Verify the id chain for the message - final matchId = await _messageIntegrity.generateMessageId(_lastMessage); - if (matchId.compare(nextMessage.value.idBytes) != 0) { + final matchId = + await _messageIntegrity.generateMessageId(_previousMessage); + if (matchId.compare(currentMessage.idBytes) != 0) { + log.warning('id chain invalid: $matchId != ${currentMessage.idBytes}'); continue; } // Verify the signature for the message - if (!await _messageIntegrity.verifyMessage(nextMessage.value)) { + if (!await _messageIntegrity.verifyMessage(currentMessage)) { + log.warning('invalid message signature: $currentMessage'); continue; } - _currentMessage = nextMessage.value; break; } return true; @@ -108,106 +133,166 @@ class AuthorInputQueue { //////////////////////////////////////////////////////////////////////////// // Internal implementation - // Walk backward from the tail of the input queue to find the first - // message newer than our last reconciled message from this author - // Returns false if no work is needed - Future _findStartOfWork() async { + /// Walk backward from the tail of the input queue to find the first + /// message newer than our last reconciled message from this author + /// Returns false if no work is needed + Future _rewindInputToAfterLastMessage() async { // Iterate windows over the inputSource + InputWindow? currentWindow; outer: while (true) { + // Get more window if we need to + currentWindow = await _updateWindow(clampInputPosition: true); + if (currentWindow == null) { + // Window is not available or things are empty so this + // queue can't work right now + return false; + } + // Iterate through current window backward - for (var i = _inputSource.currentWindow.elements.length - 1; - i >= 0 && _currentPosition >= 0; - i--, _currentPosition--) { - final elem = _inputSource.currentWindow.elements[i]; + for (var i = currentWindow.elements.length - 1; + i >= 0 && _inputPosition >= 0; + i--, _inputPosition--) { + final elem = currentWindow.elements[i]; // If we've found an input element that is older or same time as our // last reconciled message for this author, or we find the message // itself then we stop - if (_lastMessage != null) { + if (_previousMessage != null) { if (elem.value.authorUniqueIdBytes - .compare(_lastMessage!.authorUniqueIdBytes) == + .compare(_previousMessage!.authorUniqueIdBytes) == 0 || - elem.value.timestamp <= _lastMessage!.timestamp) { + elem.value.timestamp <= _previousMessage!.timestamp) { break outer; } } } // If we're at the beginning of the inputSource then we stop - if (_currentPosition < 0) { + if (_inputPosition < 0) { break; } - - // Get more window if we need to - if (!await _updateWindow()) { - // Window is not available or things are empty so this - // queue can't work right now - _isDone = true; - return false; - } } - // _currentPosition points to either before the input source starts + // _inputPosition points to either before the input source starts // or the position of the previous element. We still need to set the - // _currentMessage to the previous element so consume() can compare + // _currentMessage to the previous element so advance() can compare // against it if we can. - if (_currentPosition >= 0) { - _currentMessage = _inputSource.currentWindow - .elements[_currentPosition - _inputSource.currentWindow.first].value; + if (_inputPosition >= 0) { + _currentMessage = currentWindow + .elements[_inputPosition - currentWindow.firstPosition].value; } - // After this consume(), the currentPosition and _currentMessage should + // After this advance(), the _inputPosition and _currentMessage should // be equal to the first message to process and the current window to - // process should not be empty - return consume(); + // process should not be empty if there is work to do + return advance(); } - // Slide the window toward the current position and load the batch around it - Future _updateWindow() async { + /// Slide the window toward the current position and load the batch around it + Future _updateWindow({required bool clampInputPosition}) async { + final inputTailPosition = await _inputSource.getTailPosition(); + if (inputTailPosition == 0) { + return null; + } + + // Handle out-of-range input position + if (clampInputPosition) { + _inputPosition = min(max(_inputPosition, 0), inputTailPosition - 1); + } else if (_inputPosition < 0 || _inputPosition >= inputTailPosition) { + return null; + } + // Check if we are still in the window - if (_currentPosition >= _inputSource.currentWindow.first && - _currentPosition <= _inputSource.currentWindow.last) { - return true; + final currentWindow = _currentWindow; + + int firstPosition; + int lastPosition; + if (currentWindow != null) { + firstPosition = currentWindow.firstPosition; + lastPosition = currentWindow.lastPosition; + + // Slide the window if we need to + if (_inputPosition >= firstPosition && _inputPosition <= lastPosition) { + return currentWindow; + } else if (_inputPosition < firstPosition) { + // Slide it backward, current position is now last + firstPosition = max((_inputPosition - _maxWindowLength) + 1, 0); + lastPosition = _inputPosition; + } else if (_inputPosition > lastPosition) { + // Slide it forward, current position is now first + firstPosition = _inputPosition; + lastPosition = + min((_inputPosition + _maxWindowLength) - 1, inputTailPosition - 1); + } + } else { + // need a new window, start with the input position at the end + lastPosition = _inputPosition; + firstPosition = max((_inputPosition - _maxWindowLength) + 1, 0); } // Get another input batch futher back - final avOk = - await _inputSource.updateWindow(_currentPosition, _maxWindowLength); + final avCurrentWindow = await _inputSource.getWindow( + firstPosition, lastPosition - firstPosition + 1); - final asErr = avOk.asError; + final asErr = avCurrentWindow.asError; if (asErr != null) { _onError(asErr.error, asErr.stackTrace); - return false; + _currentWindow = null; + return null; } - final asLoading = avOk.asLoading; + final asLoading = avCurrentWindow.asLoading; if (asLoading != null) { - // xxx: no need to block the cubit here for this - // xxx: might want to switch to a 'busy' state though - // xxx: to let the messages view show a spinner at the bottom - // xxx: while we reconcile... - // emit(const AsyncValue.loading()); - return false; + _currentWindow = null; + return null; } - return avOk.asData!.value; + + final nextWindow = avCurrentWindow.asData!.value; + if (nextWindow == null || nextWindow.length == 0) { + _currentWindow = null; + return null; + } + + // Handle out-of-range input position + // Doing this again because getWindow is allowed to return a smaller + // window than the one requested, possibly due to DHT consistency + // fluctuations and race conditions + if (clampInputPosition) { + _inputPosition = min(max(_inputPosition, nextWindow.firstPosition), + nextWindow.lastPosition); + } else if (_inputPosition < nextWindow.firstPosition || + _inputPosition > nextWindow.lastPosition) { + return null; + } + + return _currentWindow = nextWindow; } //////////////////////////////////////////////////////////////////////////// + /// The author of this messages in the input source final TypedKey _author; + + /// The input source we're pulling messages from final AuthorInputSource _inputSource; - final OutputPosition? _outputPosition; + + /// What to call if an error happens final void Function(Object, StackTrace?) _onError; + + /// The message integrity validator final MessageIntegrity _messageIntegrity; - // The last message we've consumed - proto.Message? _lastMessage; - // The current position in the input log that we are looking at - int _currentPosition; - // The current message we're looking at - proto.Message? _currentMessage; - // If we have reached the end - bool _isDone = false; + /// The last message we reconciled/output + proto.Message? _previousMessage; - // Desired maximum window length - static const int _maxWindowLength = 256; + /// The current message we're looking at + proto.Message? _currentMessage; + + /// The current position in the input source that we are looking at + int _inputPosition; + + /// The current input window from the InputSource; + InputWindow? _currentWindow; + + /// Desired maximum window length + static const _maxWindowLength = 256; } diff --git a/lib/chat/cubits/reconciliation/author_input_source.dart b/lib/chat/cubits/reconciliation/author_input_source.dart index 32a750e..e7ba765 100644 --- a/lib/chat/cubits/reconciliation/author_input_source.dart +++ b/lib/chat/cubits/reconciliation/author_input_source.dart @@ -9,69 +9,68 @@ import '../../../proto/proto.dart' as proto; @immutable class InputWindow { - const InputWindow( - {required this.elements, required this.first, required this.last}); + const InputWindow({required this.elements, required this.firstPosition}) + : lastPosition = firstPosition + elements.length - 1, + isEmpty = elements.length == 0, + length = elements.length; + final IList> elements; - final int first; - final int last; + final int firstPosition; + final int lastPosition; + final bool isEmpty; + final int length; } class AuthorInputSource { - AuthorInputSource.fromCubit( - {required DHTLogStateData cubitState, - required this.cubit}) { - _currentWindow = InputWindow( - elements: cubitState.window, - first: (cubitState.windowTail - cubitState.window.length) % - cubitState.length, - last: (cubitState.windowTail - 1) % cubitState.length); - } + AuthorInputSource.fromDHTLog(DHTLog dhtLog) : _dhtLog = dhtLog; //////////////////////////////////////////////////////////////////////////// - InputWindow get currentWindow => _currentWindow; + Future getTailPosition() => + _dhtLog.operate((reader) async => reader.length); - Future> updateWindow( - int currentPosition, int windowLength) async => - cubit.operate((reader) async { - // See if we're beyond the input source - if (currentPosition < 0 || currentPosition >= reader.length) { - return const AsyncValue.data(false); - } - - // Slide the window if we need to - var first = _currentWindow.first; - var last = _currentWindow.last; - if (currentPosition < first) { - // Slide it backward, current position is now last - first = max((currentPosition - windowLength) + 1, 0); - last = currentPosition; - } else if (currentPosition > last) { - // Slide it forward, current position is now first - first = currentPosition; - last = min((currentPosition + windowLength) - 1, reader.length - 1); - } else { - return const AsyncValue.data(true); + Future> getWindow( + int startPosition, int windowLength) => + _dhtLog.operate((reader) async { + // Don't allow negative length + if (windowLength <= 0) { + return const AsyncValue.data(null); } + // Trim if we're beyond input source + var endPosition = startPosition + windowLength - 1; + startPosition = max(startPosition, 0); + endPosition = max(endPosition, 0); // Get another input batch futher back - final nextWindow = await cubit.loadElementsFromReader( - reader, last + 1, (last + 1) - first); - final asErr = nextWindow.asError; - if (asErr != null) { - return AsyncValue.error(asErr.error, asErr.stackTrace); + try { + Set? offlinePositions; + if (_dhtLog.writer != null) { + offlinePositions = await reader.getOfflinePositions(); + } + + final messages = await reader.getRangeProtobuf( + proto.Message.fromBuffer, startPosition, + length: endPosition - startPosition + 1); + if (messages == null) { + return const AsyncValue.loading(); + } + + final elements = messages.indexed + .map((x) => OnlineElementState( + value: x.$2, + isOffline: offlinePositions?.contains(x.$1 + startPosition) ?? + false)) + .toIList(); + + final window = + InputWindow(elements: elements, firstPosition: startPosition); + + return AsyncValue.data(window); + } on Exception catch (e, st) { + return AsyncValue.error(e, st); } - final asLoading = nextWindow.asLoading; - if (asLoading != null) { - return const AsyncValue.loading(); - } - _currentWindow = InputWindow( - elements: nextWindow.asData!.value, first: first, last: last); - return const AsyncValue.data(true); }); //////////////////////////////////////////////////////////////////////////// - final DHTLogCubit cubit; - - late InputWindow _currentWindow; + final DHTLog _dhtLog; } diff --git a/lib/chat/cubits/reconciliation/message_integrity.dart b/lib/chat/cubits/reconciliation/message_integrity.dart index 2fd1956..40b3b18 100644 --- a/lib/chat/cubits/reconciliation/message_integrity.dart +++ b/lib/chat/cubits/reconciliation/message_integrity.dart @@ -19,7 +19,7 @@ class MessageIntegrity { //////////////////////////////////////////////////////////////////////////// // Public interface - Future generateMessageId(proto.Message? previous) async { + Future generateMessageId(proto.Message? previous) { if (previous == null) { // If there's no last sent message, // we start at a hash of the identity public key @@ -47,7 +47,12 @@ class MessageIntegrity { message.signature = signature.toProto(); } - Future verifyMessage(proto.Message message) async { + // XXX: change bool to an enum to allow for reconciling deleted + // XXX: messages. if a message is deleted it will not verify, but its + // XXX: signature will still be in place for the message chain. + // XXX: it should be added to a list to check for a ControlDelete that + // XXX: appears later in the message log. + Future verifyMessage(proto.Message message) { // Ensure the message is signed assert(message.hasSignature(), 'should not verify unsigned message'); final signature = message.signature.toVeilid(); diff --git a/lib/chat/cubits/reconciliation/message_reconciliation.dart b/lib/chat/cubits/reconciliation/message_reconciliation.dart index f0b8c4c..f6d46c3 100644 --- a/lib/chat/cubits/reconciliation/message_reconciliation.dart +++ b/lib/chat/cubits/reconciliation/message_reconciliation.dart @@ -6,6 +6,7 @@ import 'package:sorted_list/sorted_list.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../../proto/proto.dart' as proto; +import '../../../tools/tools.dart'; import 'author_input_queue.dart'; import 'author_input_source.dart'; import 'output_position.dart'; @@ -19,96 +20,151 @@ class MessageReconciliation { //////////////////////////////////////////////////////////////////////////// - void reconcileMessages( - TypedKey author, - DHTLogStateData inputMessagesCubitState, - DHTLogCubit inputMessagesCubit) { - if (inputMessagesCubitState.window.isEmpty) { - return; - } + void addInputSourceFromDHTLog(TypedKey author, DHTLog inputMessagesDHTLog) { + _inputSources[author] = AuthorInputSource.fromDHTLog(inputMessagesDHTLog); + } - _inputSources[author] = AuthorInputSource.fromCubit( - cubitState: inputMessagesCubitState, cubit: inputMessagesCubit); + void reconcileMessages(TypedKey? author) { + // xxx: can we use 'author' here to optimize _updateAuthorInputQueues? singleFuture(this, onError: _onError, () async { - // Take entire list of input sources we have currently and process them - final inputSources = _inputSources; - _inputSources = {}; - - final inputFuts = >[]; - for (final kv in inputSources.entries) { - final author = kv.key; - final inputSource = kv.value; - inputFuts - .add(_enqueueAuthorInput(author: author, inputSource: inputSource)); - } - final inputQueues = await inputFuts.wait; - - // Make this safe to cast by removing inputs that were rejected or empty - inputQueues.removeNulls(); + // Update queues + final activeInputQueues = await _updateAuthorInputQueues(); // Process all input queues together - await _outputCubit - .operate((reconciledArray) async => _reconcileInputQueues( - reconciledArray: reconciledArray, - inputQueues: inputQueues.cast(), - )); + await _outputCubit.operate((reconciledArray) => _reconcileInputQueues( + reconciledArray: reconciledArray, + activeInputQueues: activeInputQueues, + )); }); } //////////////////////////////////////////////////////////////////////////// + // Prepare author input queues by removing dead ones and adding new ones + // Queues that are empty are not added until they have something in them + // Active input queues with a current message are returned in a list + Future> _updateAuthorInputQueues() async { + // Remove any dead input queues + final deadQueues = []; + for (final author in _inputQueues.keys) { + if (!_inputSources.containsKey(author)) { + deadQueues.add(author); + } + } + for (final author in deadQueues) { + _inputQueues.remove(author); + _outputPositions.remove(author); + } + + await _outputCubit.operate((outputArray) async { + final dws = DelayedWaitSet(); + + for (final kv in _inputSources.entries) { + final author = kv.key; + final inputSource = kv.value; + + final iqExisting = _inputQueues[author]; + if (iqExisting == null || iqExisting.inputSource != inputSource) { + dws.add((_) async { + try { + await _enqueueAuthorInput( + author: author, + inputSource: inputSource, + outputArray: outputArray); + // Catch everything so we can avoid ParallelWaitError + // ignore: avoid_catches_without_on_clauses + } catch (e, st) { + log.error('Exception updating author input queue: $e:\n$st\n'); + _inputQueues.remove(author); + _outputPositions.remove(author); + } + }); + } + } + + await dws(); + }); + + // Get the active input queues + final activeInputQueues = await _inputQueues.entries + .map((entry) async { + if (await entry.value.getCurrentMessage() != null) { + return entry.value; + } else { + return null; + } + }) + .toList() + .wait + ..removeNulls(); + + return activeInputQueues.cast(); + } + // Set up a single author's message reconciliation - Future _enqueueAuthorInput( + Future _enqueueAuthorInput( {required TypedKey author, - required AuthorInputSource inputSource}) async { + required AuthorInputSource inputSource, + required TableDBArrayProtobuf + outputArray}) async { // Get the position of our most recent reconciled message from this author - final outputPosition = await _findLastOutputPosition(author: author); + final outputPosition = + await _findLastOutputPosition(author: author, outputArray: outputArray); // Find oldest message we have not yet reconciled final inputQueue = await AuthorInputQueue.create( author: author, inputSource: inputSource, - outputPosition: outputPosition, + previousMessage: outputPosition?.message.content, onError: _onError, ); - return inputQueue; + + if (inputQueue != null) { + _inputQueues[author] = inputQueue; + _outputPositions[author] = outputPosition; + } else { + _inputQueues.remove(author); + _outputPositions.remove(author); + } } // Get the position of our most recent reconciled message from this author // XXX: For a group chat, this should find when the author // was added to the membership so we don't just go back in time forever Future _findLastOutputPosition( - {required TypedKey author}) async => - _outputCubit.operate((arr) async { - var pos = arr.length - 1; - while (pos >= 0) { - final message = await arr.get(pos); - if (message.content.author.toVeilid() == author) { - return OutputPosition(message, pos); - } - pos--; - } - return null; - }); + {required TypedKey author, + required TableDBArrayProtobuf + outputArray}) async { + var pos = outputArray.length - 1; + while (pos >= 0) { + final message = await outputArray.get(pos); + if (message.content.author.toVeilid() == author) { + return OutputPosition(message, pos); + } + pos--; + } + return null; + } // Process a list of author input queues and insert their messages // into the output array, performing validation steps along the way Future _reconcileInputQueues({ required TableDBArrayProtobuf reconciledArray, - required List inputQueues, + required List activeInputQueues, }) async { - // Ensure queues all have something to do - inputQueues.removeWhere((q) => q.isDone); - if (inputQueues.isEmpty) { + // Ensure we have active queues to process + if (activeInputQueues.isEmpty) { return; } // Sort queues from earliest to latest and then by author // to ensure a deterministic insert order - inputQueues.sort((a, b) { - final acmp = a.outputPosition?.pos ?? -1; - final bcmp = b.outputPosition?.pos ?? -1; + activeInputQueues.sort((a, b) { + final aout = _outputPositions[a.author]; + final bout = _outputPositions[b.author]; + final acmp = aout?.pos ?? -1; + final bcmp = bout?.pos ?? -1; if (acmp == bcmp) { return a.author.toString().compareTo(b.author.toString()); } @@ -116,21 +172,28 @@ class MessageReconciliation { }); // Start at the earliest position we know about in all the queues - var currentOutputPosition = inputQueues.first.outputPosition; + var currentOutputPosition = + _outputPositions[activeInputQueues.first.author]; final toInsert = SortedList(proto.MessageExt.compareTimestamp); - while (inputQueues.isNotEmpty) { + while (activeInputQueues.isNotEmpty) { // Get up to '_maxReconcileChunk' of the items from the queues // that we can insert at this location bool added; do { added = false; - var someQueueEmpty = false; - for (final inputQueue in inputQueues) { - final inputCurrent = inputQueue.current!; + + final emptyQueues = {}; + for (final inputQueue in activeInputQueues) { + final inputCurrent = await inputQueue.getCurrentMessage(); + if (inputCurrent == null) { + log.error('Active input queue did not have a current message: ' + '${inputQueue.author}'); + continue; + } if (currentOutputPosition == null || inputCurrent.timestamp < currentOutputPosition.message.content.timestamp) { @@ -138,16 +201,14 @@ class MessageReconciliation { added = true; // Advance this queue - if (!await inputQueue.consume()) { - // Queue is empty now, run a queue purge - someQueueEmpty = true; + if (!await inputQueue.advance()) { + // Mark queue as empty for removal + emptyQueues.add(inputQueue); } } } - // Remove empty queues now that we're done iterating - if (someQueueEmpty) { - inputQueues.removeWhere((q) => q.isDone); - } + // Remove finished queues now that we're done iterating + activeInputQueues.removeWhere(emptyQueues.contains); if (toInsert.length >= _maxReconcileChunk) { break; @@ -165,9 +226,27 @@ class MessageReconciliation { ..content = message) .toList(); - await reconciledArray.insertAll( - currentOutputPosition?.pos ?? reconciledArray.length, - reconciledInserts); + // Figure out where to insert the reconciled messages + final insertPos = currentOutputPosition?.pos ?? reconciledArray.length; + + // Insert them all at once + await reconciledArray.insertAll(insertPos, reconciledInserts); + + // Update output positions for input queues + final updatePositions = _outputPositions.keys.toSet(); + var outputPos = insertPos + reconciledInserts.length; + for (final inserted in reconciledInserts.reversed) { + if (updatePositions.isEmpty) { + // Last seen positions already recorded for each active author + break; + } + outputPos--; + final author = inserted.content.author.toVeilid(); + if (updatePositions.contains(author)) { + _outputPositions[author] = OutputPosition(inserted, outputPos); + updatePositions.remove(author); + } + } toInsert.clear(); } else { @@ -187,9 +266,11 @@ class MessageReconciliation { //////////////////////////////////////////////////////////////////////////// - Map _inputSources = {}; + final Map _inputSources = {}; + final Map _inputQueues = {}; + final Map _outputPositions = {}; final TableDBArrayProtobufCubit _outputCubit; final void Function(Object, StackTrace?) _onError; - static const int _maxReconcileChunk = 65536; + static const _maxReconcileChunk = 65536; } diff --git a/lib/chat/cubits/single_contact_messages_cubit.dart b/lib/chat/cubits/single_contact_messages_cubit.dart index 0894ac1..0ec1037 100644 --- a/lib/chat/cubits/single_contact_messages_cubit.dart +++ b/lib/chat/cubits/single_contact_messages_cubit.dart @@ -2,8 +2,9 @@ import 'dart:async'; import 'package:async_tools/async_tools.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:uuid/uuid.dart'; +import 'package:uuid/v4.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../account_manager/account_manager.dart'; @@ -12,9 +13,12 @@ import '../../tools/tools.dart'; import '../models/models.dart'; import 'reconciliation/reconciliation.dart'; +const _sfSendMessageTag = 'sfSendMessageTag'; + class RenderStateElement { RenderStateElement( - {required this.message, + {required this.seqId, + required this.message, required this.isLocal, this.reconciledTimestamp, this.sent = false, @@ -37,6 +41,7 @@ class RenderStateElement { return null; } + int seqId; proto.Message message; bool isLocal; Timestamp? reconciledTimestamp; @@ -50,13 +55,13 @@ typedef SingleContactMessagesState = AsyncValue>; // Builds the reconciled chat record from the local and remote conversation keys class SingleContactMessagesCubit extends Cubit { SingleContactMessagesCubit({ - required ActiveAccountInfo activeAccountInfo, + required AccountInfo accountInfo, required TypedKey remoteIdentityPublicKey, required TypedKey localConversationRecordKey, required TypedKey localMessagesRecordKey, required TypedKey remoteConversationRecordKey, - required TypedKey remoteMessagesRecordKey, - }) : _activeAccountInfo = activeAccountInfo, + required TypedKey? remoteMessagesRecordKey, + }) : _accountInfo = accountInfo, _remoteIdentityPublicKey = remoteIdentityPublicKey, _localConversationRecordKey = localConversationRecordKey, _localMessagesRecordKey = localMessagesRecordKey, @@ -72,26 +77,41 @@ class SingleContactMessagesCubit extends Cubit { Future close() async { await _initWait(); + await serialFutureClose((this, _sfSendMessageTag)); + await _commandController.close(); await _commandRunnerFut; await _unsentMessagesQueue.close(); await _sentSubscription?.cancel(); await _rcvdSubscription?.cancel(); await _reconciledSubscription?.cancel(); - await _sentMessagesCubit?.close(); - await _rcvdMessagesCubit?.close(); + await _sentMessagesDHTLog?.close(); + await _rcvdMessagesDHTLog?.close(); await _reconciledMessagesCubit?.close(); + + // If the local conversation record is gone, then delete the reconciled + // messages table as well + final conversationDead = await DHTRecordPool.instance + .isDeletedRecordKey(_localConversationRecordKey); + if (conversationDead) { + await SingleContactMessagesCubit.cleanupAndDeleteMessages( + localConversationRecordKey: _localConversationRecordKey); + } + await super.close(); } // Initialize everything - Future _init() async { + Future _init(Completer _) async { _unsentMessagesQueue = PersistentQueue( - table: 'SingleContactUnsentMessages', - key: _remoteConversationRecordKey.toString(), - fromBuffer: proto.Message.fromBuffer, - closure: _processUnsentMessages, - ); + table: 'SingleContactUnsentMessages', + key: _remoteConversationRecordKey.toString(), + fromBuffer: proto.Message.fromBuffer, + toBuffer: (x) => x.writeToBuffer(), + closure: _processUnsentMessages, + onError: (e, st) { + log.error('Exception while processing unsent messages: $e\n$st\n'); + }); // Make crypto await _initCrypto(); @@ -100,56 +120,93 @@ class SingleContactMessagesCubit extends Cubit { await _initReconciledMessagesCubit(); // Local messages key - await _initSentMessagesCubit(); + await _initSentMessagesDHTLog(); // Remote messages key - await _initRcvdMessagesCubit(); + await _initRcvdMessagesDHTLog(); // Command execution background process _commandRunnerFut = Future.delayed(Duration.zero, _commandRunner); + + // Run reconciliation once for all input queues + _reconciliation.reconcileMessages(null); } // Make crypto Future _initCrypto() async { - _conversationCrypto = await _activeAccountInfo - .makeConversationCrypto(_remoteIdentityPublicKey); + _conversationCrypto = + await _accountInfo.makeConversationCrypto(_remoteIdentityPublicKey); _senderMessageIntegrity = await MessageIntegrity.create( - author: _activeAccountInfo.identityTypedPublicKey); + author: _accountInfo.identityTypedPublicKey); } // Open local messages key - Future _initSentMessagesCubit() async { - final writer = _activeAccountInfo.identityWriter; + Future _initSentMessagesDHTLog() async { + final writer = _accountInfo.identityWriter; - _sentMessagesCubit = DHTLogCubit( - open: () async => DHTLog.openWrite(_localMessagesRecordKey, writer, + final sentMessagesDHTLog = + await DHTLog.openWrite(_localMessagesRecordKey, writer, debugName: 'SingleContactMessagesCubit::_initSentMessagesCubit::' 'SentMessages', parent: _localConversationRecordKey, - crypto: _conversationCrypto), - decodeElement: proto.Message.fromBuffer); - _sentSubscription = - _sentMessagesCubit!.stream.listen(_updateSentMessagesState); - _updateSentMessagesState(_sentMessagesCubit!.state); + crypto: _conversationCrypto); + _sentSubscription = await sentMessagesDHTLog.listen(_updateSentMessages); + + _sentMessagesDHTLog = sentMessagesDHTLog; + _reconciliation.addInputSourceFromDHTLog( + _accountInfo.identityTypedPublicKey, sentMessagesDHTLog); } // Open remote messages key - Future _initRcvdMessagesCubit() async { - _rcvdMessagesCubit = DHTLogCubit( - open: () async => DHTLog.openRead(_remoteMessagesRecordKey, - debugName: 'SingleContactMessagesCubit::_initRcvdMessagesCubit::' - 'RcvdMessages', - parent: _remoteConversationRecordKey, - crypto: _conversationCrypto), - decodeElement: proto.Message.fromBuffer); - _rcvdSubscription = - _rcvdMessagesCubit!.stream.listen(_updateRcvdMessagesState); - _updateRcvdMessagesState(_rcvdMessagesCubit!.state); + Future _initRcvdMessagesDHTLog() async { + // Don't bother if we don't have a remote messages record key yet + if (_remoteMessagesRecordKey == null) { + return; + } + + // Open new cubit if one is desired + final rcvdMessagesDHTLog = await DHTLog.openRead(_remoteMessagesRecordKey!, + debugName: 'SingleContactMessagesCubit::_initRcvdMessagesCubit::' + 'RcvdMessages', + parent: _remoteConversationRecordKey, + crypto: _conversationCrypto); + _rcvdSubscription = await rcvdMessagesDHTLog.listen(_updateRcvdMessages); + + _rcvdMessagesDHTLog = rcvdMessagesDHTLog; + _reconciliation.addInputSourceFromDHTLog( + _remoteIdentityPublicKey, rcvdMessagesDHTLog); } - Future _makeLocalMessagesCrypto() async => + void updateRemoteMessagesRecordKey(TypedKey? remoteMessagesRecordKey) { + _sspRemoteConversationRecordKey.updateState(remoteMessagesRecordKey, + (remoteMessagesRecordKey) async { + await _initWait(); + + // Don't bother if nothing is changing + if (_remoteMessagesRecordKey == remoteMessagesRecordKey) { + return; + } + + // Close existing DHTLog if we have one + final rcvdMessagesDHTLog = _rcvdMessagesDHTLog; + _rcvdMessagesDHTLog = null; + _remoteMessagesRecordKey = null; + await _rcvdSubscription?.cancel(); + _rcvdSubscription = null; + await rcvdMessagesDHTLog?.close(); + + // Init the new DHTLog if we should + _remoteMessagesRecordKey = remoteMessagesRecordKey; + await _initRcvdMessagesDHTLog(); + + // Run reconciliation once for all input queues + _reconciliation.reconcileMessages(null); + }); + } + + Future _makeLocalMessagesCrypto() => VeilidCryptoPrivate.fromTypedKey( - _activeAccountInfo.userLogin.identitySecret, 'tabledb'); + _accountInfo.userLogin!.identitySecret, 'tabledb'); // Open reconciled chat record key Future _initReconciledMessagesCubit() async { @@ -159,7 +216,7 @@ class SingleContactMessagesCubit extends Cubit { final crypto = await _makeLocalMessagesCrypto(); _reconciledMessagesCubit = TableDBArrayProtobufCubit( - open: () async => TableDBArrayProtobuf.make( + open: () => TableDBArrayProtobuf.make( table: tableName, crypto: crypto, fromBuffer: proto.ReconciledMessage.fromBuffer), @@ -168,6 +225,7 @@ class SingleContactMessagesCubit extends Cubit { _reconciliation = MessageReconciliation( output: _reconciledMessagesCubit!, onError: (e, st) { + addError(e, st); emit(AsyncValue.error(e, st)); }); @@ -205,7 +263,7 @@ class SingleContactMessagesCubit extends Cubit { void runCommand(String command) { final (cmd, rest) = command.splitOnce(' '); - if (kDebugMode) { + if (kIsDebugMode) { if (cmd == '/repeat' && rest != null) { final (countStr, text) = rest.splitOnce(' '); final count = int.tryParse(countStr); @@ -232,30 +290,15 @@ class SingleContactMessagesCubit extends Cubit { //////////////////////////////////////////////////////////////////////////// // Internal implementation - // Called when the sent messages cubit gets a change + // Called when the sent messages DHTLog gets a change // This will re-render when messages are sent from another machine - void _updateSentMessagesState(DHTLogBusyState avmessages) { - final sentMessages = avmessages.state.asData?.value; - if (sentMessages == null) { - return; - } - - _reconciliation.reconcileMessages(_activeAccountInfo.identityTypedPublicKey, - sentMessages, _sentMessagesCubit!); - - // Update the view - _renderState(); + void _updateSentMessages(DHTLogUpdate upd) { + _reconciliation.reconcileMessages(_accountInfo.identityTypedPublicKey); } - // Called when the received messages cubit gets a change - void _updateRcvdMessagesState(DHTLogBusyState avmessages) { - final rcvdMessages = avmessages.state.asData?.value; - if (rcvdMessages == null) { - return; - } - - _reconciliation.reconcileMessages( - _remoteIdentityPublicKey, rcvdMessages, _rcvdMessagesCubit!); + // Called when the received messages DHTLog gets a change + void _updateRcvdMessages(DHTLogUpdate upd) { + _reconciliation.reconcileMessages(_remoteIdentityPublicKey); } // Called when the reconciled messages window gets a change @@ -267,46 +310,60 @@ class SingleContactMessagesCubit extends Cubit { Future _processMessageToSend( proto.Message message, proto.Message? previousMessage) async { - // Get the previous message if we don't have one - previousMessage ??= await _sentMessagesCubit!.operate((r) async => - r.length == 0 - ? null - : await r.getProtobuf(proto.Message.fromBuffer, r.length - 1)); - - message.id = - await _senderMessageIntegrity.generateMessageId(previousMessage); + // It's possible we had a signature from a previous + // operateAppendEventual attempt, so clear it and make a new message id too + message + ..clearSignature() + ..id = await _senderMessageIntegrity.generateMessageId(previousMessage); // Now sign it await _senderMessageIntegrity.signMessage( - message, _activeAccountInfo.identitySecretKey); + message, _accountInfo.identitySecretKey); } // Async process to send messages in the background Future _processUnsentMessages(IList messages) async { - // Go through and assign ids to all the messages in order - proto.Message? previousMessage; - final processedMessages = messages.toList(); - for (final message in processedMessages) { - await _processMessageToSend(message, previousMessage); - previousMessage = message; - } + try { + await _sentMessagesDHTLog!.operateAppendEventual((writer) async { + // Get the previous message if we have one + var previousMessage = writer.length == 0 + ? null + : await writer.getProtobuf( + proto.Message.fromBuffer, writer.length - 1); - await _sentMessagesCubit!.operateAppendEventual((writer) => - writer.addAll(messages.map((m) => m.writeToBuffer()).toList())); + // Sign all messages + final processedMessages = messages.toList(); + for (final message in processedMessages) { + try { + await _processMessageToSend(message, previousMessage); + previousMessage = message; + } on Exception catch (e, st) { + log.error('Exception processing unsent message: $e:\n$st\n'); + } + } + final byteMessages = messages.map((m) => m.writeToBuffer()).toList(); + + return writer.addAll(byteMessages); + }); + } on Exception catch (e, st) { + log.error('Exception appending unsent messages: $e:\n$st\n'); + } } // Produce a state for this cubit from the input cubits and queues void _renderState() { - // Get all reconciled messages + // Get all reconciled messages in the cubit window final reconciledMessages = _reconciledMessagesCubit?.state.state.asData?.value; - // Get all sent messages - final sentMessages = _sentMessagesCubit?.state.state.asData?.value; + + // Get all sent messages that are still offline + //final sentMessages = _sentMessagesDHTLog. + // Get all items in the unsent queue - // final unsentMessages = _unsentMessagesQueue.queue; + final unsentMessages = _unsentMessagesQueue.queue; // If we aren't ready to render a state, say we're loading - if (reconciledMessages == null || sentMessages == null) { + if (reconciledMessages == null) { emit(const AsyncLoading()); return; } @@ -315,41 +372,80 @@ class SingleContactMessagesCubit extends Cubit { // final reconciledMessagesMap = // IMap.fromValues( // keyMapper: (x) => x.content.authorUniqueIdString, - // values: reconciledMessages.elements, + // values: reconciledMessages.windowElements, + // ); + // final sentMessagesMap = + // IMap>.fromValues( + // keyMapper: (x) => x.value.authorUniqueIdString, + // values: sentMessages.window, // ); - final sentMessagesMap = - IMap>.fromValues( - keyMapper: (x) => x.value.authorUniqueIdString, - values: sentMessages.window, - ); // final unsentMessagesMap = IMap.fromValues( // keyMapper: (x) => x.authorUniqueIdString, // values: unsentMessages, // ); + // List of all rendered state elements that we will turn into + // message states final renderedElements = []; - for (final m in reconciledMessages.windowElements) { - final isLocal = m.content.author.toVeilid() == - _activeAccountInfo.identityTypedPublicKey; - final reconciledTimestamp = Timestamp.fromInt64(m.reconciledTime); - final sm = - isLocal ? sentMessagesMap[m.content.authorUniqueIdString] : null; - final sent = isLocal && sm != null; - final sentOffline = isLocal && sm != null && sm.isOffline; + // Keep track of the ids we have rendered + // because there can be an overlap between the 'unsent messages' + // and the reconciled messages as the async state catches up + final renderedIds = {}; + var seqId = (reconciledMessages.windowTail == 0 + ? reconciledMessages.length + : reconciledMessages.windowTail) - + reconciledMessages.windowElements.length; + for (final m in reconciledMessages.windowElements) { + final isLocal = + m.content.author.toVeilid() == _accountInfo.identityTypedPublicKey; + final reconciledTimestamp = Timestamp.fromInt64(m.reconciledTime); + //final sm = + //isLocal ? sentMessagesMap[m.content.authorUniqueIdString] : null; + //final sent = isLocal && sm != null; + //final sentOffline = isLocal && sm != null && sm.isOffline; + final sent = isLocal; + final sentOffline = false; // + + if (renderedIds.contains(m.content.authorUniqueIdString)) { + seqId++; + continue; + } renderedElements.add(RenderStateElement( + seqId: seqId, message: m.content, isLocal: isLocal, reconciledTimestamp: reconciledTimestamp, sent: sent, sentOffline: sentOffline, )); + renderedIds.add(m.content.authorUniqueIdString); + seqId++; + } + + // Render in-flight messages at the bottom + + for (final m in unsentMessages) { + if (renderedIds.contains(m.authorUniqueIdString)) { + seqId++; + continue; + } + renderedElements.add(RenderStateElement( + seqId: seqId, + message: m, + isLocal: true, + sent: true, + sentOffline: true, + )); + renderedIds.add(m.authorUniqueIdString); + seqId++; } // Render the state final messages = renderedElements .map((x) => MessageState( + seqId: x.seqId, content: x.message, sentTimestamp: Timestamp.fromInt64(x.message.timestamp), reconciledTimestamp: x.reconciledTimestamp, @@ -367,16 +463,26 @@ class SingleContactMessagesCubit extends Cubit { void _sendMessage({required proto.Message message}) { // Add common fields - // id and signature will get set by _processMessageToSend + // real id and signature will get set by _processMessageToSend + // temporary id set here is random and not 'valid' in the eyes + // of reconcilation, noting that reconciled timestamp is not yet set. message - ..author = _activeAccountInfo.identityTypedPublicKey.toProto() - ..timestamp = Veilid.instance.now().toInt64(); + ..author = _accountInfo.identityTypedPublicKey.toProto() + ..timestamp = Veilid.instance.now().toInt64() + ..id = Uuid.parse(_uuidGen.generate()); + + if ((message.writeToBuffer().lengthInBytes + 256) > 4096) { + throw const FormatException('message is too long'); + } // Put in the queue - _unsentMessagesQueue.addSync(message); + serialFuture((this, _sfSendMessageTag), () async { + // Add the message to the persistent queue + await _unsentMessagesQueue.add(message); - // Update the view - _renderState(); + // Update the view + _renderState(); + }); } Future _commandRunner() async { @@ -401,29 +507,31 @@ class SingleContactMessagesCubit extends Cubit { ///////////////////////////////////////////////////////////////////////// - final WaitSet _initWait = WaitSet(); - final ActiveAccountInfo _activeAccountInfo; + final WaitSet _initWait = WaitSet(); + late final AccountInfo _accountInfo; final TypedKey _remoteIdentityPublicKey; final TypedKey _localConversationRecordKey; final TypedKey _localMessagesRecordKey; final TypedKey _remoteConversationRecordKey; - final TypedKey _remoteMessagesRecordKey; + TypedKey? _remoteMessagesRecordKey; late final VeilidCrypto _conversationCrypto; late final MessageIntegrity _senderMessageIntegrity; - DHTLogCubit? _sentMessagesCubit; - DHTLogCubit? _rcvdMessagesCubit; + DHTLog? _sentMessagesDHTLog; + DHTLog? _rcvdMessagesDHTLog; TableDBArrayProtobufCubit? _reconciledMessagesCubit; late final MessageReconciliation _reconciliation; late final PersistentQueue _unsentMessagesQueue; - - StreamSubscription>? _sentSubscription; - StreamSubscription>? _rcvdSubscription; + StreamSubscription? _sentSubscription; + StreamSubscription? _rcvdSubscription; StreamSubscription>? _reconciledSubscription; final StreamController Function()> _commandController; late final Future _commandRunnerFut; + + final _sspRemoteConversationRecordKey = SingleStateProcessor(); + final _uuidGen = const UuidV4(); } diff --git a/lib/chat/models/chat_component_state.dart b/lib/chat/models/chat_component_state.dart index b8da8d4..1a52524 100644 --- a/lib/chat/models/chat_component_state.dart +++ b/lib/chat/models/chat_component_state.dart @@ -1,34 +1,26 @@ import 'package:async_tools/async_tools.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart' show Message, User; -import 'package:flutter_chat_ui/flutter_chat_ui.dart' show ChatState; +import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:scroll_to_index/scroll_to_index.dart'; -import 'package:veilid_support/veilid_support.dart'; import 'window_state.dart'; part 'chat_component_state.freezed.dart'; @freezed -class ChatComponentState with _$ChatComponentState { +sealed class ChatComponentState with _$ChatComponentState { const factory ChatComponentState( { - // GlobalKey for the chat - required GlobalKey chatKey, - // ScrollController for the chat - required AutoScrollController scrollController, // Local user - required User localUser, - // Remote users - required IMap remoteUsers, + required User? localUser, + // Active remote users + required IMap remoteUsers, + // Historical remote users + required IMap historicalRemoteUsers, + // Unknown users + required IMap unknownUsers, // Messages state required AsyncValue> messageWindow, // Title of the chat required String title}) = _ChatComponentState; } - -extension ChatComponentStateExt on ChatComponentState { - // -} diff --git a/lib/chat/models/chat_component_state.freezed.dart b/lib/chat/models/chat_component_state.freezed.dart index 859f363..bbecc7a 100644 --- a/lib/chat/models/chat_component_state.freezed.dart +++ b/lib/chat/models/chat_component_state.freezed.dart @@ -1,3 +1,4 @@ +// dart format width=80 // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint @@ -9,195 +10,170 @@ part of 'chat_component_state.dart'; // FreezedGenerator // ************************************************************************** +// dart format off T _$identity(T value) => value; -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - /// @nodoc mixin _$ChatComponentState { -// GlobalKey for the chat - GlobalKey get chatKey => - throw _privateConstructorUsedError; // ScrollController for the chat - AutoScrollController get scrollController => - throw _privateConstructorUsedError; // Local user - User get localUser => throw _privateConstructorUsedError; // Remote users - IMap, User> get remoteUsers => - throw _privateConstructorUsedError; // Messages state - AsyncValue> get messageWindow => - throw _privateConstructorUsedError; // Title of the chat - String get title => throw _privateConstructorUsedError; +// Local user + User? get localUser; // Active remote users + IMap get remoteUsers; // Historical remote users + IMap get historicalRemoteUsers; // Unknown users + IMap get unknownUsers; // Messages state + AsyncValue> get messageWindow; // Title of the chat + String get title; - @JsonKey(ignore: true) + /// Create a copy of ChatComponentState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') $ChatComponentStateCopyWith get copyWith => - throw _privateConstructorUsedError; + _$ChatComponentStateCopyWithImpl( + this as ChatComponentState, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is ChatComponentState && + (identical(other.localUser, localUser) || + other.localUser == localUser) && + (identical(other.remoteUsers, remoteUsers) || + other.remoteUsers == remoteUsers) && + (identical(other.historicalRemoteUsers, historicalRemoteUsers) || + other.historicalRemoteUsers == historicalRemoteUsers) && + (identical(other.unknownUsers, unknownUsers) || + other.unknownUsers == unknownUsers) && + (identical(other.messageWindow, messageWindow) || + other.messageWindow == messageWindow) && + (identical(other.title, title) || other.title == title)); + } + + @override + int get hashCode => Object.hash(runtimeType, localUser, remoteUsers, + historicalRemoteUsers, unknownUsers, messageWindow, title); + + @override + String toString() { + return 'ChatComponentState(localUser: $localUser, remoteUsers: $remoteUsers, historicalRemoteUsers: $historicalRemoteUsers, unknownUsers: $unknownUsers, messageWindow: $messageWindow, title: $title)'; + } } /// @nodoc -abstract class $ChatComponentStateCopyWith<$Res> { +abstract mixin class $ChatComponentStateCopyWith<$Res> { factory $ChatComponentStateCopyWith( - ChatComponentState value, $Res Function(ChatComponentState) then) = - _$ChatComponentStateCopyWithImpl<$Res, ChatComponentState>; + ChatComponentState value, $Res Function(ChatComponentState) _then) = + _$ChatComponentStateCopyWithImpl; @useResult $Res call( - {GlobalKey chatKey, - AutoScrollController scrollController, - User localUser, - IMap, User> remoteUsers, + {User? localUser, + IMap remoteUsers, + IMap historicalRemoteUsers, + IMap unknownUsers, AsyncValue> messageWindow, String title}); + $UserCopyWith<$Res>? get localUser; $AsyncValueCopyWith, $Res> get messageWindow; } /// @nodoc -class _$ChatComponentStateCopyWithImpl<$Res, $Val extends ChatComponentState> +class _$ChatComponentStateCopyWithImpl<$Res> implements $ChatComponentStateCopyWith<$Res> { - _$ChatComponentStateCopyWithImpl(this._value, this._then); + _$ChatComponentStateCopyWithImpl(this._self, this._then); - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; + final ChatComponentState _self; + final $Res Function(ChatComponentState) _then; + /// Create a copy of ChatComponentState + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ - Object? chatKey = null, - Object? scrollController = null, - Object? localUser = null, + Object? localUser = freezed, Object? remoteUsers = null, + Object? historicalRemoteUsers = null, + Object? unknownUsers = null, Object? messageWindow = null, Object? title = null, }) { - return _then(_value.copyWith( - chatKey: null == chatKey - ? _value.chatKey - : chatKey // ignore: cast_nullable_to_non_nullable - as GlobalKey, - scrollController: null == scrollController - ? _value.scrollController - : scrollController // ignore: cast_nullable_to_non_nullable - as AutoScrollController, - localUser: null == localUser - ? _value.localUser + return _then(_self.copyWith( + localUser: freezed == localUser + ? _self.localUser : localUser // ignore: cast_nullable_to_non_nullable - as User, + as User?, remoteUsers: null == remoteUsers - ? _value.remoteUsers + ? _self.remoteUsers : remoteUsers // ignore: cast_nullable_to_non_nullable - as IMap, User>, + as IMap, + historicalRemoteUsers: null == historicalRemoteUsers + ? _self.historicalRemoteUsers + : historicalRemoteUsers // ignore: cast_nullable_to_non_nullable + as IMap, + unknownUsers: null == unknownUsers + ? _self.unknownUsers + : unknownUsers // ignore: cast_nullable_to_non_nullable + as IMap, messageWindow: null == messageWindow - ? _value.messageWindow + ? _self.messageWindow : messageWindow // ignore: cast_nullable_to_non_nullable as AsyncValue>, title: null == title - ? _value.title + ? _self.title : title // ignore: cast_nullable_to_non_nullable as String, - ) as $Val); + )); } + /// Create a copy of ChatComponentState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $UserCopyWith<$Res>? get localUser { + if (_self.localUser == null) { + return null; + } + + return $UserCopyWith<$Res>(_self.localUser!, (value) { + return _then(_self.copyWith(localUser: value)); + }); + } + + /// Create a copy of ChatComponentState + /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') $AsyncValueCopyWith, $Res> get messageWindow { - return $AsyncValueCopyWith, $Res>(_value.messageWindow, + return $AsyncValueCopyWith, $Res>(_self.messageWindow, (value) { - return _then(_value.copyWith(messageWindow: value) as $Val); + return _then(_self.copyWith(messageWindow: value)); }); } } /// @nodoc -abstract class _$$ChatComponentStateImplCopyWith<$Res> - implements $ChatComponentStateCopyWith<$Res> { - factory _$$ChatComponentStateImplCopyWith(_$ChatComponentStateImpl value, - $Res Function(_$ChatComponentStateImpl) then) = - __$$ChatComponentStateImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {GlobalKey chatKey, - AutoScrollController scrollController, - User localUser, - IMap, User> remoteUsers, - AsyncValue> messageWindow, - String title}); - @override - $AsyncValueCopyWith, $Res> get messageWindow; -} - -/// @nodoc -class __$$ChatComponentStateImplCopyWithImpl<$Res> - extends _$ChatComponentStateCopyWithImpl<$Res, _$ChatComponentStateImpl> - implements _$$ChatComponentStateImplCopyWith<$Res> { - __$$ChatComponentStateImplCopyWithImpl(_$ChatComponentStateImpl _value, - $Res Function(_$ChatComponentStateImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? chatKey = null, - Object? scrollController = null, - Object? localUser = null, - Object? remoteUsers = null, - Object? messageWindow = null, - Object? title = null, - }) { - return _then(_$ChatComponentStateImpl( - chatKey: null == chatKey - ? _value.chatKey - : chatKey // ignore: cast_nullable_to_non_nullable - as GlobalKey, - scrollController: null == scrollController - ? _value.scrollController - : scrollController // ignore: cast_nullable_to_non_nullable - as AutoScrollController, - localUser: null == localUser - ? _value.localUser - : localUser // ignore: cast_nullable_to_non_nullable - as User, - remoteUsers: null == remoteUsers - ? _value.remoteUsers - : remoteUsers // ignore: cast_nullable_to_non_nullable - as IMap, User>, - messageWindow: null == messageWindow - ? _value.messageWindow - : messageWindow // ignore: cast_nullable_to_non_nullable - as AsyncValue>, - title: null == title - ? _value.title - : title // ignore: cast_nullable_to_non_nullable - as String, - )); - } -} - -/// @nodoc - -class _$ChatComponentStateImpl implements _ChatComponentState { - const _$ChatComponentStateImpl( - {required this.chatKey, - required this.scrollController, - required this.localUser, +class _ChatComponentState implements ChatComponentState { + const _ChatComponentState( + {required this.localUser, required this.remoteUsers, + required this.historicalRemoteUsers, + required this.unknownUsers, required this.messageWindow, required this.title}); -// GlobalKey for the chat - @override - final GlobalKey chatKey; -// ScrollController for the chat - @override - final AutoScrollController scrollController; // Local user @override - final User localUser; -// Remote users + final User? localUser; +// Active remote users @override - final IMap, User> remoteUsers; + final IMap remoteUsers; +// Historical remote users + @override + final IMap historicalRemoteUsers; +// Unknown users + @override + final IMap unknownUsers; // Messages state @override final AsyncValue> messageWindow; @@ -205,63 +181,136 @@ class _$ChatComponentStateImpl implements _ChatComponentState { @override final String title; + /// Create a copy of ChatComponentState + /// with the given fields replaced by the non-null parameter values. @override - String toString() { - return 'ChatComponentState(chatKey: $chatKey, scrollController: $scrollController, localUser: $localUser, remoteUsers: $remoteUsers, messageWindow: $messageWindow, title: $title)'; - } + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$ChatComponentStateCopyWith<_ChatComponentState> get copyWith => + __$ChatComponentStateCopyWithImpl<_ChatComponentState>(this, _$identity); @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$ChatComponentStateImpl && - (identical(other.chatKey, chatKey) || other.chatKey == chatKey) && - (identical(other.scrollController, scrollController) || - other.scrollController == scrollController) && + other is _ChatComponentState && (identical(other.localUser, localUser) || other.localUser == localUser) && (identical(other.remoteUsers, remoteUsers) || other.remoteUsers == remoteUsers) && + (identical(other.historicalRemoteUsers, historicalRemoteUsers) || + other.historicalRemoteUsers == historicalRemoteUsers) && + (identical(other.unknownUsers, unknownUsers) || + other.unknownUsers == unknownUsers) && (identical(other.messageWindow, messageWindow) || other.messageWindow == messageWindow) && (identical(other.title, title) || other.title == title)); } @override - int get hashCode => Object.hash(runtimeType, chatKey, scrollController, - localUser, remoteUsers, messageWindow, title); + int get hashCode => Object.hash(runtimeType, localUser, remoteUsers, + historicalRemoteUsers, unknownUsers, messageWindow, title); - @JsonKey(ignore: true) + @override + String toString() { + return 'ChatComponentState(localUser: $localUser, remoteUsers: $remoteUsers, historicalRemoteUsers: $historicalRemoteUsers, unknownUsers: $unknownUsers, messageWindow: $messageWindow, title: $title)'; + } +} + +/// @nodoc +abstract mixin class _$ChatComponentStateCopyWith<$Res> + implements $ChatComponentStateCopyWith<$Res> { + factory _$ChatComponentStateCopyWith( + _ChatComponentState value, $Res Function(_ChatComponentState) _then) = + __$ChatComponentStateCopyWithImpl; + @override + @useResult + $Res call( + {User? localUser, + IMap remoteUsers, + IMap historicalRemoteUsers, + IMap unknownUsers, + AsyncValue> messageWindow, + String title}); + + @override + $UserCopyWith<$Res>? get localUser; + @override + $AsyncValueCopyWith, $Res> get messageWindow; +} + +/// @nodoc +class __$ChatComponentStateCopyWithImpl<$Res> + implements _$ChatComponentStateCopyWith<$Res> { + __$ChatComponentStateCopyWithImpl(this._self, this._then); + + final _ChatComponentState _self; + final $Res Function(_ChatComponentState) _then; + + /// Create a copy of ChatComponentState + /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') - _$$ChatComponentStateImplCopyWith<_$ChatComponentStateImpl> get copyWith => - __$$ChatComponentStateImplCopyWithImpl<_$ChatComponentStateImpl>( - this, _$identity); -} + $Res call({ + Object? localUser = freezed, + Object? remoteUsers = null, + Object? historicalRemoteUsers = null, + Object? unknownUsers = null, + Object? messageWindow = null, + Object? title = null, + }) { + return _then(_ChatComponentState( + localUser: freezed == localUser + ? _self.localUser + : localUser // ignore: cast_nullable_to_non_nullable + as User?, + remoteUsers: null == remoteUsers + ? _self.remoteUsers + : remoteUsers // ignore: cast_nullable_to_non_nullable + as IMap, + historicalRemoteUsers: null == historicalRemoteUsers + ? _self.historicalRemoteUsers + : historicalRemoteUsers // ignore: cast_nullable_to_non_nullable + as IMap, + unknownUsers: null == unknownUsers + ? _self.unknownUsers + : unknownUsers // ignore: cast_nullable_to_non_nullable + as IMap, + messageWindow: null == messageWindow + ? _self.messageWindow + : messageWindow // ignore: cast_nullable_to_non_nullable + as AsyncValue>, + title: null == title + ? _self.title + : title // ignore: cast_nullable_to_non_nullable + as String, + )); + } -abstract class _ChatComponentState implements ChatComponentState { - const factory _ChatComponentState( - {required final GlobalKey chatKey, - required final AutoScrollController scrollController, - required final User localUser, - required final IMap, User> remoteUsers, - required final AsyncValue> messageWindow, - required final String title}) = _$ChatComponentStateImpl; - - @override // GlobalKey for the chat - GlobalKey get chatKey; - @override // ScrollController for the chat - AutoScrollController get scrollController; - @override // Local user - User get localUser; - @override // Remote users - IMap, User> get remoteUsers; - @override // Messages state - AsyncValue> get messageWindow; - @override // Title of the chat - String get title; + /// Create a copy of ChatComponentState + /// with the given fields replaced by the non-null parameter values. @override - @JsonKey(ignore: true) - _$$ChatComponentStateImplCopyWith<_$ChatComponentStateImpl> get copyWith => - throw _privateConstructorUsedError; + @pragma('vm:prefer-inline') + $UserCopyWith<$Res>? get localUser { + if (_self.localUser == null) { + return null; + } + + return $UserCopyWith<$Res>(_self.localUser!, (value) { + return _then(_self.copyWith(localUser: value)); + }); + } + + /// Create a copy of ChatComponentState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $AsyncValueCopyWith, $Res> get messageWindow { + return $AsyncValueCopyWith, $Res>(_self.messageWindow, + (value) { + return _then(_self.copyWith(messageWindow: value)); + }); + } } + +// dart format on diff --git a/lib/chat/models/message_state.dart b/lib/chat/models/message_state.dart index 8eacc8e..80852e6 100644 --- a/lib/chat/models/message_state.dart +++ b/lib/chat/models/message_state.dart @@ -24,8 +24,11 @@ enum MessageSendState { } @freezed -class MessageState with _$MessageState { +sealed class MessageState with _$MessageState { + @JsonSerializable() const factory MessageState({ + // Sequence number of the message for display purposes + required int seqId, // Content of the message @JsonKey(fromJson: messageFromJson, toJson: messageToJson) required proto.Message content, diff --git a/lib/chat/models/message_state.freezed.dart b/lib/chat/models/message_state.freezed.dart index 96c98e2..342b305 100644 --- a/lib/chat/models/message_state.freezed.dart +++ b/lib/chat/models/message_state.freezed.dart @@ -1,3 +1,4 @@ +// dart format width=80 // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint @@ -9,96 +10,76 @@ part of 'message_state.dart'; // FreezedGenerator // ************************************************************************** +// dart format off T _$identity(T value) => value; -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -MessageState _$MessageStateFromJson(Map json) { - return _MessageState.fromJson(json); -} - /// @nodoc -mixin _$MessageState { -// Content of the message +mixin _$MessageState implements DiagnosticableTreeMixin { +// Sequence number of the message for display purposes + int get seqId; // Content of the message @JsonKey(fromJson: messageFromJson, toJson: messageToJson) - proto.Message get content => - throw _privateConstructorUsedError; // Sent timestamp - Timestamp get sentTimestamp => - throw _privateConstructorUsedError; // Reconciled timestamp - Timestamp? get reconciledTimestamp => - throw _privateConstructorUsedError; // The state of the message - MessageSendState? get sendState => throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $MessageStateCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $MessageStateCopyWith<$Res> { - factory $MessageStateCopyWith( - MessageState value, $Res Function(MessageState) then) = - _$MessageStateCopyWithImpl<$Res, MessageState>; - @useResult - $Res call( - {@JsonKey(fromJson: messageFromJson, toJson: messageToJson) - proto.Message content, - Timestamp sentTimestamp, - Timestamp? reconciledTimestamp, - MessageSendState? sendState}); -} - -/// @nodoc -class _$MessageStateCopyWithImpl<$Res, $Val extends MessageState> - implements $MessageStateCopyWith<$Res> { - _$MessageStateCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; + proto.Message get content; // Sent timestamp + Timestamp get sentTimestamp; // Reconciled timestamp + Timestamp? get reconciledTimestamp; // The state of the message + MessageSendState? get sendState; + /// Create a copy of MessageState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') + $MessageStateCopyWith get copyWith => + _$MessageStateCopyWithImpl( + this as MessageState, _$identity); + + /// Serializes this MessageState to a JSON map. + Map toJson(); + @override - $Res call({ - Object? content = null, - Object? sentTimestamp = null, - Object? reconciledTimestamp = freezed, - Object? sendState = freezed, - }) { - return _then(_value.copyWith( - content: null == content - ? _value.content - : content // ignore: cast_nullable_to_non_nullable - as proto.Message, - sentTimestamp: null == sentTimestamp - ? _value.sentTimestamp - : sentTimestamp // ignore: cast_nullable_to_non_nullable - as Timestamp, - reconciledTimestamp: freezed == reconciledTimestamp - ? _value.reconciledTimestamp - : reconciledTimestamp // ignore: cast_nullable_to_non_nullable - as Timestamp?, - sendState: freezed == sendState - ? _value.sendState - : sendState // ignore: cast_nullable_to_non_nullable - as MessageSendState?, - ) as $Val); + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'MessageState')) + ..add(DiagnosticsProperty('seqId', seqId)) + ..add(DiagnosticsProperty('content', content)) + ..add(DiagnosticsProperty('sentTimestamp', sentTimestamp)) + ..add(DiagnosticsProperty('reconciledTimestamp', reconciledTimestamp)) + ..add(DiagnosticsProperty('sendState', sendState)); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is MessageState && + (identical(other.seqId, seqId) || other.seqId == seqId) && + (identical(other.content, content) || other.content == content) && + (identical(other.sentTimestamp, sentTimestamp) || + other.sentTimestamp == sentTimestamp) && + (identical(other.reconciledTimestamp, reconciledTimestamp) || + other.reconciledTimestamp == reconciledTimestamp) && + (identical(other.sendState, sendState) || + other.sendState == sendState)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, seqId, content, sentTimestamp, + reconciledTimestamp, sendState); + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'MessageState(seqId: $seqId, content: $content, sentTimestamp: $sentTimestamp, reconciledTimestamp: $reconciledTimestamp, sendState: $sendState)'; } } /// @nodoc -abstract class _$$MessageStateImplCopyWith<$Res> - implements $MessageStateCopyWith<$Res> { - factory _$$MessageStateImplCopyWith( - _$MessageStateImpl value, $Res Function(_$MessageStateImpl) then) = - __$$MessageStateImplCopyWithImpl<$Res>; - @override +abstract mixin class $MessageStateCopyWith<$Res> { + factory $MessageStateCopyWith( + MessageState value, $Res Function(MessageState) _then) = + _$MessageStateCopyWithImpl; @useResult $Res call( - {@JsonKey(fromJson: messageFromJson, toJson: messageToJson) + {int seqId, + @JsonKey(fromJson: messageFromJson, toJson: messageToJson) proto.Message content, Timestamp sentTimestamp, Timestamp? reconciledTimestamp, @@ -106,36 +87,42 @@ abstract class _$$MessageStateImplCopyWith<$Res> } /// @nodoc -class __$$MessageStateImplCopyWithImpl<$Res> - extends _$MessageStateCopyWithImpl<$Res, _$MessageStateImpl> - implements _$$MessageStateImplCopyWith<$Res> { - __$$MessageStateImplCopyWithImpl( - _$MessageStateImpl _value, $Res Function(_$MessageStateImpl) _then) - : super(_value, _then); +class _$MessageStateCopyWithImpl<$Res> implements $MessageStateCopyWith<$Res> { + _$MessageStateCopyWithImpl(this._self, this._then); + final MessageState _self; + final $Res Function(MessageState) _then; + + /// Create a copy of MessageState + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ + Object? seqId = null, Object? content = null, Object? sentTimestamp = null, Object? reconciledTimestamp = freezed, Object? sendState = freezed, }) { - return _then(_$MessageStateImpl( + return _then(_self.copyWith( + seqId: null == seqId + ? _self.seqId + : seqId // ignore: cast_nullable_to_non_nullable + as int, content: null == content - ? _value.content + ? _self.content : content // ignore: cast_nullable_to_non_nullable as proto.Message, sentTimestamp: null == sentTimestamp - ? _value.sentTimestamp + ? _self.sentTimestamp : sentTimestamp // ignore: cast_nullable_to_non_nullable as Timestamp, reconciledTimestamp: freezed == reconciledTimestamp - ? _value.reconciledTimestamp + ? _self.reconciledTimestamp : reconciledTimestamp // ignore: cast_nullable_to_non_nullable as Timestamp?, sendState: freezed == sendState - ? _value.sendState + ? _self.sendState : sendState // ignore: cast_nullable_to_non_nullable as MessageSendState?, )); @@ -143,18 +130,22 @@ class __$$MessageStateImplCopyWithImpl<$Res> } /// @nodoc + @JsonSerializable() -class _$MessageStateImpl with DiagnosticableTreeMixin implements _MessageState { - const _$MessageStateImpl( - {@JsonKey(fromJson: messageFromJson, toJson: messageToJson) +class _MessageState with DiagnosticableTreeMixin implements MessageState { + const _MessageState( + {required this.seqId, + @JsonKey(fromJson: messageFromJson, toJson: messageToJson) required this.content, required this.sentTimestamp, required this.reconciledTimestamp, required this.sendState}); + factory _MessageState.fromJson(Map json) => + _$MessageStateFromJson(json); - factory _$MessageStateImpl.fromJson(Map json) => - _$$MessageStateImplFromJson(json); - +// Sequence number of the message for display purposes + @override + final int seqId; // Content of the message @override @JsonKey(fromJson: messageFromJson, toJson: messageToJson) @@ -169,16 +160,26 @@ class _$MessageStateImpl with DiagnosticableTreeMixin implements _MessageState { @override final MessageSendState? sendState; + /// Create a copy of MessageState + /// with the given fields replaced by the non-null parameter values. @override - String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'MessageState(content: $content, sentTimestamp: $sentTimestamp, reconciledTimestamp: $reconciledTimestamp, sendState: $sendState)'; + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$MessageStateCopyWith<_MessageState> get copyWith => + __$MessageStateCopyWithImpl<_MessageState>(this, _$identity); + + @override + Map toJson() { + return _$MessageStateToJson( + this, + ); } @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); properties ..add(DiagnosticsProperty('type', 'MessageState')) + ..add(DiagnosticsProperty('seqId', seqId)) ..add(DiagnosticsProperty('content', content)) ..add(DiagnosticsProperty('sentTimestamp', sentTimestamp)) ..add(DiagnosticsProperty('reconciledTimestamp', reconciledTimestamp)) @@ -189,7 +190,8 @@ class _$MessageStateImpl with DiagnosticableTreeMixin implements _MessageState { bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$MessageStateImpl && + other is _MessageState && + (identical(other.seqId, seqId) || other.seqId == seqId) && (identical(other.content, content) || other.content == content) && (identical(other.sentTimestamp, sentTimestamp) || other.sentTimestamp == sentTimestamp) && @@ -199,47 +201,76 @@ class _$MessageStateImpl with DiagnosticableTreeMixin implements _MessageState { other.sendState == sendState)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash( - runtimeType, content, sentTimestamp, reconciledTimestamp, sendState); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$MessageStateImplCopyWith<_$MessageStateImpl> get copyWith => - __$$MessageStateImplCopyWithImpl<_$MessageStateImpl>(this, _$identity); + int get hashCode => Object.hash(runtimeType, seqId, content, sentTimestamp, + reconciledTimestamp, sendState); @override - Map toJson() { - return _$$MessageStateImplToJson( - this, - ); + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'MessageState(seqId: $seqId, content: $content, sentTimestamp: $sentTimestamp, reconciledTimestamp: $reconciledTimestamp, sendState: $sendState)'; } } -abstract class _MessageState implements MessageState { - const factory _MessageState( - {@JsonKey(fromJson: messageFromJson, toJson: messageToJson) - required final proto.Message content, - required final Timestamp sentTimestamp, - required final Timestamp? reconciledTimestamp, - required final MessageSendState? sendState}) = _$MessageStateImpl; - - factory _MessageState.fromJson(Map json) = - _$MessageStateImpl.fromJson; - - @override // Content of the message - @JsonKey(fromJson: messageFromJson, toJson: messageToJson) - proto.Message get content; - @override // Sent timestamp - Timestamp get sentTimestamp; - @override // Reconciled timestamp - Timestamp? get reconciledTimestamp; - @override // The state of the message - MessageSendState? get sendState; +/// @nodoc +abstract mixin class _$MessageStateCopyWith<$Res> + implements $MessageStateCopyWith<$Res> { + factory _$MessageStateCopyWith( + _MessageState value, $Res Function(_MessageState) _then) = + __$MessageStateCopyWithImpl; @override - @JsonKey(ignore: true) - _$$MessageStateImplCopyWith<_$MessageStateImpl> get copyWith => - throw _privateConstructorUsedError; + @useResult + $Res call( + {int seqId, + @JsonKey(fromJson: messageFromJson, toJson: messageToJson) + proto.Message content, + Timestamp sentTimestamp, + Timestamp? reconciledTimestamp, + MessageSendState? sendState}); } + +/// @nodoc +class __$MessageStateCopyWithImpl<$Res> + implements _$MessageStateCopyWith<$Res> { + __$MessageStateCopyWithImpl(this._self, this._then); + + final _MessageState _self; + final $Res Function(_MessageState) _then; + + /// Create a copy of MessageState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? seqId = null, + Object? content = null, + Object? sentTimestamp = null, + Object? reconciledTimestamp = freezed, + Object? sendState = freezed, + }) { + return _then(_MessageState( + seqId: null == seqId + ? _self.seqId + : seqId // ignore: cast_nullable_to_non_nullable + as int, + content: null == content + ? _self.content + : content // ignore: cast_nullable_to_non_nullable + as proto.Message, + sentTimestamp: null == sentTimestamp + ? _self.sentTimestamp + : sentTimestamp // ignore: cast_nullable_to_non_nullable + as Timestamp, + reconciledTimestamp: freezed == reconciledTimestamp + ? _self.reconciledTimestamp + : reconciledTimestamp // ignore: cast_nullable_to_non_nullable + as Timestamp?, + sendState: freezed == sendState + ? _self.sendState + : sendState // ignore: cast_nullable_to_non_nullable + as MessageSendState?, + )); + } +} + +// dart format on diff --git a/lib/chat/models/message_state.g.dart b/lib/chat/models/message_state.g.dart index 99899a7..2eee78d 100644 --- a/lib/chat/models/message_state.g.dart +++ b/lib/chat/models/message_state.g.dart @@ -6,8 +6,9 @@ part of 'message_state.dart'; // JsonSerializableGenerator // ************************************************************************** -_$MessageStateImpl _$$MessageStateImplFromJson(Map json) => - _$MessageStateImpl( +_MessageState _$MessageStateFromJson(Map json) => + _MessageState( + seqId: (json['seq_id'] as num).toInt(), content: messageFromJson(json['content'] as Map), sentTimestamp: Timestamp.fromJson(json['sent_timestamp']), reconciledTimestamp: json['reconciled_timestamp'] == null @@ -18,8 +19,9 @@ _$MessageStateImpl _$$MessageStateImplFromJson(Map json) => : MessageSendState.fromJson(json['send_state']), ); -Map _$$MessageStateImplToJson(_$MessageStateImpl instance) => +Map _$MessageStateToJson(_MessageState instance) => { + 'seq_id': instance.seqId, 'content': messageToJson(instance.content), 'sent_timestamp': instance.sentTimestamp.toJson(), 'reconciled_timestamp': instance.reconciledTimestamp?.toJson(), diff --git a/lib/chat/models/window_state.dart b/lib/chat/models/window_state.dart index 91cde8a..14a94a5 100644 --- a/lib/chat/models/window_state.dart +++ b/lib/chat/models/window_state.dart @@ -5,7 +5,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'window_state.freezed.dart'; @freezed -class WindowState with _$WindowState { +sealed class WindowState with _$WindowState { const factory WindowState({ // List of objects in the window required IList window, diff --git a/lib/chat/models/window_state.freezed.dart b/lib/chat/models/window_state.freezed.dart index 604931d..38a2ec1 100644 --- a/lib/chat/models/window_state.freezed.dart +++ b/lib/chat/models/window_state.freezed.dart @@ -1,3 +1,4 @@ +// dart format width=80 // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint @@ -9,94 +10,71 @@ part of 'window_state.dart'; // FreezedGenerator // ************************************************************************** +// dart format off T _$identity(T value) => value; -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - /// @nodoc -mixin _$WindowState { +mixin _$WindowState implements DiagnosticableTreeMixin { // List of objects in the window - IList get window => - throw _privateConstructorUsedError; // Total number of objects (windowTail max) - int get length => - throw _privateConstructorUsedError; // One past the end of the last element - int get windowTail => - throw _privateConstructorUsedError; // The total number of elements to try to keep in the window - int get windowCount => - throw _privateConstructorUsedError; // If we should have the tail following the array - bool get follow => throw _privateConstructorUsedError; - - @JsonKey(ignore: true) - $WindowStateCopyWith> get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $WindowStateCopyWith { - factory $WindowStateCopyWith( - WindowState value, $Res Function(WindowState) then) = - _$WindowStateCopyWithImpl>; - @useResult - $Res call( - {IList window, - int length, - int windowTail, - int windowCount, - bool follow}); -} - -/// @nodoc -class _$WindowStateCopyWithImpl> - implements $WindowStateCopyWith { - _$WindowStateCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; + IList get window; // Total number of objects (windowTail max) + int get length; // One past the end of the last element + int get windowTail; // The total number of elements to try to keep in the window + int get windowCount; // If we should have the tail following the array + bool get follow; + /// Create a copy of WindowState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') + $WindowStateCopyWith> get copyWith => + _$WindowStateCopyWithImpl>( + this as WindowState, _$identity); + @override - $Res call({ - Object? window = null, - Object? length = null, - Object? windowTail = null, - Object? windowCount = null, - Object? follow = null, - }) { - return _then(_value.copyWith( - window: null == window - ? _value.window - : window // ignore: cast_nullable_to_non_nullable - as IList, - length: null == length - ? _value.length - : length // ignore: cast_nullable_to_non_nullable - as int, - windowTail: null == windowTail - ? _value.windowTail - : windowTail // ignore: cast_nullable_to_non_nullable - as int, - windowCount: null == windowCount - ? _value.windowCount - : windowCount // ignore: cast_nullable_to_non_nullable - as int, - follow: null == follow - ? _value.follow - : follow // ignore: cast_nullable_to_non_nullable - as bool, - ) as $Val); + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'WindowState<$T>')) + ..add(DiagnosticsProperty('window', window)) + ..add(DiagnosticsProperty('length', length)) + ..add(DiagnosticsProperty('windowTail', windowTail)) + ..add(DiagnosticsProperty('windowCount', windowCount)) + ..add(DiagnosticsProperty('follow', follow)); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is WindowState && + const DeepCollectionEquality().equals(other.window, window) && + (identical(other.length, length) || other.length == length) && + (identical(other.windowTail, windowTail) || + other.windowTail == windowTail) && + (identical(other.windowCount, windowCount) || + other.windowCount == windowCount) && + (identical(other.follow, follow) || other.follow == follow)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(window), + length, + windowTail, + windowCount, + follow); + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'WindowState<$T>(window: $window, length: $length, windowTail: $windowTail, windowCount: $windowCount, follow: $follow)'; } } /// @nodoc -abstract class _$$WindowStateImplCopyWith - implements $WindowStateCopyWith { - factory _$$WindowStateImplCopyWith(_$WindowStateImpl value, - $Res Function(_$WindowStateImpl) then) = - __$$WindowStateImplCopyWithImpl; - @override +abstract mixin class $WindowStateCopyWith { + factory $WindowStateCopyWith( + WindowState value, $Res Function(WindowState) _then) = + _$WindowStateCopyWithImpl; @useResult $Res call( {IList window, @@ -107,13 +85,15 @@ abstract class _$$WindowStateImplCopyWith } /// @nodoc -class __$$WindowStateImplCopyWithImpl - extends _$WindowStateCopyWithImpl> - implements _$$WindowStateImplCopyWith { - __$$WindowStateImplCopyWithImpl( - _$WindowStateImpl _value, $Res Function(_$WindowStateImpl) _then) - : super(_value, _then); +class _$WindowStateCopyWithImpl + implements $WindowStateCopyWith { + _$WindowStateCopyWithImpl(this._self, this._then); + final WindowState _self; + final $Res Function(WindowState) _then; + + /// Create a copy of WindowState + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -123,25 +103,25 @@ class __$$WindowStateImplCopyWithImpl Object? windowCount = null, Object? follow = null, }) { - return _then(_$WindowStateImpl( + return _then(_self.copyWith( window: null == window - ? _value.window + ? _self.window : window // ignore: cast_nullable_to_non_nullable as IList, length: null == length - ? _value.length + ? _self.length : length // ignore: cast_nullable_to_non_nullable as int, windowTail: null == windowTail - ? _value.windowTail + ? _self.windowTail : windowTail // ignore: cast_nullable_to_non_nullable as int, windowCount: null == windowCount - ? _value.windowCount + ? _self.windowCount : windowCount // ignore: cast_nullable_to_non_nullable as int, follow: null == follow - ? _value.follow + ? _self.follow : follow // ignore: cast_nullable_to_non_nullable as bool, )); @@ -150,10 +130,8 @@ class __$$WindowStateImplCopyWithImpl /// @nodoc -class _$WindowStateImpl - with DiagnosticableTreeMixin - implements _WindowState { - const _$WindowStateImpl( +class _WindowState with DiagnosticableTreeMixin implements WindowState { + const _WindowState( {required this.window, required this.length, required this.windowTail, @@ -176,14 +154,16 @@ class _$WindowStateImpl @override final bool follow; + /// Create a copy of WindowState + /// with the given fields replaced by the non-null parameter values. @override - String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'WindowState<$T>(window: $window, length: $length, windowTail: $windowTail, windowCount: $windowCount, follow: $follow)'; - } + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$WindowStateCopyWith> get copyWith => + __$WindowStateCopyWithImpl>(this, _$identity); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); properties ..add(DiagnosticsProperty('type', 'WindowState<$T>')) ..add(DiagnosticsProperty('window', window)) @@ -197,7 +177,7 @@ class _$WindowStateImpl bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$WindowStateImpl && + other is _WindowState && const DeepCollectionEquality().equals(other.window, window) && (identical(other.length, length) || other.length == length) && (identical(other.windowTail, windowTail) || @@ -216,34 +196,70 @@ class _$WindowStateImpl windowCount, follow); - @JsonKey(ignore: true) + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'WindowState<$T>(window: $window, length: $length, windowTail: $windowTail, windowCount: $windowCount, follow: $follow)'; + } +} + +/// @nodoc +abstract mixin class _$WindowStateCopyWith + implements $WindowStateCopyWith { + factory _$WindowStateCopyWith( + _WindowState value, $Res Function(_WindowState) _then) = + __$WindowStateCopyWithImpl; + @override + @useResult + $Res call( + {IList window, + int length, + int windowTail, + int windowCount, + bool follow}); +} + +/// @nodoc +class __$WindowStateCopyWithImpl + implements _$WindowStateCopyWith { + __$WindowStateCopyWithImpl(this._self, this._then); + + final _WindowState _self; + final $Res Function(_WindowState) _then; + + /// Create a copy of WindowState + /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') - _$$WindowStateImplCopyWith> get copyWith => - __$$WindowStateImplCopyWithImpl>( - this, _$identity); + $Res call({ + Object? window = null, + Object? length = null, + Object? windowTail = null, + Object? windowCount = null, + Object? follow = null, + }) { + return _then(_WindowState( + window: null == window + ? _self.window + : window // ignore: cast_nullable_to_non_nullable + as IList, + length: null == length + ? _self.length + : length // ignore: cast_nullable_to_non_nullable + as int, + windowTail: null == windowTail + ? _self.windowTail + : windowTail // ignore: cast_nullable_to_non_nullable + as int, + windowCount: null == windowCount + ? _self.windowCount + : windowCount // ignore: cast_nullable_to_non_nullable + as int, + follow: null == follow + ? _self.follow + : follow // ignore: cast_nullable_to_non_nullable + as bool, + )); + } } -abstract class _WindowState implements WindowState { - const factory _WindowState( - {required final IList window, - required final int length, - required final int windowTail, - required final int windowCount, - required final bool follow}) = _$WindowStateImpl; - - @override // List of objects in the window - IList get window; - @override // Total number of objects (windowTail max) - int get length; - @override // One past the end of the last element - int get windowTail; - @override // The total number of elements to try to keep in the window - int get windowCount; - @override // If we should have the tail following the array - bool get follow; - @override - @JsonKey(ignore: true) - _$$WindowStateImplCopyWith> get copyWith => - throw _privateConstructorUsedError; -} +// dart format on diff --git a/lib/chat/views/chat_builders/chat_builders.dart b/lib/chat/views/chat_builders/chat_builders.dart new file mode 100644 index 0000000..529341f --- /dev/null +++ b/lib/chat/views/chat_builders/chat_builders.dart @@ -0,0 +1,2 @@ +export 'vc_composer_widget.dart'; +export 'vc_text_message_widget.dart'; diff --git a/lib/chat/views/chat_builders/vc_composer_widget.dart b/lib/chat/views/chat_builders/vc_composer_widget.dart new file mode 100644 index 0000000..f470e9b --- /dev/null +++ b/lib/chat/views/chat_builders/vc_composer_widget.dart @@ -0,0 +1,432 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_chat_ui/flutter_chat_ui.dart'; +// Typedefs need to come out +// ignore: implementation_imports +import 'package:flutter_chat_ui/src/utils/typedefs.dart'; +import 'package:provider/provider.dart'; + +import '../../../theme/theme.dart'; +import '../../chat.dart'; + +enum ShiftEnterAction { newline, send } + +/// The message composer widget positioned at the bottom of the chat screen. +/// +/// Includes a text input field, an optional attachment button, +/// and a send button. +class VcComposerWidget extends StatefulWidget { + /// Creates a message composer widget. + const VcComposerWidget({ + super.key, + this.textEditingController, + this.left = 0, + this.right = 0, + this.top, + this.bottom = 0, + this.sigmaX = 20, + this.sigmaY = 20, + this.padding = const EdgeInsets.all(8), + this.attachmentIcon = const Icon(Icons.attachment), + this.sendIcon = const Icon(Icons.send), + this.gap = 8, + this.inputBorder, + this.filled, + this.topWidget, + this.handleSafeArea = true, + this.backgroundColor, + this.attachmentIconColor, + this.sendIconColor, + this.hintColor, + this.textColor, + this.inputFillColor, + this.hintText = 'Type a message', + this.keyboardAppearance, + this.autocorrect, + this.autofocus = false, + this.textCapitalization = TextCapitalization.sentences, + this.keyboardType, + this.textInputAction = TextInputAction.newline, + this.shiftEnterAction = ShiftEnterAction.send, + this.focusNode, + this.maxLength, + this.minLines = 1, + this.maxLines = 3, + }); + + /// Optional controller for the text input field. + final TextEditingController? textEditingController; + + /// Optional left position. + final double? left; + + /// Optional right position. + final double? right; + + /// Optional top position. + final double? top; + + /// Optional bottom position. + final double? bottom; + + /// Optional X blur value for the background (if using glassmorphism). + final double? sigmaX; + + /// Optional Y blur value for the background (if using glassmorphism). + final double? sigmaY; + + /// Padding around the composer content. + final EdgeInsetsGeometry? padding; + + /// Icon for the attachment button. Defaults to [Icons.attachment]. + final Widget? attachmentIcon; + + /// Icon for the send button. Defaults to [Icons.send]. + final Widget? sendIcon; + + /// Horizontal gap between elements (attachment icon, text field, send icon). + final double? gap; + + /// Border style for the text input field. + final InputBorder? inputBorder; + + /// Whether the text input field should be filled. + final bool? filled; + + /// Optional widget to display above the main composer row. + final Widget? topWidget; + + /// Whether to adjust padding for the bottom safe area. + final bool handleSafeArea; + + /// Background color of the composer container. + final Color? backgroundColor; + + /// Color of the attachment icon. + final Color? attachmentIconColor; + + /// Color of the send icon. + final Color? sendIconColor; + + /// Color of the hint text in the input field. + final Color? hintColor; + + /// Color of the text entered in the input field. + final Color? textColor; + + /// Fill color for the text input field when [filled] is true. + final Color? inputFillColor; + + /// Placeholder text for the input field. + final String? hintText; + + /// Appearance of the keyboard. + final Brightness? keyboardAppearance; + + /// Whether to enable autocorrect for the input field. + final bool? autocorrect; + + /// Whether the input field should autofocus. + final bool autofocus; + + /// Capitalization behavior for the input field. + final TextCapitalization textCapitalization; + + /// Type of keyboard to display. + final TextInputType? keyboardType; + + /// Action button type for the keyboard (e.g., newline, send). + final TextInputAction textInputAction; + + /// Action when shift-enter is pressed (e.g., newline, send). + final ShiftEnterAction shiftEnterAction; + + /// Focus node for the text input field. + final FocusNode? focusNode; + + /// Maximum character length for the input field. + final int? maxLength; + + /// Minimum number of lines for the input field. + final int? minLines; + + /// Maximum number of lines the input field can expand to. + final int? maxLines; + + @override + State createState() => _VcComposerState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty( + 'textEditingController', textEditingController)) + ..add(DoubleProperty('left', left)) + ..add(DoubleProperty('right', right)) + ..add(DoubleProperty('top', top)) + ..add(DoubleProperty('bottom', bottom)) + ..add(DoubleProperty('sigmaX', sigmaX)) + ..add(DoubleProperty('sigmaY', sigmaY)) + ..add(DiagnosticsProperty('padding', padding)) + ..add(DoubleProperty('gap', gap)) + ..add(DiagnosticsProperty('inputBorder', inputBorder)) + ..add(DiagnosticsProperty('filled', filled)) + ..add(DiagnosticsProperty('handleSafeArea', handleSafeArea)) + ..add(ColorProperty('backgroundColor', backgroundColor)) + ..add(ColorProperty('attachmentIconColor', attachmentIconColor)) + ..add(ColorProperty('sendIconColor', sendIconColor)) + ..add(ColorProperty('hintColor', hintColor)) + ..add(ColorProperty('textColor', textColor)) + ..add(ColorProperty('inputFillColor', inputFillColor)) + ..add(StringProperty('hintText', hintText)) + ..add(EnumProperty('keyboardAppearance', keyboardAppearance)) + ..add(DiagnosticsProperty('autocorrect', autocorrect)) + ..add(DiagnosticsProperty('autofocus', autofocus)) + ..add(EnumProperty( + 'textCapitalization', textCapitalization)) + ..add(DiagnosticsProperty('keyboardType', keyboardType)) + ..add(EnumProperty('textInputAction', textInputAction)) + ..add( + EnumProperty('shiftEnterAction', shiftEnterAction)) + ..add(DiagnosticsProperty('focusNode', focusNode)) + ..add(IntProperty('maxLength', maxLength)) + ..add(IntProperty('minLines', minLines)) + ..add(IntProperty('maxLines', maxLines)); + } +} + +class _VcComposerState extends State { + final _key = GlobalKey(); + late final TextEditingController _textController; + late final FocusNode _focusNode; + late String _suffixText; + + @override + void initState() { + super.initState(); + _textController = widget.textEditingController ?? TextEditingController(); + _focusNode = widget.focusNode ?? FocusNode(); + _focusNode.onKeyEvent = _handleKeyEvent; + _updateSuffixText(); + WidgetsBinding.instance.addPostFrameCallback((_) => _measure()); + } + + void _updateSuffixText() { + final utf8Length = utf8.encode(_textController.text).length; + _suffixText = '$utf8Length/${widget.maxLength}'; + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + // Check for Shift+Enter + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.enter && + HardwareKeyboard.instance.isShiftPressed) { + if (widget.shiftEnterAction == ShiftEnterAction.send) { + _handleSubmitted(_textController.text); + return KeyEventResult.handled; + } else if (widget.shiftEnterAction == ShiftEnterAction.newline) { + final val = _textController.value; + final insertOffset = val.selection.extent.offset; + final messageWithNewLine = + '${_textController.text.substring(0, insertOffset)}\n' + '${_textController.text.substring(insertOffset)}'; + _textController.value = TextEditingValue( + text: messageWithNewLine, + selection: TextSelection.fromPosition( + TextPosition(offset: insertOffset + 1), + ), + ); + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + } + + @override + void didUpdateWidget(covariant VcComposerWidget oldWidget) { + super.didUpdateWidget(oldWidget); + WidgetsBinding.instance.addPostFrameCallback((_) => _measure()); + } + + @override + void dispose() { + // Only try to dispose text controller if it's not provided, let + // user handle disposing it how they want. + if (widget.textEditingController == null) { + _textController.dispose(); + } + if (widget.focusNode == null) { + _focusNode.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final bottomSafeArea = + widget.handleSafeArea ? MediaQuery.of(context).padding.bottom : 0.0; + final onAttachmentTap = context.read(); + final theme = Theme.of(context); + final scaleTheme = theme.extension()!; + final config = scaleTheme.config; + final scheme = scaleTheme.scheme; + final scale = scaleTheme.scheme.scale(ScaleKind.primary); + final textTheme = theme.textTheme; + final scaleChatTheme = scaleTheme.chatTheme(); + final chatTheme = scaleChatTheme.chatTheme; + + final suffixTextStyle = + textTheme.bodySmall!.copyWith(color: scale.subtleText); + + return Positioned( + left: widget.left, + right: widget.right, + top: widget.top, + bottom: widget.bottom, + child: ClipRect( + child: DecoratedBox( + key: _key, + decoration: BoxDecoration( + border: config.preferBorders + ? Border(top: BorderSide(color: scale.border, width: 2)) + : null, + color: config.preferBorders + ? scale.elementBackground + : scale.border), + child: Column( + children: [ + if (widget.topWidget != null) widget.topWidget!, + Padding( + padding: widget.handleSafeArea + ? (widget.padding?.add( + EdgeInsets.only(bottom: bottomSafeArea), + ) ?? + EdgeInsets.only(bottom: bottomSafeArea)) + : (widget.padding ?? EdgeInsets.zero), + child: Row( + children: [ + if (widget.attachmentIcon != null && + onAttachmentTap != null) + IconButton( + icon: widget.attachmentIcon!, + color: widget.attachmentIconColor ?? + chatTheme.colors.onSurface.withValues(alpha: 0.5), + onPressed: onAttachmentTap, + ) + else + const SizedBox.shrink(), + SizedBox(width: widget.gap), + Expanded( + child: TextField( + controller: _textController, + decoration: InputDecoration( + filled: widget.filled ?? !config.preferBorders, + fillColor: widget.inputFillColor ?? + scheme.primaryScale.subtleBackground, + isDense: true, + contentPadding: + const EdgeInsets.fromLTRB(8, 8, 8, 8), + disabledBorder: OutlineInputBorder( + borderSide: config.preferBorders + ? BorderSide( + color: scheme.grayScale.border, + width: 2) + : BorderSide.none, + borderRadius: BorderRadius.all(Radius.circular( + 8 * config.borderRadiusScale))), + enabledBorder: OutlineInputBorder( + borderSide: config.preferBorders + ? BorderSide( + color: scheme.primaryScale.border, + width: 2) + : BorderSide.none, + borderRadius: BorderRadius.all(Radius.circular( + 8 * config.borderRadiusScale))), + focusedBorder: OutlineInputBorder( + borderSide: config.preferBorders + ? BorderSide( + color: scheme.primaryScale.border, + width: 2) + : BorderSide.none, + borderRadius: BorderRadius.all(Radius.circular( + 8 * config.borderRadiusScale))), + hintText: widget.hintText, + hintMaxLines: 1, + hintStyle: chatTheme.typography.bodyMedium.copyWith( + color: widget.hintColor ?? + chatTheme.colors.onSurface + .withValues(alpha: 0.5), + ), + border: widget.inputBorder, + hoverColor: Colors.transparent, + suffix: Text(_suffixText, style: suffixTextStyle)), + onSubmitted: _handleSubmitted, + onChanged: (value) { + setState(_updateSuffixText); + }, + textInputAction: widget.textInputAction, + keyboardAppearance: widget.keyboardAppearance, + autocorrect: widget.autocorrect ?? true, + autofocus: widget.autofocus, + textCapitalization: widget.textCapitalization, + keyboardType: widget.keyboardType, + focusNode: _focusNode, + //maxLength: widget.maxLength, + minLines: widget.minLines, + maxLines: widget.maxLines, + maxLengthEnforcement: MaxLengthEnforcement.none, + inputFormatters: [ + Utf8LengthLimitingTextInputFormatter( + maxLength: widget.maxLength), + ], + ), + ), + SizedBox(width: widget.gap), + if ((widget.sendIcon ?? scaleChatTheme.sendButtonIcon) != + null) + IconButton( + icon: + (widget.sendIcon ?? scaleChatTheme.sendButtonIcon)!, + color: widget.sendIconColor, + onPressed: () => _handleSubmitted(_textController.text), + ) + else + const SizedBox.shrink(), + ], + ), + ), + ], + ), + ), + ), + ); + } + + void _measure() { + if (!mounted) { + return; + } + + final renderBox = _key.currentContext?.findRenderObject() as RenderBox?; + if (renderBox != null) { + final height = renderBox.size.height; + final bottomSafeArea = MediaQuery.of(context).padding.bottom; + + context.read().setHeight( + // only set real height of the composer, ignoring safe area + widget.handleSafeArea ? height - bottomSafeArea : height, + ); + } + } + + void _handleSubmitted(String text) { + if (text.isNotEmpty) { + context.read()?.call(text); + _textController.clear(); + } + } +} diff --git a/lib/chat/views/chat_builders/vc_text_message_widget.dart b/lib/chat/views/chat_builders/vc_text_message_widget.dart new file mode 100644 index 0000000..7ea2957 --- /dev/null +++ b/lib/chat/views/chat_builders/vc_text_message_widget.dart @@ -0,0 +1,221 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:provider/provider.dart'; + +import '../../../theme/theme.dart'; +import '../date_formatter.dart'; + +/// A widget that displays a text message. +class VcTextMessageWidget extends StatelessWidget { + /// Creates a widget to display a simple text message. + const VcTextMessageWidget({ + required this.message, + required this.index, + this.padding, + this.borderRadius, + this.onlyEmojiFontSize, + this.sentBackgroundColor, + this.receivedBackgroundColor, + this.sentTextStyle, + this.receivedTextStyle, + this.timeStyle, + this.showTime = true, + this.showStatus = true, + super.key, + }); + + /// The text message data model. + final TextMessage message; + + /// The index of the message in the list. + final int index; + + /// Padding around the message bubble content. + final EdgeInsetsGeometry? padding; + + /// Border radius of the message bubble. + final BorderRadiusGeometry? borderRadius; + + /// Font size for messages containing only emojis. + final double? onlyEmojiFontSize; + + /// Background color for messages sent by the current user. + final Color? sentBackgroundColor; + + /// Background color for messages received from other users. + final Color? receivedBackgroundColor; + + /// Text style for messages sent by the current user. + final TextStyle? sentTextStyle; + + /// Text style for messages received from other users. + final TextStyle? receivedTextStyle; + + /// Text style for the message timestamp and status. + final TextStyle? timeStyle; + + /// Whether to display the message timestamp. + final bool showTime; + + /// Whether to display the message status (sent, delivered, seen) + /// for sent messages. + final bool showStatus; + + bool get _isOnlyEmoji => message.metadata?['isOnlyEmoji'] == true; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scaleTheme = theme.extension()!; + final scaleChatTheme = scaleTheme.chatTheme(); + final chatTheme = scaleChatTheme.chatTheme; + + final isSentByMe = context.watch() == message.authorId; + final backgroundColor = _resolveBackgroundColor(isSentByMe, scaleChatTheme); + final textStyle = _resolveTextStyle(isSentByMe, scaleChatTheme); + final timeStyle = _resolveTimeStyle(isSentByMe, scaleChatTheme); + final emojiFontSize = onlyEmojiFontSize ?? scaleChatTheme.onlyEmojiFontSize; + + final timeAndStatus = showTime || (isSentByMe && showStatus) + ? TimeAndStatus( + time: message.time, + status: message.status, + showTime: showTime, + showStatus: isSentByMe && showStatus, + textStyle: timeStyle, + ) + : null; + + final textContent = Text( + message.text, + style: _isOnlyEmoji + ? textStyle.copyWith(fontSize: emojiFontSize) + : textStyle, + ); + + return Column( + crossAxisAlignment: + isSentByMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + Container( + padding: _isOnlyEmoji + ? EdgeInsets.symmetric( + horizontal: (padding?.horizontal ?? 0) / 2, + // vertical: 0, + ) + : padding, + decoration: _isOnlyEmoji + ? null + : BoxDecoration( + color: backgroundColor, + borderRadius: borderRadius ?? chatTheme.shape, + ), + child: textContent), + if (timeAndStatus != null) timeAndStatus, + ]); + } + + Color _resolveBackgroundColor(bool isSentByMe, ScaleChatTheme theme) { + if (isSentByMe) { + return sentBackgroundColor ?? theme.primaryColor; + } + return receivedBackgroundColor ?? theme.secondaryColor; + } + + TextStyle _resolveTextStyle(bool isSentByMe, ScaleChatTheme theme) { + if (isSentByMe) { + return sentTextStyle ?? theme.sentMessageBodyTextStyle; + } + return receivedTextStyle ?? theme.receivedMessageBodyTextStyle; + } + + TextStyle _resolveTimeStyle(bool isSentByMe, ScaleChatTheme theme) => + theme.timeStyle; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('message', message)) + ..add(IntProperty('index', index)) + ..add(DiagnosticsProperty('padding', padding)) + ..add(DiagnosticsProperty( + 'borderRadius', borderRadius)) + ..add(DoubleProperty('onlyEmojiFontSize', onlyEmojiFontSize)) + ..add(ColorProperty('sentBackgroundColor', sentBackgroundColor)) + ..add(ColorProperty('receivedBackgroundColor', receivedBackgroundColor)) + ..add(DiagnosticsProperty('sentTextStyle', sentTextStyle)) + ..add(DiagnosticsProperty( + 'receivedTextStyle', receivedTextStyle)) + ..add(DiagnosticsProperty('timeStyle', timeStyle)) + ..add(DiagnosticsProperty('showTime', showTime)) + ..add(DiagnosticsProperty('showStatus', showStatus)); + } +} + +/// A widget to display the message timestamp and status indicator. +class TimeAndStatus extends StatelessWidget { + /// Creates a widget for displaying time and status. + const TimeAndStatus({ + required this.time, + this.status, + this.showTime = true, + this.showStatus = true, + this.textStyle, + super.key, + }); + + /// The time the message was created. + final DateTime? time; + + /// The status of the message. + final MessageStatus? status; + + /// Whether to display the timestamp. + final bool showTime; + + /// Whether to display the status indicator. + final bool showStatus; + + /// The text style for the time and status. + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) { + final dformat = DateFormatter(); + + return Row( + spacing: 2, + mainAxisSize: MainAxisSize.min, + children: [ + if (showTime && time != null) + Text(dformat.chatDateTimeFormat(time!.toLocal()), style: textStyle), + if (showStatus && status != null) + if (status == MessageStatus.sending) + SizedBox( + width: 6.scaled(context), + height: 6.scaled(context), + child: CircularProgressIndicator( + color: textStyle?.color, + strokeWidth: 2, + ), + ) + else + Icon(getIconForStatus(status!), + color: textStyle?.color, size: 12.scaled(context)), + ], + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('time', time)) + ..add(EnumProperty('status', status)) + ..add(DiagnosticsProperty('showTime', showTime)) + ..add(DiagnosticsProperty('showStatus', showStatus)) + ..add(DiagnosticsProperty('textStyle', textStyle)); + } +} diff --git a/lib/chat/views/chat_component_widget.dart b/lib/chat/views/chat_component_widget.dart index a3b2e33..aecf531 100644 --- a/lib/chat/views/chat_component_widget.dart +++ b/lib/chat/views/chat_component_widget.dart @@ -1,92 +1,447 @@ +import 'dart:async'; +import 'dart:convert'; import 'dart:math'; import 'package:async_tools/async_tools.dart'; import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; import 'package:flutter_chat_ui/flutter_chat_ui.dart'; +import 'package:flutter_translate/flutter_translate.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../account_manager/account_manager.dart'; -import '../../chat_list/chat_list.dart'; +import '../../contacts/contacts.dart'; +import '../../conversation/conversation.dart'; +import '../../notifications/notifications.dart'; import '../../theme/theme.dart'; import '../chat.dart'; +import 'chat_builders/chat_builders.dart'; const onEndReachedThreshold = 0.75; +const _kScrollTag = 'kScrollTag'; +const kSeqId = 'seqId'; +const kSending = 'sending'; +const maxMessageLength = 2048; -class ChatComponentWidget extends StatelessWidget { - const ChatComponentWidget._({required super.key}); +class ChatComponentWidget extends StatefulWidget { + const ChatComponentWidget._({ + required super.key, + required TypedKey localConversationRecordKey, + required void Function() onCancel, + required void Function() onClose, + }) : _localConversationRecordKey = localConversationRecordKey, + _onCancel = onCancel, + _onClose = onClose; - // Builder wrapper function that takes care of state management requirements - static Widget builder( - {required TypedKey localConversationRecordKey, Key? key}) => - Builder(builder: (context) { - // Get all watched dependendies - final activeAccountInfo = context.watch(); - final accountRecordInfo = - context.watch().state.asData?.value; - if (accountRecordInfo == null) { - return debugPage('should always have an account record here'); - } + // Create a single-contact chat and its associated state + static Widget singleContact({ + required BuildContext context, + required TypedKey localConversationRecordKey, + required void Function() onCancel, + required void Function() onClose, + Key? key, + }) { + // Get the account info + final accountInfo = context.watch().state; - final avconversation = context.select?>( - (x) => x.state[localConversationRecordKey]); - if (avconversation == null) { - return waitingPage(); - } + // Get the account record cubit + final accountRecordCubit = context.read(); - final activeConversationState = avconversation.asData?.value; - if (activeConversationState == null) { - return avconversation.buildNotData(); - } + // Get the contact list cubit + final contactListCubit = context.watch(); - // Get the messages cubit - final messagesCubit = context.select< - ActiveSingleContactChatBlocMapCubit, - SingleContactMessagesCubit?>( - (x) => x.tryOperate(localConversationRecordKey, - closure: (cubit) => cubit)); - if (messagesCubit == null) { - return waitingPage(); - } + // Get the active conversation cubit + final activeConversationCubit = context.select< + ActiveConversationsBlocMapCubit, + ActiveConversationCubit?>((x) => x.entry(localConversationRecordKey)); + if (activeConversationCubit == null) { + return waitingPage(onCancel: onCancel); + } - // Make chat component state - return BlocProvider( - create: (context) => ChatComponentCubit.singleContact( - activeAccountInfo: activeAccountInfo, - accountRecordInfo: accountRecordInfo, - activeConversationState: activeConversationState, - messagesCubit: messagesCubit, - ), - child: ChatComponentWidget._(key: key)); - }); + // Get the messages cubit + final messagesCubit = context.select( + (x) => x.entry(localConversationRecordKey)); + if (messagesCubit == null) { + return waitingPage(onCancel: onCancel); + } + + // Make chat component state + return BlocProvider( + key: key, + create: (context) => ChatComponentCubit.singleContact( + accountInfo: accountInfo, + accountRecordCubit: accountRecordCubit, + contactListCubit: contactListCubit, + activeConversationCubit: activeConversationCubit, + messagesCubit: messagesCubit, + ), + child: ChatComponentWidget._( + key: ValueKey(localConversationRecordKey), + localConversationRecordKey: localConversationRecordKey, + onCancel: onCancel, + onClose: onClose)); + } + + @override + State createState() => _ChatComponentWidgetState(); + + //////////////////////////////////////////////////////////////////////////// + final TypedKey _localConversationRecordKey; + final void Function() _onCancel; + final void Function() _onClose; +} + +class _ChatComponentWidgetState extends State { + //////////////////////////////////////////////////////////////////// + + @override + void initState() { + _chatController = core.InMemoryChatController(); + _textEditingController = TextEditingController(); + _scrollController = ScrollController(); + _chatStateProcessor = SingleStateProcessor(); + _focusNode = FocusNode(); + + final chatComponentCubit = context.read(); + _chatStateProcessor.follow( + chatComponentCubit.stream, chatComponentCubit.state, _updateChatState); + + super.initState(); + } + + @override + void dispose() { + unawaited(_chatStateProcessor.close()); + + _focusNode.dispose(); + _chatController.dispose(); + _scrollController.dispose(); + _textEditingController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scaleTheme = theme.extension()!; + final scale = scaleTheme.scheme.scale(ScaleKind.primary); + final textTheme = theme.textTheme; + final scaleChatTheme = scaleTheme.chatTheme(); + + // Get the enclosing chat component cubit that contains our state + // (created by ChatComponentWidget.singleContact()) + final chatComponentCubit = context.watch(); + final chatComponentState = chatComponentCubit.state; + + final localUser = chatComponentState.localUser; + if (localUser == null) { + return const EmptyChatWidget(); + } + + final messageWindow = chatComponentState.messageWindow.asData?.value; + if (messageWindow == null) { + return chatComponentState.messageWindow.buildNotData(); + } + final isLastPage = messageWindow.windowStart == 0; + final isFirstPage = messageWindow.windowEnd == messageWindow.length - 1; + final title = chatComponentState.title; + + if (chatComponentCubit.scrollOffset != 0) { + _scrollController.position.correctPixels( + _scrollController.position.pixels + chatComponentCubit.scrollOffset); + + chatComponentCubit.scrollOffset = 0; + } + + return Column( + children: [ + Container( + height: 40.scaledNoShrink(context), + decoration: BoxDecoration( + color: scale.border, + ), + child: Row(children: [ + Align( + alignment: AlignmentDirectional.centerStart, + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(16, 0, 16, 0), + child: Text(title, + textAlign: TextAlign.start, + style: textTheme.titleMedium! + .copyWith(color: scale.borderText)), + )), + const Spacer(), + IconButton( + iconSize: 24.scaledNoShrink(context), + icon: Icon(Icons.close, color: scale.borderText), + onPressed: widget._onClose) + .paddingLTRB(0, 0, 8, 0) + ]), + ), + DecoratedBox( + decoration: const BoxDecoration(color: Colors.transparent), + child: NotificationListener( + onNotification: (notification) { + if (chatComponentCubit.scrollOffset != 0) { + return false; + } + + if (!isFirstPage && + notification.metrics.pixels <= + ((notification.metrics.maxScrollExtent - + notification.metrics.minScrollExtent) * + (1.0 - onEndReachedThreshold) + + notification.metrics.minScrollExtent)) { + // + final scrollOffset = (notification.metrics.maxScrollExtent - + notification.metrics.minScrollExtent) * + (1.0 - onEndReachedThreshold); + + chatComponentCubit.scrollOffset = scrollOffset; + + // + singleFuture((chatComponentCubit, _kScrollTag), () async { + await _handlePageForward( + chatComponentCubit, messageWindow, notification); + }); + } else if (!isLastPage && + notification.metrics.pixels >= + ((notification.metrics.maxScrollExtent - + notification.metrics.minScrollExtent) * + onEndReachedThreshold + + notification.metrics.minScrollExtent)) { + // + final scrollOffset = + -(notification.metrics.maxScrollExtent - + notification.metrics.minScrollExtent) * + (1.0 - onEndReachedThreshold); + + chatComponentCubit.scrollOffset = scrollOffset; + // + singleFuture((chatComponentCubit, _kScrollTag), () async { + await _handlePageBackward( + chatComponentCubit, messageWindow, notification); + }); + } + return false; + }, + child: ValueListenableBuilder( + valueListenable: _textEditingController, + builder: (context, textEditingValue, __) { + final messageIsValid = + _messageIsValid(textEditingValue.text); + var sendIconColor = scaleTheme.config.preferBorders + ? scale.border + : scale.borderText; + + if (!messageIsValid || + _textEditingController.text.isEmpty) { + sendIconColor = sendIconColor.withAlpha(128); + } + + return Chat( + currentUserId: localUser.id, + resolveUser: (id) async { + if (id == localUser.id) { + return localUser; + } + return chatComponentState.remoteUsers.get(id); + }, + chatController: _chatController, + onMessageSend: (text) => + _handleSendPressed(chatComponentCubit, text), + theme: scaleChatTheme.chatTheme, + builders: core.Builders( + // Chat list builder + chatAnimatedListBuilder: (context, itemBuilder) => + ChatAnimatedListReversed( + scrollController: _scrollController, + messageGroupingTimeoutInSeconds: 60, + itemBuilder: itemBuilder), + // Text message builder + textMessageBuilder: (context, message, index) { + var showTime = true; + if (_chatController.messages.length > 1 && + index < _chatController.messages.length - 1 && + message.time != null) { + final nextMessage = + _chatController.messages[index + 1]; + if (nextMessage.time != null) { + if (nextMessage.time! + .difference(message.time!) + .inSeconds < + 60 && + nextMessage.authorId == message.authorId) { + showTime = false; + } + } + } + return VcTextMessageWidget( + message: message, + index: index, + padding: const EdgeInsets.symmetric( + vertical: 12, horizontal: 16) + .scaled(context), + showTime: showTime, + showStatus: showTime, + ); + }, + // Composer builder + composerBuilder: (ctx) => VcComposerWidget( + autofocus: true, + padding: const EdgeInsets.all(4).scaled(context), + gap: 8.scaled(context), + focusNode: _focusNode, + textInputAction: isAnyMobile + ? TextInputAction.newline + : TextInputAction.send, + shiftEnterAction: isAnyMobile + ? ShiftEnterAction.send + : ShiftEnterAction.newline, + textEditingController: _textEditingController, + maxLength: maxMessageLength, + keyboardType: TextInputType.multiline, + sendIconColor: sendIconColor, + topWidget: messageIsValid + ? null + : Text(translate('chat.message_too_long'), + style: TextStyle( + color: scaleTheme + .scheme.errorScale.primary)) + .toCenter(), + ), + ), + timeFormat: core.DateFormat.jm(), + ); + }))).expanded(), + ], + ); + } ///////////////////////////////////////////////////////////////////// - void _handleSendPressed( - ChatComponentCubit chatComponentCubit, types.PartialText message) { - final text = message.text; + bool _messageIsValid(String text) => + utf8.encode(text).lengthInBytes < maxMessageLength; + + Future _updateChatState(ChatComponentState chatComponentState) async { + // Update message window state + final data = chatComponentState.messageWindow.asData; + if (data == null) { + await _chatController.setMessages([]); + return; + } + + final windowState = data.value; + + // await _chatController.setMessages(windowState.window.toList()); + + final newMessagesSet = windowState.window.toSet(); + final newMessagesById = + Map.fromEntries(newMessagesSet.map((m) => MapEntry(m.id, m))); + final newMessagesBySeqId = Map.fromEntries( + newMessagesSet.map((m) => MapEntry(m.metadata![kSeqId], m))); + final oldMessagesSet = _chatController.messages.toSet(); + + if (oldMessagesSet.isEmpty) { + await _chatController.setMessages(windowState.window.toList()); + return; + } + + // See how many messages differ by equality (not identity) + // If there are more than `replaceAllMessagesThreshold` differences + // just replace the whole list of messages + final diffs = newMessagesSet.diffAndIntersect(oldMessagesSet, + diffThisMinusOther: true, diffOtherMinusThis: true); + final addedMessages = diffs.diffThisMinusOther!; + final removedMessages = diffs.diffOtherMinusThis!; + + final replaceAllPaginationLimit = windowState.windowCount / 3; + + if ((addedMessages.length >= replaceAllPaginationLimit) || + removedMessages.length >= replaceAllPaginationLimit) { + await _chatController.setMessages(windowState.window.toList()); + return; + } + + // Remove messages that are gone, and replace the ones that have changed + for (final m in removedMessages) { + final newm = newMessagesById[m.id]; + if (newm != null) { + await _chatController.updateMessage(m, newm); + } else { + final newm = newMessagesBySeqId[m.metadata![kSeqId]]; + if (newm != null) { + await _chatController.updateMessage(m, newm); + addedMessages.remove(newm); + } else { + await _chatController.removeMessage(m); + } + } + } + + if (addedMessages.isNotEmpty) { + await _chatController.setMessages(windowState.window.toList()); + } + + // // // Check for append + // if (addedMessages.isNotEmpty) { + // if (_chatController.messages.isNotEmpty && + // (addedMessages.first.metadata![kSeqId] as int) > + // (_chatController.messages.reversed.last.metadata![kSeqId] + // as int)) { + // await _chatController.insertAllMessages(addedMessages.reversedView, + // index: 0); + // } + // // Check for prepend + // else if (_chatController.messages.isNotEmpty && + // (addedMessages.last.metadata![kSeqId] as int) < + // (_chatController.messages.reversed.first.metadata![kSeqId] + // as int)) { + // await _chatController.insertAllMessages( + // addedMessages.reversedView, + // ); + // } + // // Otherwise just replace + // // xxx could use a better algorithm here to merge added messages in + // else { + // await _chatController.setMessages(windowState.window.toList()); + // } + // } + } + + void _handleSendPressed(ChatComponentCubit chatComponentCubit, String text) { + _focusNode.requestFocus(); if (text.startsWith('/')) { chatComponentCubit.runCommand(text); return; } - chatComponentCubit.sendMessage(message); + if (!_messageIsValid(text)) { + context + .read() + .error(text: translate('chat.message_too_long')); + return; + } + + chatComponentCubit.sendMessage(text: text); } // void _handleAttachmentPressed() async { - // // - // } - Future _handlePageForward( ChatComponentCubit chatComponentCubit, - WindowState messageWindow, + WindowState messageWindow, ScrollNotification notification) async { - print( - '_handlePageForward: messagesState.length=${messageWindow.length} messagesState.windowTail=${messageWindow.windowTail} messagesState.windowCount=${messageWindow.windowCount} ScrollNotification=$notification'); + debugPrint( + '_handlePageForward: messagesState.length=${messageWindow.length} ' + 'messagesState.windowTail=${messageWindow.windowTail} ' + 'messagesState.windowCount=${messageWindow.windowCount} ' + 'ScrollNotification=$notification'); // Go forward a page final tail = min(messageWindow.length, @@ -114,11 +469,14 @@ class ChatComponentWidget extends StatelessWidget { Future _handlePageBackward( ChatComponentCubit chatComponentCubit, - WindowState messageWindow, + WindowState messageWindow, ScrollNotification notification, ) async { - print( - '_handlePageBackward: messagesState.length=${messageWindow.length} messagesState.windowTail=${messageWindow.windowTail} messagesState.windowCount=${messageWindow.windowCount} ScrollNotification=$notification'); + debugPrint( + '_handlePageBackward: messagesState.length=${messageWindow.length} ' + 'messagesState.windowTail=${messageWindow.windowTail} ' + 'messagesState.windowCount=${messageWindow.windowCount} ' + 'ScrollNotification=$notification'); // Go back a page final tail = max( @@ -147,148 +505,9 @@ class ChatComponentWidget extends StatelessWidget { //chatComponentCubit.scrollOffset = 0; } - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scale = theme.extension()!; - final textTheme = Theme.of(context).textTheme; - final chatTheme = makeChatTheme(scale, textTheme); - - // Get the enclosing chat component cubit that contains our state - // (created by ChatComponentWidget.builder()) - final chatComponentCubit = context.watch(); - final chatComponentState = chatComponentCubit.state; - - final messageWindow = chatComponentState.messageWindow.asData?.value; - if (messageWindow == null) { - return chatComponentState.messageWindow.buildNotData(); - } - final isLastPage = messageWindow.windowStart == 0; - final isFirstPage = messageWindow.windowEnd == messageWindow.length - 1; - final title = chatComponentState.title; - - if (chatComponentCubit.scrollOffset != 0) { - chatComponentState.scrollController.position.correctPixels( - chatComponentState.scrollController.position.pixels + - chatComponentCubit.scrollOffset); - - chatComponentCubit.scrollOffset = 0; - } - - return DefaultTextStyle( - style: textTheme.bodySmall!, - child: Align( - alignment: AlignmentDirectional.centerEnd, - child: Stack( - children: [ - Column( - children: [ - Container( - height: 48, - decoration: BoxDecoration( - color: scale.primaryScale.subtleBorder, - ), - child: Row(children: [ - Align( - alignment: AlignmentDirectional.centerStart, - child: Padding( - padding: const EdgeInsetsDirectional.fromSTEB( - 16, 0, 16, 0), - child: Text(title, - textAlign: TextAlign.start, - style: textTheme.titleMedium!.copyWith( - color: scale.primaryScale.borderText)), - )), - const Spacer(), - IconButton( - icon: Icon(Icons.close, - color: scale.primaryScale.borderText), - onPressed: () async { - context.read().setActiveChat(null); - }).paddingLTRB(16, 0, 16, 0) - ]), - ), - Expanded( - child: DecoratedBox( - decoration: const BoxDecoration(), - child: NotificationListener( - onNotification: (notification) { - if (chatComponentCubit.scrollOffset != 0) { - return false; - } - - if (!isFirstPage && - notification.metrics.pixels <= - ((notification.metrics.maxScrollExtent - - notification - .metrics.minScrollExtent) * - (1.0 - onEndReachedThreshold) + - notification.metrics.minScrollExtent)) { - // - final scrollOffset = (notification - .metrics.maxScrollExtent - - notification.metrics.minScrollExtent) * - (1.0 - onEndReachedThreshold); - - chatComponentCubit.scrollOffset = scrollOffset; - - // - singleFuture(chatComponentState.chatKey, - () async { - await _handlePageForward(chatComponentCubit, - messageWindow, notification); - }); - } else if (!isLastPage && - notification.metrics.pixels >= - ((notification.metrics.maxScrollExtent - - notification - .metrics.minScrollExtent) * - onEndReachedThreshold + - notification.metrics.minScrollExtent)) { - // - final scrollOffset = -(notification - .metrics.maxScrollExtent - - notification.metrics.minScrollExtent) * - (1.0 - onEndReachedThreshold); - - chatComponentCubit.scrollOffset = scrollOffset; - // - singleFuture(chatComponentState.chatKey, - () async { - await _handlePageBackward(chatComponentCubit, - messageWindow, notification); - }); - } - return false; - }, - child: Chat( - key: chatComponentState.chatKey, - theme: chatTheme, - messages: messageWindow.window.toList(), - scrollToBottomOnSend: isFirstPage, - scrollController: - chatComponentState.scrollController, - // isLastPage: isLastPage, - // onEndReached: () async { - // await _handlePageBackward( - // chatComponentCubit, messageWindow); - // }, - //onEndReachedThreshold: onEndReachedThreshold, - //onAttachmentPressed: _handleAttachmentPressed, - //onMessageTap: _handleMessageTap, - //onPreviewDataFetched: _handlePreviewDataFetched, - onSendPressed: (pt) => - _handleSendPressed(chatComponentCubit, pt), - //showUserAvatars: false, - //showUserNames: true, - user: chatComponentState.localUser, - emptyState: const EmptyChatWidget())), - ), - ), - ], - ), - ], - ), - )); - } + late final core.ChatController _chatController; + late final TextEditingController _textEditingController; + late final ScrollController _scrollController; + late final SingleStateProcessor _chatStateProcessor; + late final FocusNode _focusNode; } diff --git a/lib/chat/views/date_formatter.dart b/lib/chat/views/date_formatter.dart new file mode 100644 index 0000000..2835b70 --- /dev/null +++ b/lib/chat/views/date_formatter.dart @@ -0,0 +1,41 @@ +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:intl/intl.dart'; + +class DateFormatter { + DateFormatter(); + + String chatDateTimeFormat(DateTime dateTime) { + final now = DateTime.now(); + + final justNow = now.subtract(const Duration(minutes: 1)); + + final localDateTime = dateTime.toLocal(); + + if (!localDateTime.difference(justNow).isNegative) { + return translate('date_formatter.just_now'); + } + + final roughTimeString = DateFormat.jm().format(dateTime); + + if (localDateTime.day == now.day && + localDateTime.month == now.month && + localDateTime.year == now.year) { + return roughTimeString; + } + + final yesterday = now.subtract(const Duration(days: 1)); + + if (localDateTime.day == yesterday.day && + localDateTime.month == now.month && + localDateTime.year == now.year) { + return translate('date_formatter.yesterday'); + } + + if (now.difference(localDateTime).inDays < 4) { + final weekday = DateFormat(DateFormat.WEEKDAY).format(localDateTime); + + return '$weekday, $roughTimeString'; + } + return '${DateFormat.yMd().format(dateTime)}, $roughTimeString'; + } +} diff --git a/lib/chat/views/empty_chat_widget.dart b/lib/chat/views/empty_chat_widget.dart index c975722..946fc3f 100644 --- a/lib/chat/views/empty_chat_widget.dart +++ b/lib/chat/views/empty_chat_widget.dart @@ -7,7 +7,6 @@ class EmptyChatWidget extends StatelessWidget { const EmptyChatWidget({super.key}); @override - // ignore: prefer_expression_function_bodies Widget build( BuildContext context, ) { @@ -18,7 +17,7 @@ class EmptyChatWidget extends StatelessWidget { width: double.infinity, height: double.infinity, decoration: BoxDecoration( - color: Theme.of(context).scaffoldBackgroundColor, + color: scale.primaryScale.appBackground, ), child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/chat/views/new_chat_bottom_sheet.dart b/lib/chat/views/new_chat_bottom_sheet.dart deleted file mode 100644 index 646a3ec..0000000 --- a/lib/chat/views/new_chat_bottom_sheet.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_translate/flutter_translate.dart'; - -import '../../theme/theme.dart'; - -Widget newChatBottomSheetBuilder( - BuildContext sheetContext, BuildContext context) { - //final theme = Theme.of(sheetContext); - //final scale = theme.extension()!; - - return KeyboardListener( - focusNode: FocusNode(), - onKeyEvent: (ke) { - if (ke.logicalKey == LogicalKeyboardKey.escape) { - Navigator.pop(sheetContext); - } - }, - child: styledBottomSheet( - context: context, - title: translate('add_chat_sheet.new_chat'), - child: SizedBox( - height: 160, - child: const Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Text( - 'Group and custom chat functionality is not available yet') - ]).paddingAll(16)))); -} diff --git a/lib/chat/views/no_conversation_widget.dart b/lib/chat/views/no_conversation_widget.dart index c19b535..3269bea 100644 --- a/lib/chat/views/no_conversation_widget.dart +++ b/lib/chat/views/no_conversation_widget.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import '../../theme/models/scale_scheme.dart'; +import '../../theme/theme.dart'; class NoConversationWidget extends StatelessWidget { const NoConversationWidget({super.key}); @@ -12,30 +12,31 @@ class NoConversationWidget extends StatelessWidget { BuildContext context, ) { final theme = Theme.of(context); - final scale = theme.extension()!; + final scaleScheme = theme.extension()!; + final scaleConfig = theme.extension()!; + final scale = scaleScheme.scale(ScaleKind.primary); - return Container( - width: double.infinity, - height: double.infinity, - decoration: BoxDecoration( - color: Theme.of(context).scaffoldBackgroundColor, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.diversity_3, - color: scale.primaryScale.subtleBorder, - size: 48, - ), - Text( - translate('chat.start_a_conversation'), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scale.primaryScale.subtleBorder, - ), - ), - ], - ), - ); + return DecoratedBox( + decoration: BoxDecoration( + color: scale.appBackground.withAlpha(scaleConfig.wallpaperAlpha), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.diversity_3, + color: scale.appText.withAlpha(127), + size: 48, + ), + Text( + textAlign: TextAlign.center, + translate('chat.start_a_conversation'), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: scale.appText.withAlpha(127), + ), + ), + ], + )); } } diff --git a/lib/chat/views/utf8_length_limiting_text_input_formatter.dart b/lib/chat/views/utf8_length_limiting_text_input_formatter.dart new file mode 100644 index 0000000..f037ca8 --- /dev/null +++ b/lib/chat/views/utf8_length_limiting_text_input_formatter.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter { + Utf8LengthLimitingTextInputFormatter({this.maxLength}) + : assert(maxLength != null || maxLength! >= 0, 'maxLength is invalid'); + + final int? maxLength; + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + if (maxLength != null && _bytesLength(newValue.text) > maxLength!) { + // If already at the maximum and tried to enter even more, + // keep the old value. + if (_bytesLength(oldValue.text) == maxLength) { + return oldValue; + } + return _truncate(newValue, maxLength!); + } + return newValue; + } + + static TextEditingValue _truncate(TextEditingValue value, int maxLength) { + var newValue = ''; + if (_bytesLength(value.text) > maxLength) { + var length = 0; + + value.text.characters.takeWhile((char) { + final nbBytes = _bytesLength(char); + if (length + nbBytes <= maxLength) { + newValue += char; + length += nbBytes; + return true; + } + return false; + }); + } + return TextEditingValue( + text: newValue, + selection: value.selection.copyWith( + baseOffset: min(value.selection.start, newValue.length), + extentOffset: min(value.selection.end, newValue.length), + ), + ); + } + + static int _bytesLength(String value) => utf8.encode(value).length; +} diff --git a/lib/chat/views/views.dart b/lib/chat/views/views.dart index 7e8adce..41b1936 100644 --- a/lib/chat/views/views.dart +++ b/lib/chat/views/views.dart @@ -1,4 +1,4 @@ export 'chat_component_widget.dart'; export 'empty_chat_widget.dart'; -export 'new_chat_bottom_sheet.dart'; export 'no_conversation_widget.dart'; +export 'utf8_length_limiting_text_input_formatter.dart'; diff --git a/lib/chat_list/cubits/active_conversations_bloc_map_cubit.dart b/lib/chat_list/cubits/active_conversations_bloc_map_cubit.dart deleted file mode 100644 index c497941..0000000 --- a/lib/chat_list/cubits/active_conversations_bloc_map_cubit.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'package:async_tools/async_tools.dart'; -import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; -import 'package:equatable/equatable.dart'; -import 'package:meta/meta.dart'; -import 'package:veilid_support/veilid_support.dart'; - -import '../../account_manager/account_manager.dart'; -import '../../contacts/contacts.dart'; -import '../../proto/proto.dart' as proto; -import 'cubits.dart'; - -@immutable -class ActiveConversationState extends Equatable { - const ActiveConversationState({ - required this.contact, - required this.localConversation, - required this.remoteConversation, - }); - - final proto.Contact contact; - final proto.Conversation localConversation; - final proto.Conversation remoteConversation; - - @override - List get props => [contact, localConversation, remoteConversation]; -} - -typedef ActiveConversationCubit = TransformerCubit< - AsyncValue, AsyncValue>; - -typedef ActiveConversationsBlocMapState - = BlocMapState>; - -// Map of localConversationRecordKey to ActiveConversationCubit -// Wraps a conversation cubit to only expose completely built conversations -// Automatically follows the state of a ChatListCubit. -// Even though 'conversations' are per-contact and not per-chat -// We currently only build the cubits for the chats that are active, not -// archived chats or contacts that are not actively in a chat. -class ActiveConversationsBlocMapCubit extends BlocMapCubit, ActiveConversationCubit> - with StateMapFollower { - ActiveConversationsBlocMapCubit( - {required ActiveAccountInfo activeAccountInfo, - required ContactListCubit contactListCubit}) - : _activeAccountInfo = activeAccountInfo, - _contactListCubit = contactListCubit; - - // Add an active conversation to be tracked for changes - Future _addConversation({required proto.Contact contact}) async => - add(() => MapEntry( - contact.localConversationRecordKey.toVeilid(), - TransformerCubit( - ConversationCubit( - activeAccountInfo: _activeAccountInfo, - remoteIdentityPublicKey: contact.identityPublicKey.toVeilid(), - localConversationRecordKey: - contact.localConversationRecordKey.toVeilid(), - remoteConversationRecordKey: - contact.remoteConversationRecordKey.toVeilid(), - ), - // Transformer that only passes through completed conversations - // along with the contact that corresponds to the completed - // conversation - transform: (avstate) => avstate.when( - data: (data) => (data.localConversation == null || - data.remoteConversation == null) - ? const AsyncValue.loading() - : AsyncValue.data(ActiveConversationState( - contact: contact, - localConversation: data.localConversation!, - remoteConversation: data.remoteConversation!)), - loading: AsyncValue.loading, - error: AsyncValue.error)))); - - /// StateFollower ///////////////////////// - - @override - Future removeFromState(TypedKey key) => remove(key); - - @override - Future updateState(TypedKey key, proto.Chat value) async { - final contactList = _contactListCubit.state.state.asData?.value; - if (contactList == null) { - await addState(key, const AsyncValue.loading()); - return; - } - final contactIndex = contactList.indexWhere( - (c) => c.value.localConversationRecordKey.toVeilid() == key); - if (contactIndex == -1) { - await addState(key, AsyncValue.error('Contact not found')); - return; - } - final contact = contactList[contactIndex]; - await _addConversation(contact: contact.value); - } - - //// - - final ActiveAccountInfo _activeAccountInfo; - final ContactListCubit _contactListCubit; -} diff --git a/lib/chat_list/cubits/active_single_contact_chat_bloc_map_cubit.dart b/lib/chat_list/cubits/active_single_contact_chat_bloc_map_cubit.dart deleted file mode 100644 index 914d357..0000000 --- a/lib/chat_list/cubits/active_single_contact_chat_bloc_map_cubit.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'dart:async'; - -import 'package:async_tools/async_tools.dart'; -import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; -import 'package:veilid_support/veilid_support.dart'; - -import '../../account_manager/account_manager.dart'; -import '../../chat/chat.dart'; -import '../../contacts/contacts.dart'; -import '../../proto/proto.dart' as proto; -import 'active_conversations_bloc_map_cubit.dart'; -import 'chat_list_cubit.dart'; - -// Map of localConversationRecordKey to MessagesCubit -// Wraps a MessagesCubit to stream the latest messages to the state -// Automatically follows the state of a ActiveConversationsBlocMapCubit. -class ActiveSingleContactChatBlocMapCubit extends BlocMapCubit - with - StateMapFollower> { - ActiveSingleContactChatBlocMapCubit( - {required ActiveAccountInfo activeAccountInfo, - required ContactListCubit contactListCubit, - required ChatListCubit chatListCubit}) - : _activeAccountInfo = activeAccountInfo, - _contactListCubit = contactListCubit, - _chatListCubit = chatListCubit; - - Future _addConversationMessages( - {required proto.Contact contact, - required proto.Chat chat, - required proto.Conversation localConversation, - required proto.Conversation remoteConversation}) async => - add(() => MapEntry( - contact.localConversationRecordKey.toVeilid(), - SingleContactMessagesCubit( - activeAccountInfo: _activeAccountInfo, - remoteIdentityPublicKey: contact.identityPublicKey.toVeilid(), - localConversationRecordKey: - contact.localConversationRecordKey.toVeilid(), - remoteConversationRecordKey: - contact.remoteConversationRecordKey.toVeilid(), - localMessagesRecordKey: localConversation.messages.toVeilid(), - remoteMessagesRecordKey: remoteConversation.messages.toVeilid(), - ))); - - /// StateFollower ///////////////////////// - - @override - Future removeFromState(TypedKey key) => remove(key); - - @override - Future updateState( - TypedKey key, AsyncValue value) async { - // Get the contact object for this single contact chat - final contactList = _contactListCubit.state.state.asData?.value; - if (contactList == null) { - await addState(key, const AsyncValue.loading()); - return; - } - final contactIndex = contactList.indexWhere( - (c) => c.value.localConversationRecordKey.toVeilid() == key); - if (contactIndex == -1) { - await addState( - key, AsyncValue.error('Contact not found for conversation')); - return; - } - final contact = contactList[contactIndex].value; - - // Get the chat object for this single contact chat - final chatList = _chatListCubit.state.state.asData?.value; - if (chatList == null) { - await addState(key, const AsyncValue.loading()); - return; - } - final chatIndex = chatList.indexWhere( - (c) => c.value.localConversationRecordKey.toVeilid() == key); - if (contactIndex == -1) { - await addState(key, AsyncValue.error('Chat not found for conversation')); - return; - } - final chat = chatList[chatIndex].value; - - await value.when( - data: (state) => _addConversationMessages( - contact: contact, - chat: chat, - localConversation: state.localConversation, - remoteConversation: state.remoteConversation), - loading: () => addState(key, const AsyncValue.loading()), - error: (error, stackTrace) => - addState(key, AsyncValue.error(error, stackTrace))); - } - - //// - - final ActiveAccountInfo _activeAccountInfo; - final ContactListCubit _contactListCubit; - final ChatListCubit _chatListCubit; -} diff --git a/lib/chat_list/cubits/chat_list_cubit.dart b/lib/chat_list/cubits/chat_list_cubit.dart index 0c36f52..ae31f29 100644 --- a/lib/chat_list/cubits/chat_list_cubit.dart +++ b/lib/chat_list/cubits/chat_list_cubit.dart @@ -8,44 +8,40 @@ import 'package:veilid_support/veilid_support.dart'; import '../../account_manager/account_manager.dart'; import '../../chat/chat.dart'; import '../../proto/proto.dart' as proto; -import '../../tools/tools.dart'; ////////////////////////////////////////////////// ////////////////////////////////////////////////// // Mutable state for per-account chat list -typedef ChatListCubitState = DHTShortArrayBusyState; +typedef ChatListCubitState = DHTShortArrayCubitState; class ChatListCubit extends DHTShortArrayCubit with StateMapFollowable { ChatListCubit({ - required ActiveAccountInfo activeAccountInfo, - required proto.Account account, - required this.activeChatCubit, - }) : super( - open: () => _open(activeAccountInfo, account), + required AccountInfo accountInfo, + required OwnedDHTRecordPointer chatListRecordPointer, + required ActiveChatCubit activeChatCubit, + }) : _activeChatCubit = activeChatCubit, + super( + open: () => _open(accountInfo, chatListRecordPointer), decodeElement: proto.Chat.fromBuffer); - static Future _open( - ActiveAccountInfo activeAccountInfo, proto.Account account) async { - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - final chatListRecordKey = account.chatList.toVeilid(); - - final dhtRecord = await DHTShortArray.openOwned(chatListRecordKey, - debugName: 'ChatListCubit::_open::ChatList', parent: accountRecordKey); + static Future _open(AccountInfo accountInfo, + OwnedDHTRecordPointer chatListRecordPointer) async { + final dhtRecord = await DHTShortArray.openOwned(chatListRecordPointer, + debugName: 'ChatListCubit::_open::ChatList', + parent: accountInfo.accountRecordKey); return dhtRecord; } Future getDefaultChatSettings( proto.Contact contact) async { - final pronouns = contact.editedProfile.pronouns.isEmpty + final pronouns = contact.profile.pronouns.isEmpty ? '' - : ' (${contact.editedProfile.pronouns})'; + : ' [${contact.profile.pronouns}])'; return proto.ChatSettings() - ..title = '${contact.editedProfile.name}$pronouns' + ..title = '${contact.displayName}$pronouns' ..description = '' ..defaultExpiration = Int64.ZERO; } @@ -57,12 +53,24 @@ class ChatListCubit extends DHTShortArrayCubit // Make local copy so we don't share the buffer final localConversationRecordKey = contact.localConversationRecordKey.toVeilid(); + final remoteIdentityPublicKey = contact.identityPublicKey.toVeilid(); final remoteConversationRecordKey = contact.remoteConversationRecordKey.toVeilid(); + // Create 1:1 conversation type Chat + final chatMember = proto.ChatMember() + ..remoteIdentityPublicKey = remoteIdentityPublicKey.toProto() + ..remoteConversationRecordKey = remoteConversationRecordKey.toProto(); + + final directChat = proto.DirectChat() + ..settings = await getDefaultChatSettings(contact) + ..localConversationRecordKey = localConversationRecordKey.toProto() + ..remoteMember = chatMember; + + final chat = proto.Chat()..direct = directChat; + // Add Chat to account's list - // if this fails, don't keep retrying, user can try again later - await operateWrite((writer) async { + await operateWriteEventual((writer) async { // See if we have added this chat already for (var i = 0; i < writer.length; i++) { final cbuf = await writer.get(i); @@ -70,19 +78,25 @@ class ChatListCubit extends DHTShortArrayCubit throw Exception('Failed to get chat'); } final c = proto.Chat.fromBuffer(cbuf); - if (c.localConversationRecordKey == - contact.localConversationRecordKey) { - // Nothing to do here - return; + + switch (c.whichKind()) { + case proto.Chat_Kind.direct: + if (c.direct.localConversationRecordKey == + contact.localConversationRecordKey) { + // Nothing to do here + return; + } + case proto.Chat_Kind.group: + if (c.group.localConversationRecordKey == + contact.localConversationRecordKey) { + throw StateError('direct conversation record key should' + ' not be used for group chats!'); + } + case proto.Chat_Kind.notSet: + throw StateError('unknown chat kind'); } } - // Create 1:1 conversation type Chat - final chat = proto.Chat() - ..settings = await getDefaultChatSettings(contact) - ..localConversationRecordKey = localConversationRecordKey.toProto() - ..remoteConversationRecordKey = remoteConversationRecordKey.toProto(); - // Add chat await writer.add(chat.writeToBuffer()); }); @@ -91,41 +105,23 @@ class ChatListCubit extends DHTShortArrayCubit /// Delete a chat Future deleteChat( {required TypedKey localConversationRecordKey}) async { - final localConversationRecordKeyProto = - localConversationRecordKey.toProto(); - // Remove Chat from account's list - // if this fails, don't keep retrying, user can try again later - final deletedItem = - // Ensure followers get their changes before we return - await syncFollowers(() => operateWrite((writer) async { - if (activeChatCubit.state == localConversationRecordKey) { - activeChatCubit.setActiveChat(null); - } - for (var i = 0; i < writer.length; i++) { - final c = await writer.getProtobuf(proto.Chat.fromBuffer, i); - if (c == null) { - throw Exception('Failed to get chat'); - } - if (c.localConversationRecordKey == - localConversationRecordKeyProto) { - // Found the right chat - await writer.remove(i); - return c; - } - } - return null; - })); - // Since followers are synced, we can safetly remove the reconciled - // chat record now - if (deletedItem != null) { - try { - await SingleContactMessagesCubit.cleanupAndDeleteMessages( - localConversationRecordKey: localConversationRecordKey); - } on Exception catch (e) { - log.debug('error removing reconciled chat table: $e', e); + await operateWriteEventual((writer) async { + if (_activeChatCubit.state == localConversationRecordKey) { + _activeChatCubit.setActiveChat(null); } - } + for (var i = 0; i < writer.length; i++) { + final c = await writer.getProtobuf(proto.Chat.fromBuffer, i); + if (c == null) { + throw Exception('Failed to get chat'); + } + + if (c.localConversationRecordKey == localConversationRecordKey) { + await writer.remove(i); + return; + } + } + }); } /// StateMapFollowable ///////////////////////// @@ -136,9 +132,11 @@ class ChatListCubit extends DHTShortArrayCubit return IMap(); } return IMap.fromIterable(stateValue, - keyMapper: (e) => e.value.localConversationRecordKey.toVeilid(), + keyMapper: (e) => e.value.localConversationRecordKey, valueMapper: (e) => e.value); } - final ActiveChatCubit activeChatCubit; + //////////////////////////////////////////////////////////////////////////// + + final ActiveChatCubit _activeChatCubit; } diff --git a/lib/chat_list/cubits/cubits.dart b/lib/chat_list/cubits/cubits.dart index 35595db..cafafff 100644 --- a/lib/chat_list/cubits/cubits.dart +++ b/lib/chat_list/cubits/cubits.dart @@ -1,3 +1 @@ -export 'active_single_contact_chat_bloc_map_cubit.dart'; -export 'active_conversations_bloc_map_cubit.dart'; export 'chat_list_cubit.dart'; diff --git a/lib/chat_list/views/chat_list_widget.dart b/lib/chat_list/views/chat_list_widget.dart new file mode 100644 index 0000000..33ddaab --- /dev/null +++ b/lib/chat_list/views/chat_list_widget.dart @@ -0,0 +1,93 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:searchable_listview/searchable_listview.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../contacts/contacts.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import '../chat_list.dart'; + +class ChatListWidget extends StatelessWidget { + const ChatListWidget({super.key}); + + Widget _itemBuilderDirect( + proto.DirectChat direct, IMap contactMap) { + final contact = contactMap[direct.localConversationRecordKey]; + return ChatSingleContactItemWidget( + localConversationRecordKey: + direct.localConversationRecordKey.toVeilid(), + contact: contact) + .paddingLTRB(0, 4, 0, 0); + } + + List _itemFilter(IMap contactMap, + IList> chatList, String filter) { + final lowerValue = filter.toLowerCase(); + return chatList.map((x) => x.value).where((c) { + switch (c.whichKind()) { + case proto.Chat_Kind.direct: + final contact = contactMap[c.direct.localConversationRecordKey]; + if (contact == null) { + return false; + } + return contact.nickname.toLowerCase().contains(lowerValue) || + contact.profile.name.toLowerCase().contains(lowerValue) || + contact.profile.pronouns.toLowerCase().contains(lowerValue); + case proto.Chat_Kind.group: + // xxx: how to filter group chats + return true; + case proto.Chat_Kind.notSet: + throw StateError('unknown chat kind'); + } + }).toList(); + } + + @override + Widget build(BuildContext context) { + final contactListV = context.watch().state; + + return contactListV.builder((context, contactList) { + final contactMap = IMap.fromIterable(contactList, + keyMapper: (c) => c.value.localConversationRecordKey, + valueMapper: (c) => c.value); + + final chatListV = context.watch().state; + return chatListV.builder((context, chatList) => SizedBox.expand( + child: styledContainer( + context: context, + child: (chatList.isEmpty) + ? const SizedBox.expand(child: EmptyChatListWidget()) + : TapRegion( + onTapOutside: (_) { + FocusScope.of(context).unfocus(); + }, + child: SearchableList( + initialList: chatList.map((x) => x.value).toList(), + itemBuilder: (c) { + switch (c.whichKind()) { + case proto.Chat_Kind.direct: + return _itemBuilderDirect( + c.direct, + contactMap, + ); + case proto.Chat_Kind.group: + return const Text('group chats not yet supported!'); + case proto.Chat_Kind.notSet: + throw StateError('unknown chat kind'); + } + }, + filter: (value) => + _itemFilter(contactMap, chatList, value), + searchFieldPadding: const EdgeInsets.fromLTRB(0, 0, 0, 4), + inputDecoration: InputDecoration( + labelText: translate('chat_list.search'), + ), + )).paddingAll(8), + ))); + }); + } +} diff --git a/lib/chat_list/views/chat_single_contact_item_widget.dart b/lib/chat_list/views/chat_single_contact_item_widget.dart index ce9cf0e..d2594c5 100644 --- a/lib/chat_list/views/chat_single_contact_item_widget.dart +++ b/lib/chat_list/views/chat_single_contact_item_widget.dart @@ -2,54 +2,81 @@ import 'package:async_tools/async_tools.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_translate/flutter_translate.dart'; +import 'package:veilid_support/veilid_support.dart'; import '../../chat/cubits/active_chat_cubit.dart'; +import '../../contacts/contacts.dart'; import '../../proto/proto.dart' as proto; import '../../theme/theme.dart'; import '../chat_list.dart'; class ChatSingleContactItemWidget extends StatelessWidget { const ChatSingleContactItemWidget({ - required proto.Contact contact, - required bool disabled, + required TypedKey localConversationRecordKey, + required proto.Contact? contact, + bool disabled = false, super.key, - }) : _contact = contact, + }) : _localConversationRecordKey = localConversationRecordKey, + _contact = contact, _disabled = disabled; - final proto.Contact _contact; + final TypedKey _localConversationRecordKey; + final proto.Contact? _contact; final bool _disabled; @override - // ignore: prefer_expression_function_bodies Widget build( BuildContext context, ) { - final activeChatCubit = context.watch(); - final localConversationRecordKey = - _contact.localConversationRecordKey.toVeilid(); - final selected = activeChatCubit.state == localConversationRecordKey; + final scaleTheme = Theme.of(context).extension()!; - return SliderTile( - key: ObjectKey(_contact), + final activeChatCubit = context.watch(); + final selected = activeChatCubit.state == _localConversationRecordKey; + + final name = _contact == null ? '?' : _contact.nameOrNickname; + final title = _contact == null + ? translate('chat_list.deleted_contact') + : _contact.displayName; + final subtitle = _contact == null ? '' : _contact.profile.status; + final availability = _contact == null + ? proto.Availability.AVAILABILITY_UNSPECIFIED + : _contact.profile.availability; + + final scaleTileTheme = scaleTheme.tileTheme( disabled: _disabled, selected: selected, - tileScale: ScaleKind.secondary, - title: _contact.editedProfile.name, - subtitle: _contact.editedProfile.pronouns, - icon: Icons.chat, + ); + + final avatar = StyledAvatar( + name: name, + size: 32.scaled(context), + ); + + return StyledSlideTile( + key: ValueKey(_localConversationRecordKey), + disabled: _disabled, + selected: selected, + tileScale: ScaleKind.primary, + title: title, + subtitle: subtitle, + leading: avatar, + trailing: AvailabilityWidget( + availability: availability, + color: scaleTileTheme.textColor, + ).fit(fit: BoxFit.fill), onTap: () { singleFuture(activeChatCubit, () async { - activeChatCubit.setActiveChat(localConversationRecordKey); + activeChatCubit.setActiveChat(_localConversationRecordKey); }); }, endActions: [ - SliderTileAction( - icon: Icons.delete, + SlideTileAction( + //icon: Icons.delete, label: translate('button.delete'), actionScale: ScaleKind.tertiary, onPressed: (context) async { final chatListCubit = context.read(); await chatListCubit.deleteChat( - localConversationRecordKey: localConversationRecordKey); + localConversationRecordKey: _localConversationRecordKey); }) ], ); diff --git a/lib/chat_list/views/chat_single_contact_list_widget.dart b/lib/chat_list/views/chat_single_contact_list_widget.dart deleted file mode 100644 index 9053bc6..0000000 --- a/lib/chat_list/views/chat_single_contact_list_widget.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:searchable_listview/searchable_listview.dart'; - -import '../../contacts/contacts.dart'; -import '../../proto/proto.dart' as proto; -import '../../theme/theme.dart'; -import '../chat_list.dart'; - -class ChatSingleContactListWidget extends StatelessWidget { - const ChatSingleContactListWidget({super.key}); - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - final contactListV = context.watch().state; - - return contactListV.builder((context, contactList) { - final contactMap = IMap.fromIterable(contactList, - keyMapper: (c) => c.value.localConversationRecordKey, - valueMapper: (c) => c.value); - - final chatListV = context.watch().state; - return chatListV - .builder((context, chatList) => SizedBox.expand( - child: styledTitleContainer( - context: context, - title: translate('chat_list.chats'), - child: SizedBox.expand( - child: (chatList.isEmpty) - ? const EmptyChatListWidget() - : SearchableList( - initialList: chatList.map((x) => x.value).toList(), - itemBuilder: (c) { - final contact = - contactMap[c.localConversationRecordKey]; - if (contact == null) { - return const Text('...'); - } - return ChatSingleContactItemWidget( - contact: contact, - disabled: contactListV.busy) - .paddingLTRB(0, 4, 0, 0); - }, - filter: (value) { - final lowerValue = value.toLowerCase(); - return chatList.map((x) => x.value).where((c) { - final contact = - contactMap[c.localConversationRecordKey]; - if (contact == null) { - return false; - } - return contact.editedProfile.name - .toLowerCase() - .contains(lowerValue) || - contact.editedProfile.pronouns - .toLowerCase() - .contains(lowerValue); - }).toList(); - }, - spaceBetweenSearchAndList: 4, - inputDecoration: InputDecoration( - labelText: translate('chat_list.search'), - ), - ), - ).paddingAll(8)))) - .paddingLTRB(8, 0, 8, 8); - }); - } -} diff --git a/lib/chat_list/views/views.dart b/lib/chat_list/views/views.dart index 311d02e..1420794 100644 --- a/lib/chat_list/views/views.dart +++ b/lib/chat_list/views/views.dart @@ -1,3 +1,3 @@ +export 'chat_list_widget.dart'; export 'chat_single_contact_item_widget.dart'; -export 'chat_single_contact_list_widget.dart'; export 'empty_chat_list_widget.dart'; diff --git a/lib/contact_invitation/cubits/contact_invitation_list_cubit.dart b/lib/contact_invitation/cubits/contact_invitation_list_cubit.dart index f2f44e9..4875263 100644 --- a/lib/contact_invitation/cubits/contact_invitation_list_cubit.dart +++ b/lib/contact_invitation/cubits/contact_invitation_list_cubit.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'package:async_tools/async_tools.dart'; import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:convert/convert.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter/foundation.dart'; @@ -18,6 +20,13 @@ class ContactInviteInvalidKeyException implements Exception { final EncryptionKeyType type; } +class ContactInviteInvalidIdentityException implements Exception { + const ContactInviteInvalidIdentityException( + this.contactSuperIdentityRecordKey) + : super(); + final TypedKey contactSuperIdentityRecordKey; +} + typedef GetEncryptionKeyCallback = Future Function( VeilidCryptoSystem cs, EncryptionKeyType encryptionKeyType, @@ -26,7 +35,7 @@ typedef GetEncryptionKeyCallback = Future Function( ////////////////////////////////////////////////// typedef ContactInvitiationListState - = DHTShortArrayBusyState; + = DHTShortArrayCubitState; ////////////////////////////////////////////////// // Mutable state for per-account contact invitations @@ -36,22 +45,16 @@ class ContactInvitationListCubit StateMapFollowable { ContactInvitationListCubit({ - required ActiveAccountInfo activeAccountInfo, - required proto.Account account, - }) : _activeAccountInfo = activeAccountInfo, - _account = account, + required AccountInfo accountInfo, + required OwnedDHTRecordPointer contactInvitationListRecordPointer, + }) : _accountInfo = accountInfo, super( - open: () => _open(activeAccountInfo, account), + open: () => _open(accountInfo.accountRecordKey, + contactInvitationListRecordPointer), decodeElement: proto.ContactInvitationRecord.fromBuffer); - static Future _open( - ActiveAccountInfo activeAccountInfo, proto.Account account) async { - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - final contactInvitationListRecordPointer = - account.contactInvitationRecords.toVeilid(); - + static Future _open(TypedKey accountRecordKey, + OwnedDHTRecordPointer contactInvitationListRecordPointer) async { final dhtRecord = await DHTShortArray.openOwned( contactInvitationListRecordPointer, debugName: 'ContactInvitationListCubit::_open::ContactInvitationList', @@ -60,9 +63,11 @@ class ContactInvitationListCubit return dhtRecord; } - Future createInvitation( - {required EncryptionKeyType encryptionKeyType, + Future<(Uint8List, TypedKey)> createInvitation( + {required proto.Profile profile, + required EncryptionKeyType encryptionKeyType, required String encryptionKey, + required String recipient, required String message, required Timestamp? expiration}) async { final pool = DHTRecordPool.instance; @@ -71,8 +76,8 @@ class ContactInvitationListCubit final crcs = await pool.veilid.bestCryptoSystem(); final contactRequestWriter = await crcs.generateKeyPair(); - final idcs = await _activeAccountInfo.identityCryptoSystem; - final identityWriter = _activeAccountInfo.identityWriter; + final idcs = await _accountInfo.identityCryptoSystem; + final identityWriter = _accountInfo.identityWriter; // Encrypt the writer secret with the encryption key final encryptedSecret = await encryptionKeyType.encryptSecretToBytes( @@ -87,10 +92,11 @@ class ContactInvitationListCubit // to and it will be eventually encrypted with the DH of the contact's // identity key late final Uint8List signedContactInvitationBytes; + late final TypedKey contactRequestInboxKey; await (await pool.createRecord( debugName: 'ContactInvitationListCubit::createInvitation::' 'LocalConversation', - parent: _activeAccountInfo.accountRecordKey, + parent: _accountInfo.accountRecordKey, schema: DHTSchema.smpl( oCnt: 0, members: [DHTSchemaMember(mKey: identityWriter.key, mCnt: 1)]))) @@ -99,9 +105,8 @@ class ContactInvitationListCubit // Make ContactRequestPrivate and encrypt with the writer secret final crpriv = proto.ContactRequestPrivate() ..writerKey = contactRequestWriter.key.toProto() - ..profile = _account.profile - ..superIdentityRecordKey = - _activeAccountInfo.userLogin.superIdentityRecordKey.toProto() + ..profile = profile + ..superIdentityRecordKey = _accountInfo.superIdentityRecordKey.toProto() ..chatRecordKey = localConversation.key.toProto() ..expiration = expiration?.toInt64() ?? Int64.ZERO; final crprivbytes = crpriv.writeToBuffer(); @@ -119,12 +124,15 @@ class ContactInvitationListCubit await (await pool.createRecord( debugName: 'ContactInvitationListCubit::createInvitation::' 'ContactRequestInbox', - parent: _activeAccountInfo.accountRecordKey, + parent: _accountInfo.accountRecordKey, schema: DHTSchema.smpl(oCnt: 1, members: [ DHTSchemaMember(mCnt: 1, mKey: contactRequestWriter.key) ]), crypto: const VeilidCryptoPublic())) .deleteScope((contactRequestInbox) async { + // Keep the contact request inbox key + contactRequestInboxKey = contactRequestInbox.key; + // Store ContactRequest in owner subkey await contactRequestInbox.eventualWriteProtobuf(creq); // Store an empty invitation response @@ -155,25 +163,28 @@ class ContactInvitationListCubit ..localConversationRecordKey = localConversation.key.toProto() ..expiration = expiration?.toInt64() ?? Int64.ZERO ..invitation = signedContactInvitationBytes - ..message = message; + ..message = message + ..recipient = recipient; // Add ContactInvitationRecord to account's list - // if this fails, don't keep retrying, user can try again later - await operateWrite((writer) async { + await operateWriteEventual((writer) async { await writer.add(cinvrec.writeToBuffer()); }); }); }); - return signedContactInvitationBytes; + log.debug('createInvitation:\n' + 'contactRequestInboxKey=$contactRequestInboxKey\n' + 'bytes=${signedContactInvitationBytes.lengthInBytes}\n' + '${hex.encode(signedContactInvitationBytes)}'); + + return (signedContactInvitationBytes, contactRequestInboxKey); } Future deleteInvitation( {required bool accepted, required TypedKey contactRequestInboxRecordKey}) async { final pool = DHTRecordPool.instance; - final accountRecordKey = - _activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; // Remove ContactInvitationRecord from account's list final deletedItem = await operateWrite((writer) async { @@ -198,7 +209,7 @@ class ContactInvitationListCubit await (await pool.openRecordOwned(contactRequestInbox, debugName: 'ContactInvitationListCubit::deleteInvitation::' 'ContactRequestInbox', - parent: accountRecordKey)) + parent: _accountInfo.accountRecordKey)) .scope((contactRequestInbox) async { // Wipe out old invitation so it shows up as invalid await contactRequestInbox.tryWriteBytes(Uint8List(0)); @@ -219,9 +230,15 @@ class ContactInvitationListCubit } } - Future validateInvitation( - {required Uint8List inviteData, - required GetEncryptionKeyCallback getEncryptionKeyCallback}) async { + Future validateInvitation({ + required Uint8List inviteData, + required GetEncryptionKeyCallback getEncryptionKeyCallback, + required CancelRequest cancelRequest, + }) async { + log.debug('validateInvitation:\n' + 'bytes=${inviteData.lengthInBytes}\n' + '${hex.encode(inviteData)}'); + final pool = DHTRecordPool.instance; // Get contact request inbox from invitation @@ -240,19 +257,28 @@ class ContactInvitationListCubit // inbox with our list of extant invitations // If we're chatting to ourselves, // we are validating an invitation we have created - final isSelf = state.state.asData!.value.indexWhere((cir) => + final contactInvitationList = state.state.asData?.value; + if (contactInvitationList == null) { + return null; + } + + final isSelf = contactInvitationList.indexWhere((cir) => cir.value.contactRequestInbox.recordKey.toVeilid() == contactRequestInboxKey) != -1; - await (await pool.openRecordRead(contactRequestInboxKey, - debugName: 'ContactInvitationListCubit::validateInvitation::' - 'ContactRequestInbox', - parent: _activeAccountInfo.accountRecordKey)) + await (await pool + .openRecordRead(contactRequestInboxKey, + debugName: 'ContactInvitationListCubit::validateInvitation::' + 'ContactRequestInbox', + parent: await pool.getParentRecordKey(contactRequestInboxKey) ?? + _accountInfo.accountRecordKey) + .withCancel(cancelRequest)) .maybeDeleteScope(!isSelf, (contactRequestInbox) async { // final contactRequest = await contactRequestInbox - .getProtobuf(proto.ContactRequest.fromBuffer); + .getProtobuf(proto.ContactRequest.fromBuffer) + .withCancel(cancelRequest); final cs = await pool.veilid.getCryptoSystem(contactRequestInboxKey.kind); @@ -280,7 +306,12 @@ class ContactInvitationListCubit // Fetch the account master final contactSuperIdentity = await SuperIdentity.open( - superRecordKey: contactSuperIdentityRecordKey); + superRecordKey: contactSuperIdentityRecordKey) + .withCancel(cancelRequest); + if (contactSuperIdentity == null) { + throw ContactInviteInvalidIdentityException( + contactSuperIdentityRecordKey); + } // Verify final idcs = await contactSuperIdentity.currentInstance.cryptoSystem; @@ -293,8 +324,7 @@ class ContactInvitationListCubit secret: writerSecret); out = ValidContactInvitation( - activeAccountInfo: _activeAccountInfo, - account: _account, + accountInfo: _accountInfo, contactRequestInboxKey: contactRequestInboxKey, contactRequestPrivate: contactRequestPrivate, contactSuperIdentity: contactSuperIdentity, @@ -318,6 +348,5 @@ class ContactInvitationListCubit } // - final ActiveAccountInfo _activeAccountInfo; - final proto.Account _account; + final AccountInfo _accountInfo; } diff --git a/lib/contact_invitation/cubits/contact_request_inbox_cubit.dart b/lib/contact_invitation/cubits/contact_request_inbox_cubit.dart index 214d08b..198ae85 100644 --- a/lib/contact_invitation/cubits/contact_request_inbox_cubit.dart +++ b/lib/contact_invitation/cubits/contact_request_inbox_cubit.dart @@ -1,33 +1,31 @@ +import 'package:async_tools/async_tools.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../account_manager/account_manager.dart'; import '../../proto/proto.dart' as proto; // Watch subkey #1 of the ContactRequest record for accept/reject +typedef ContactRequestInboxState = AsyncValue; + class ContactRequestInboxCubit extends DefaultDHTRecordCubit { ContactRequestInboxCubit( - {required this.activeAccountInfo, required this.contactInvitationRecord}) + {required AccountInfo accountInfo, required this.contactInvitationRecord}) : super( open: () => _open( - activeAccountInfo: activeAccountInfo, + accountInfo: accountInfo, contactInvitationRecord: contactInvitationRecord), decodeState: (buf) => buf.isEmpty ? null : proto.SignedContactResponse.fromBuffer(buf)); - // ContactRequestInboxCubit.value( - // {required super.record, - // required this.activeAccountInfo, - // required this.contactInvitationRecord}) - // : super.value(decodeState: proto.SignedContactResponse.fromBuffer); - static Future _open( - {required ActiveAccountInfo activeAccountInfo, + {required AccountInfo accountInfo, required proto.ContactInvitationRecord contactInvitationRecord}) async { final pool = DHTRecordPool.instance; - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; + + final accountRecordKey = accountInfo.accountRecordKey; + final writerSecret = contactInvitationRecord.writerSecret.toVeilid(); final recordKey = contactInvitationRecord.contactRequestInbox.recordKey.toVeilid(); @@ -42,6 +40,5 @@ class ContactRequestInboxCubit defaultSubkey: 1); } - final ActiveAccountInfo activeAccountInfo; final proto.ContactInvitationRecord contactInvitationRecord; } diff --git a/lib/contact_invitation/cubits/invitation_generator_cubit.dart b/lib/contact_invitation/cubits/invitation_generator_cubit.dart index cd785fa..8d2226c 100644 --- a/lib/contact_invitation/cubits/invitation_generator_cubit.dart +++ b/lib/contact_invitation/cubits/invitation_generator_cubit.dart @@ -1,8 +1,9 @@ import 'dart:typed_data'; import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:veilid_support/veilid_support.dart'; -class InvitationGeneratorCubit extends FutureCubit { +class InvitationGeneratorCubit extends FutureCubit<(Uint8List, TypedKey)> { InvitationGeneratorCubit(super.fut); - InvitationGeneratorCubit.value(super.v) : super.value(); + InvitationGeneratorCubit.value(super.state) : super.value(); } diff --git a/lib/contact_invitation/cubits/waiting_invitation_cubit.dart b/lib/contact_invitation/cubits/waiting_invitation_cubit.dart index 120a2d7..b712546 100644 --- a/lib/contact_invitation/cubits/waiting_invitation_cubit.dart +++ b/lib/contact_invitation/cubits/waiting_invitation_cubit.dart @@ -7,12 +7,64 @@ import 'package:meta/meta.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../account_manager/account_manager.dart'; -import '../../contacts/contacts.dart'; +import '../../conversation/conversation.dart'; import '../../proto/proto.dart' as proto; -import '../../tools/tools.dart'; import '../models/accepted_contact.dart'; import 'contact_request_inbox_cubit.dart'; +/// State of WaitingInvitationCubit +sealed class WaitingInvitationState + implements StateMachineState { + WaitingInvitationState({required this.global}); + final WaitingInvitationStateGlobal global; +} + +class WaitingInvitationStateGlobal { + WaitingInvitationStateGlobal( + {required this.accountInfo, + required this.accountRecordCubit, + required this.contactInvitationRecord}); + final AccountInfo accountInfo; + final AccountRecordCubit accountRecordCubit; + final proto.ContactInvitationRecord contactInvitationRecord; +} + +/// State of WaitingInvitationCubit: +/// Signature was invalid +class WaitingInvitationStateInvalidSignature + with StateMachineEndState + implements WaitingInvitationState { + const WaitingInvitationStateInvalidSignature({required this.global}); + + @override + final WaitingInvitationStateGlobal global; +} + +/// State of WaitingInvitationCubit: +/// Failed to initialize +class WaitingInvitationStateInitFailed + with StateMachineEndState + implements WaitingInvitationState { + const WaitingInvitationStateInitFailed( + {required this.global, required this.exception}); + + @override + final WaitingInvitationStateGlobal global; + final Exception exception; +} + +/// State of WaitingInvitationCubit: +/// Finished normally with an invitation status +class WaitingInvitationStateInvitationStatus + with StateMachineEndState + implements WaitingInvitationState { + const WaitingInvitationStateInvitationStatus( + {required this.global, required this.status}); + @override + final WaitingInvitationStateGlobal global; + final InvitationStatus status; +} + @immutable class InvitationStatus extends Equatable { const InvitationStatus({required this.acceptedContact}); @@ -22,92 +74,160 @@ class InvitationStatus extends Equatable { List get props => [acceptedContact]; } -class WaitingInvitationCubit extends AsyncTransformerCubit { - WaitingInvitationCubit(ContactRequestInboxCubit super.input, - {required ActiveAccountInfo activeAccountInfo, - required proto.Account account, - required proto.ContactInvitationRecord contactInvitationRecord}) - : super( - transform: (signedContactResponse) => _transform( - signedContactResponse, - activeAccountInfo: activeAccountInfo, - account: account, - contactInvitationRecord: contactInvitationRecord)); +/// State of WaitingInvitationCubit: +/// Waiting for the invited contact to accept/reject the invitation +class WaitingInvitationStateWaitForContactResponse + extends AsyncCubitReactorState< + WaitingInvitationState, + ContactRequestInboxState, + ContactRequestInboxCubit> implements WaitingInvitationState { + WaitingInvitationStateWaitForContactResponse(super.create, + {required this.global}) + : super(onState: (ctx) async { + final signedContactResponse = ctx.state.asData?.value; + if (signedContactResponse == null) { + return null; + } - static Future> _transform( - proto.SignedContactResponse? signedContactResponse, - {required ActiveAccountInfo activeAccountInfo, - required proto.Account account, - required proto.ContactInvitationRecord contactInvitationRecord}) async { - if (signedContactResponse == null) { - return const AsyncValue.loading(); - } - final contactResponseBytes = - Uint8List.fromList(signedContactResponse.contactResponse); - final contactResponse = - proto.ContactResponse.fromBuffer(contactResponseBytes); - final contactIdentityMasterRecordKey = - contactResponse.superIdentityRecordKey.toVeilid(); + final contactResponse = proto.ContactResponse.fromBuffer( + signedContactResponse.contactResponse); + final contactSuperRecordKey = + contactResponse.superIdentityRecordKey.toVeilid(); - // Fetch the remote contact's account master - final contactSuperIdentity = await SuperIdentity.open( - superRecordKey: contactIdentityMasterRecordKey); - - // Verify - final idcs = await contactSuperIdentity.currentInstance.cryptoSystem; - final signature = signedContactResponse.identitySignature.toVeilid(); - await idcs.verify(contactSuperIdentity.currentInstance.publicKey, - contactResponseBytes, signature); - - // Check for rejection - if (!contactResponse.accept) { - // Rejection - return const AsyncValue.data(InvitationStatus(acceptedContact: null)); - } - - // Pull profile from remote conversation key - final remoteConversationRecordKey = - contactResponse.remoteConversationRecordKey.toVeilid(); - - final conversation = ConversationCubit( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: - contactSuperIdentity.currentInstance.typedPublicKey, - remoteConversationRecordKey: remoteConversationRecordKey); - - // wait for remote conversation for up to 20 seconds - proto.Conversation? remoteConversation; - var retryCount = 20; - do { - await conversation.refresh(); - remoteConversation = conversation.state.asData?.value.remoteConversation; - if (remoteConversation != null) { - break; - } - log.info('Remote conversation could not be read. Waiting...'); - await Future.delayed(const Duration(seconds: 1)); - retryCount--; - } while (retryCount > 0); - if (remoteConversation == null) { - return AsyncValue.error('Invitation accept timed out.'); - } - - // Complete the local conversation now that we have the remote profile - final remoteProfile = remoteConversation.profile; - final localConversationRecordKey = - contactInvitationRecord.localConversationRecordKey.toVeilid(); - return conversation.initLocalConversation( - existingConversationRecordKey: localConversationRecordKey, - profile: account.profile, - // ignore: prefer_expression_function_bodies - callback: (localConversation) async { - return AsyncValue.data(InvitationStatus( - acceptedContact: AcceptedContact( - remoteProfile: remoteProfile, - remoteIdentity: contactSuperIdentity, - remoteConversationRecordKey: remoteConversationRecordKey, - localConversationRecordKey: localConversationRecordKey))); + // Fetch the remote contact's account superidentity + return WaitingInvitationStateWaitForContactSuperIdentity( + () => SuperIdentityCubit(superRecordKey: contactSuperRecordKey), + global: global, + signedContactResponse: signedContactResponse); }); - } + + @override + final WaitingInvitationStateGlobal global; +} + +/// State of WaitingInvitationCubit: +/// Once an accept/reject happens, get the SuperIdentity of the recipient +class WaitingInvitationStateWaitForContactSuperIdentity + extends AsyncCubitReactorState implements WaitingInvitationState { + WaitingInvitationStateWaitForContactSuperIdentity(super.create, + {required this.global, + required proto.SignedContactResponse signedContactResponse}) + : super(onState: (ctx) async { + final contactSuperIdentity = ctx.state.asData?.value; + if (contactSuperIdentity == null) { + return null; + } + + final contactResponseBytes = + Uint8List.fromList(signedContactResponse.contactResponse); + final contactResponse = + proto.ContactResponse.fromBuffer(contactResponseBytes); + + // Verify + final idcs = await contactSuperIdentity.currentInstance.cryptoSystem; + final signature = signedContactResponse.identitySignature.toVeilid(); + if (!await idcs.verify(contactSuperIdentity.currentInstance.publicKey, + contactResponseBytes, signature)) { + // Could not verify signature of contact response + return WaitingInvitationStateInvalidSignature( + global: global, + ); + } + + // Check for rejection + if (!contactResponse.accept) { + // Rejection + return WaitingInvitationStateInvitationStatus( + global: global, + status: const InvitationStatus(acceptedContact: null), + ); + } + + // Pull profile from remote conversation key + final remoteConversationRecordKey = + contactResponse.remoteConversationRecordKey.toVeilid(); + + return WaitingInvitationStateWaitForConversation( + () => ConversationCubit( + accountInfo: global.accountInfo, + remoteIdentityPublicKey: + contactSuperIdentity.currentInstance.typedPublicKey, + remoteConversationRecordKey: remoteConversationRecordKey), + global: global, + remoteConversationRecordKey: remoteConversationRecordKey, + contactSuperIdentity: contactSuperIdentity, + ); + }); + + @override + final WaitingInvitationStateGlobal global; +} + +/// State of WaitingInvitationCubit: +/// Wait for the conversation cubit to initialize so we can return the +/// accepted invitation +class WaitingInvitationStateWaitForConversation extends AsyncCubitReactorState< + WaitingInvitationState, + AsyncValue, + ConversationCubit> implements WaitingInvitationState { + WaitingInvitationStateWaitForConversation(super.create, + {required this.global, + required TypedKey remoteConversationRecordKey, + required SuperIdentity contactSuperIdentity}) + : super(onState: (ctx) async { + final remoteConversation = ctx.state.asData?.value.remoteConversation; + final localConversation = ctx.state.asData?.value.localConversation; + if (remoteConversation == null || localConversation != null) { + return null; + } + + // Stop reacting to the conversation cubit + ctx.stop(); + + // Complete the local conversation now that we have the remote profile + final remoteProfile = remoteConversation.profile; + final localConversationRecordKey = global + .contactInvitationRecord.localConversationRecordKey + .toVeilid(); + + try { + await ctx.cubit.initLocalConversation( + profile: global.accountRecordCubit.state.asData!.value.profile, + existingConversationRecordKey: localConversationRecordKey); + } on Exception catch (e) { + return WaitingInvitationStateInitFailed( + global: global, exception: e); + } + + return WaitingInvitationStateInvitationStatus( + global: global, + status: InvitationStatus( + acceptedContact: AcceptedContact( + remoteProfile: remoteProfile, + remoteIdentity: contactSuperIdentity, + remoteConversationRecordKey: remoteConversationRecordKey, + localConversationRecordKey: localConversationRecordKey))); + }); + + @override + final WaitingInvitationStateGlobal global; +} + +/// Invitation state processor for sent invitations +class WaitingInvitationCubit extends StateMachineCubit { + WaitingInvitationCubit({ + required ContactRequestInboxCubit Function() initialStateCreate, + required AccountInfo accountInfo, + required AccountRecordCubit accountRecordCubit, + required proto.ContactInvitationRecord contactInvitationRecord, + }) : super( + WaitingInvitationStateWaitForContactResponse( + initialStateCreate, + global: WaitingInvitationStateGlobal( + accountInfo: accountInfo, + accountRecordCubit: accountRecordCubit, + contactInvitationRecord: contactInvitationRecord), + ), + ); } diff --git a/lib/contact_invitation/cubits/waiting_invitations_bloc_map_cubit.dart b/lib/contact_invitation/cubits/waiting_invitations_bloc_map_cubit.dart index 968e108..f125e71 100644 --- a/lib/contact_invitation/cubits/waiting_invitations_bloc_map_cubit.dart +++ b/lib/contact_invitation/cubits/waiting_invitations_bloc_map_cubit.dart @@ -1,48 +1,155 @@ import 'package:async_tools/async_tools.dart'; import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:flutter_translate/flutter_translate.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../account_manager/account_manager.dart'; +import '../../contacts/contacts.dart'; +import '../../notifications/notifications.dart'; import '../../proto/proto.dart' as proto; import 'cubits.dart'; typedef WaitingInvitationsBlocMapState - = BlocMapState>; + = BlocMapState; // Map of contactRequestInboxRecordKey to WaitingInvitationCubit // Wraps a contact invitation cubit to watch for accept/reject // Automatically follows the state of a ContactInvitationListCubit. class WaitingInvitationsBlocMapCubit extends BlocMapCubit, WaitingInvitationCubit> + WaitingInvitationState, WaitingInvitationCubit> with - StateMapFollower, + StateMapFollower, TypedKey, proto.ContactInvitationRecord> { WaitingInvitationsBlocMapCubit( - {required this.activeAccountInfo, required this.account}); + {required AccountInfo accountInfo, + required AccountRecordCubit accountRecordCubit, + required ContactInvitationListCubit contactInvitationListCubit, + required ContactListCubit contactListCubit, + required NotificationsCubit notificationsCubit}) + : _accountInfo = accountInfo, + _accountRecordCubit = accountRecordCubit, + _contactInvitationListCubit = contactInvitationListCubit, + _contactListCubit = contactListCubit, + _notificationsCubit = notificationsCubit { + // React to invitation status changes + _singleInvitationStatusProcessor.follow( + stream, state, _invitationStatusListener); - Future _addWaitingInvitation( - {required proto.ContactInvitationRecord - contactInvitationRecord}) async => - add(() => MapEntry( + // Follow the contact invitation list cubit + follow(contactInvitationListCubit); + } + + @override + Future close() async { + await _singleInvitationStatusProcessor.close(); + await super.close(); + } + + void _addWaitingInvitation( + {required proto.ContactInvitationRecord contactInvitationRecord}) => + add( contactInvitationRecord.contactRequestInbox.recordKey.toVeilid(), - WaitingInvitationCubit( - ContactRequestInboxCubit( - activeAccountInfo: activeAccountInfo, + () => WaitingInvitationCubit( + initialStateCreate: () => ContactRequestInboxCubit( + accountInfo: _accountInfo, contactInvitationRecord: contactInvitationRecord), - activeAccountInfo: activeAccountInfo, - account: account, - contactInvitationRecord: contactInvitationRecord))); + accountInfo: _accountInfo, + accountRecordCubit: _accountRecordCubit, + contactInvitationRecord: contactInvitationRecord)); + + // Process all accepted or rejected invitations + Future _invitationStatusListener( + WaitingInvitationsBlocMapState newState) async { + for (final entry in newState.entries) { + final contactRequestInboxRecordKey = entry.key; + + switch (entry.value) { + case WaitingInvitationStateInvalidSignature(): + // Signature was invalid, display an error and treat like a rejection + await _contactInvitationListCubit.deleteInvitation( + accepted: false, + contactRequestInboxRecordKey: contactRequestInboxRecordKey); + + // Notify about error state + _notificationsCubit.error( + text: translate('waiting_invitation.invalid')); + case final WaitingInvitationStateInitFailed st: + // Initialization error, display an error and treat like a rejection + await _contactInvitationListCubit.deleteInvitation( + accepted: false, + contactRequestInboxRecordKey: contactRequestInboxRecordKey); + + // Notify about error state + _notificationsCubit.error( + text: '${translate('waiting_invitation.init_failed')}\n' + '${st.exception}'); + + case final WaitingInvitationStateInvitationStatus st: + final invStatus = st.status; + + // Delete invitation and process the accepted or rejected contact + final acceptedContact = invStatus.acceptedContact; + if (acceptedContact != null) { + await _contactInvitationListCubit.deleteInvitation( + accepted: true, + contactRequestInboxRecordKey: contactRequestInboxRecordKey); + + // Accept + await _contactListCubit.createContact( + profile: acceptedContact.remoteProfile, + remoteSuperIdentity: acceptedContact.remoteIdentity, + remoteConversationRecordKey: + acceptedContact.remoteConversationRecordKey, + localConversationRecordKey: + acceptedContact.localConversationRecordKey, + ); + + // Notify about acceptance + _notificationsCubit.info( + text: translate('waiting_invitation.accepted', + args: {'name': acceptedContact.remoteProfile.name})); + } else { + // Reject + await _contactInvitationListCubit.deleteInvitation( + accepted: false, + contactRequestInboxRecordKey: contactRequestInboxRecordKey); + + // Notify about rejection + _notificationsCubit.info( + text: translate( + 'waiting_invitation.rejected', + )); + } + case WaitingInvitationStateWaitForContactResponse(): + // Do nothing, still waiting for contact response + break; + case WaitingInvitationStateWaitForContactSuperIdentity(): + // Do nothing, still waiting for contact SuperIdentity + break; + case WaitingInvitationStateWaitForConversation(): + // Do nothing, still waiting for conversation + break; + } + } + } /// StateFollower ///////////////////////// @override - Future removeFromState(TypedKey key) => remove(key); + void removeFromState(TypedKey key) => remove(key); @override - Future updateState(TypedKey key, proto.ContactInvitationRecord value) => - _addWaitingInvitation(contactInvitationRecord: value); + void updateState(TypedKey key, proto.ContactInvitationRecord? oldValue, + proto.ContactInvitationRecord newValue) { + _addWaitingInvitation(contactInvitationRecord: newValue); + } //// - final ActiveAccountInfo activeAccountInfo; - final proto.Account account; + final AccountInfo _accountInfo; + final AccountRecordCubit _accountRecordCubit; + final ContactInvitationListCubit _contactInvitationListCubit; + final ContactListCubit _contactListCubit; + final NotificationsCubit _notificationsCubit; + final _singleInvitationStatusProcessor = + SingleStateProcessor(); } diff --git a/lib/contact_invitation/models/valid_contact_invitation.dart b/lib/contact_invitation/models/valid_contact_invitation.dart index 3b48bfe..c39692c 100644 --- a/lib/contact_invitation/models/valid_contact_invitation.dart +++ b/lib/contact_invitation/models/valid_contact_invitation.dart @@ -2,7 +2,7 @@ import 'package:meta/meta.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../account_manager/account_manager.dart'; -import '../../contacts/contacts.dart'; +import '../../conversation/conversation.dart'; import '../../proto/proto.dart' as proto; import '../../tools/tools.dart'; import 'models.dart'; @@ -13,14 +13,12 @@ import 'models.dart'; class ValidContactInvitation { @internal ValidContactInvitation( - {required ActiveAccountInfo activeAccountInfo, - required proto.Account account, + {required AccountInfo accountInfo, required TypedKey contactRequestInboxKey, required proto.ContactRequestPrivate contactRequestPrivate, required SuperIdentity contactSuperIdentity, required KeyPair writer}) - : _activeAccountInfo = activeAccountInfo, - _account = account, + : _accountInfo = accountInfo, _contactRequestInboxKey = contactRequestInboxKey, _contactRequestPrivate = contactRequestPrivate, _contactSuperIdentity = contactSuperIdentity, @@ -28,61 +26,56 @@ class ValidContactInvitation { proto.Profile get remoteProfile => _contactRequestPrivate.profile; - Future accept() async { + Future accept(proto.Profile profile) async { final pool = DHTRecordPool.instance; try { // Ensure we don't delete this if we're trying to chat to self // The initiating side will delete the records in deleteInvitation() final isSelf = _contactSuperIdentity.currentInstance.publicKey == - _activeAccountInfo.identityPublicKey; - final accountRecordKey = _activeAccountInfo.accountRecordKey; + _accountInfo.identityPublicKey; return (await pool.openRecordWrite(_contactRequestInboxKey, _writer, debugName: 'ValidContactInvitation::accept::' 'ContactRequestInbox', - parent: accountRecordKey)) + parent: await pool.getParentRecordKey(_contactRequestInboxKey) ?? + _accountInfo.accountRecordKey)) // ignore: prefer_expression_function_bodies .maybeDeleteScope(!isSelf, (contactRequestInbox) async { // Create local conversation key for this // contact and send via contact response final conversation = ConversationCubit( - activeAccountInfo: _activeAccountInfo, + accountInfo: _accountInfo, remoteIdentityPublicKey: _contactSuperIdentity.currentInstance.typedPublicKey); - return conversation.initLocalConversation( - profile: _account.profile, - callback: (localConversation) async { - final contactResponse = proto.ContactResponse() - ..accept = true - ..remoteConversationRecordKey = localConversation.key.toProto() - ..superIdentityRecordKey = - _activeAccountInfo.superIdentityRecordKey.toProto(); - final contactResponseBytes = contactResponse.writeToBuffer(); + final localConversationRecordKey = + await conversation.initLocalConversation(profile: profile); - final cs = await pool.veilid - .getCryptoSystem(_contactRequestInboxKey.kind); + final contactResponse = proto.ContactResponse() + ..accept = true + ..remoteConversationRecordKey = localConversationRecordKey.toProto() + ..superIdentityRecordKey = + _accountInfo.superIdentityRecordKey.toProto(); + final contactResponseBytes = contactResponse.writeToBuffer(); - final identitySignature = await cs.sign( - _activeAccountInfo.identityWriter.key, - _activeAccountInfo.identityWriter.secret, - contactResponseBytes); + final cs = await _accountInfo.identityCryptoSystem; + final identitySignature = await cs.signWithKeyPair( + _accountInfo.identityWriter, contactResponseBytes); - final signedContactResponse = proto.SignedContactResponse() - ..contactResponse = contactResponseBytes - ..identitySignature = identitySignature.toProto(); + final signedContactResponse = proto.SignedContactResponse() + ..contactResponse = contactResponseBytes + ..identitySignature = identitySignature.toProto(); - // Write the acceptance to the inbox - await contactRequestInbox - .eventualWriteProtobuf(signedContactResponse, subkey: 1); + // Write the acceptance to the inbox + await contactRequestInbox.eventualWriteProtobuf(signedContactResponse, + subkey: 1); - return AcceptedContact( - remoteProfile: _contactRequestPrivate.profile, - remoteIdentity: _contactSuperIdentity, - remoteConversationRecordKey: - _contactRequestPrivate.chatRecordKey.toVeilid(), - localConversationRecordKey: localConversation.key, - ); - }); + return AcceptedContact( + remoteProfile: _contactRequestPrivate.profile, + remoteIdentity: _contactSuperIdentity, + remoteConversationRecordKey: + _contactRequestPrivate.chatRecordKey.toVeilid(), + localConversationRecordKey: localConversationRecordKey, + ); }); } on Exception catch (e) { log.debug('exception: $e', e); @@ -95,27 +88,22 @@ class ValidContactInvitation { // Ensure we don't delete this if we're trying to chat to self final isSelf = _contactSuperIdentity.currentInstance.publicKey == - _activeAccountInfo.identityPublicKey; - final accountRecordKey = _activeAccountInfo.accountRecordKey; + _accountInfo.identityPublicKey; return (await pool.openRecordWrite(_contactRequestInboxKey, _writer, debugName: 'ValidContactInvitation::reject::' 'ContactRequestInbox', - parent: accountRecordKey)) + parent: _accountInfo.accountRecordKey)) .maybeDeleteScope(!isSelf, (contactRequestInbox) async { - final cs = - await pool.veilid.getCryptoSystem(_contactRequestInboxKey.kind); - final contactResponse = proto.ContactResponse() ..accept = false ..superIdentityRecordKey = - _activeAccountInfo.superIdentityRecordKey.toProto(); + _accountInfo.superIdentityRecordKey.toProto(); final contactResponseBytes = contactResponse.writeToBuffer(); - final identitySignature = await cs.sign( - _activeAccountInfo.identityWriter.key, - _activeAccountInfo.identityWriter.secret, - contactResponseBytes); + final cs = await _accountInfo.identityCryptoSystem; + final identitySignature = await cs.signWithKeyPair( + _accountInfo.identityWriter, contactResponseBytes); final signedContactResponse = proto.SignedContactResponse() ..contactResponse = contactResponseBytes @@ -129,8 +117,7 @@ class ValidContactInvitation { } // - final ActiveAccountInfo _activeAccountInfo; - final proto.Account _account; + final AccountInfo _accountInfo; final TypedKey _contactRequestInboxKey; final SuperIdentity _contactSuperIdentity; final KeyPair _writer; diff --git a/lib/contact_invitation/views/camera_qr_scanner.dart b/lib/contact_invitation/views/camera_qr_scanner.dart new file mode 100644 index 0000000..11d8f77 --- /dev/null +++ b/lib/contact_invitation/views/camera_qr_scanner.dart @@ -0,0 +1,473 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:camera/camera.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:zxing2/qrcode.dart'; + +import '../../theme/theme.dart'; + +enum _FrameState { + notFound, + formatError, + checksumError, +} + +class _ScannerOverlay extends CustomPainter { + _ScannerOverlay(this.scanWindow, this.frameColor); + + final Rect scanWindow; + final Color? frameColor; + + @override + void paint(Canvas canvas, Size size) { + final backgroundPath = Path()..addRect(Rect.largest); + final cutoutPath = Path()..addRect(scanWindow); + + final backgroundPaint = Paint() + ..color = (frameColor ?? Colors.black).withAlpha(127) + ..style = PaintingStyle.fill + ..blendMode = BlendMode.dstOut; + + final backgroundWithCutout = Path.combine( + PathOperation.difference, + backgroundPath, + cutoutPath, + ); + canvas.drawPath(backgroundWithCutout, backgroundPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Camera QR scanner +class CameraQRScanner extends StatefulWidget { + const CameraQRScanner( + {required Widget Function(BuildContext) loadingBuilder, + required Widget Function( + BuildContext, Object error, StackTrace? stackTrace) + errorBuilder, + required Widget Function(BuildContext) bottomRowBuilder, + required void Function(String) showNotification, + required void Function(String, Object? error, StackTrace? stackTrace) + logError, + required void Function(T) onDone, + T? Function(Result)? onDetect, + T? Function(CameraImage)? onImageAvailable, + Size? scanSize, + Color? formatErrorFrameColor, + Color? checksumErrorFrameColor, + String? cameraErrorMessage, + String? deniedErrorMessage, + String? deniedWithoutPromptErrorMessage, + String? restrictedErrorMessage, + super.key}) + : _loadingBuilder = loadingBuilder, + _errorBuilder = errorBuilder, + _bottomRowBuilder = bottomRowBuilder, + _showNotification = showNotification, + _logError = logError, + _scanSize = scanSize, + _onDetect = onDetect, + _onDone = onDone, + _onImageAvailable = onImageAvailable, + _formatErrorFrameColor = formatErrorFrameColor, + _checksumErrorFrameColor = checksumErrorFrameColor, + _cameraErrorMessage = cameraErrorMessage, + _deniedErrorMessage = deniedErrorMessage, + _deniedWithoutPromptErrorMessage = deniedWithoutPromptErrorMessage, + _restrictedErrorMessage = restrictedErrorMessage; + @override + State> createState() => _CameraQRScannerState(); + + //////////////////////////////////////////////////////////////////////////// + + final Widget Function(BuildContext) _loadingBuilder; + final Widget Function(BuildContext, Object error, StackTrace? stackTrace) + _errorBuilder; + final Widget Function(BuildContext) _bottomRowBuilder; + final void Function(String) _showNotification; + final void Function(String, Object? error, StackTrace? stackTrace) _logError; + final T? Function(Result)? _onDetect; + final void Function(T) _onDone; + final T? Function(CameraImage)? _onImageAvailable; + + final Size? _scanSize; + final Color? _formatErrorFrameColor; + final Color? _checksumErrorFrameColor; + final String? _cameraErrorMessage; + final String? _deniedErrorMessage; + final String? _deniedWithoutPromptErrorMessage; + final String? _restrictedErrorMessage; +} + +class _CameraQRScannerState extends State> + with WidgetsBindingObserver, TickerProviderStateMixin { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + + // Async Init + _initWait.add(_init); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + unawaited(_controller?.dispose()); + super.dispose(); + } + + // #docregion AppLifecycle + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + final cameraController = _controller; + + // App state changed before we got the chance to initialize. + if (cameraController == null || !cameraController.value.isInitialized) { + return; + } + + if (state == AppLifecycleState.inactive) { + unawaited(cameraController.dispose()); + } else if (state == AppLifecycleState.resumed) { + unawaited(_initializeCameraController(cameraController.description)); + } + } + // #enddocregion AppLifecycle + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final activeColor = theme.colorScheme.primary; + final inactiveColor = theme.colorScheme.onPrimary; + + final scanSize = widget._scanSize; + final scanWindow = scanSize == null + ? null + : Rect.fromCenter( + center: Offset.zero, + width: scanSize.width, + height: scanSize.height, + ); + + return Scaffold( + body: FutureBuilder( + future: _initWait(), + builder: (context, av) => av.when( + error: (e, st) => widget._errorBuilder(context, e, st), + loading: () => widget._loadingBuilder(context), + data: (data, isComplete) => Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(1), + child: Center( + child: Stack( + alignment: AlignmentDirectional.center, + children: [ + _cameraPreviewWidget(context), + if (scanWindow != null) + IgnorePointer( + child: CustomPaint( + foregroundPainter: _ScannerOverlay( + scanWindow, + switch (_frameState) { + _FrameState.notFound => null, + _FrameState.formatError => + widget._formatErrorFrameColor, + _FrameState.checksumError => + widget._checksumErrorFrameColor + }), + )), + ]), + ), + ), + ), + widget._bottomRowBuilder(context), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _cameraToggleWidget(), + _torchToggleWidget(activeColor, inactiveColor) + ], + ), + ], + ), + ))); + } + + /// Display the preview from the camera + /// (or a message if the preview is not available). + Widget _cameraPreviewWidget(BuildContext context) { + final cameraController = _controller; + + if (cameraController == null || !cameraController.value.isInitialized) { + return widget._loadingBuilder(context); + } else { + return Listener( + onPointerDown: (_) => _pointers++, + onPointerUp: (_) => _pointers--, + child: CameraPreview( + cameraController, + child: LayoutBuilder( + builder: (context, constraints) => GestureDetector( + behavior: HitTestBehavior.opaque, + onScaleStart: _handleScaleStart, + onScaleUpdate: _handleScaleUpdate, + onTapDown: (details) => + _onViewFinderTap(details, constraints), + )), + ), + ); + } + } + + void _handleScaleStart(ScaleStartDetails details) { + _baseScale = _currentScale; + } + + Future _handleScaleUpdate(ScaleUpdateDetails details) async { + // When there are not exactly two fingers on screen don't scale + if (_controller == null || _pointers != 2) { + return; + } + + _currentScale = (_baseScale * details.scale) + .clamp(_minAvailableZoom, _maxAvailableZoom); + + await _controller!.setZoomLevel(_currentScale); + } + + Widget _torchToggleWidget(Color activeColor, Color inactiveColor) => + IconButton( + icon: const Icon(Icons.highlight), + color: _controller?.value.flashMode == FlashMode.torch + ? activeColor + : inactiveColor, + onPressed: _controller != null + ? () => _onSetFlashModeButtonPressed( + _controller?.value.flashMode == FlashMode.torch + ? FlashMode.off + : FlashMode.torch) + : null, + ); + + Widget _cameraToggleWidget() { + final currentCameraDescription = _controller?.description; + return IconButton( + icon: + Icon(isAndroid ? Icons.flip_camera_android : Icons.flip_camera_ios), + onPressed: (currentCameraDescription == null || _cameras.isEmpty) + ? null + : () { + final nextCameraIndex = + (_cameras.indexOf(currentCameraDescription) + 1) % + _cameras.length; + unawaited(_onNewCameraSelected(_cameras[nextCameraIndex])); + }); + } + + void _onViewFinderTap(TapDownDetails details, BoxConstraints constraints) { + final cameraController = _controller; + if (cameraController == null) { + return; + } + + final offset = Offset( + details.localPosition.dx / constraints.maxWidth, + details.localPosition.dy / constraints.maxHeight, + ); + unawaited(cameraController.setExposurePoint(offset)); + unawaited(cameraController.setFocusPoint(offset)); + } + + Future _onNewCameraSelected(CameraDescription cameraDescription) { + if (_controller != null) { + return _controller!.setDescription(cameraDescription); + } else { + return _initializeCameraController(cameraDescription); + } + } + + Future _initializeCameraController( + CameraDescription cameraDescription) async { + final cameraController = CameraController( + cameraDescription, + kIsWeb ? ResolutionPreset.max : ResolutionPreset.medium, + enableAudio: false, + imageFormatGroup: ImageFormatGroup.jpeg, + ); + + _controller = cameraController; + + // If the controller is updated then update the UI. + cameraController.addListener(() { + if (mounted) { + setState(() {}); + } + if (cameraController.value.hasError && + (cameraController.value.errorDescription?.isNotEmpty ?? false)) { + widget._showNotification( + '${widget._cameraErrorMessage ?? 'Camera error'}: ' + '${cameraController.value.errorDescription!}'); + } + }); + + try { + await cameraController.initialize(); + + try { + _maxAvailableZoom = await cameraController.getMaxZoomLevel(); + _minAvailableZoom = await cameraController.getMinZoomLevel(); + } on PlatformException { + _maxAvailableZoom = 1; + _minAvailableZoom = 1; + } + + await cameraController.startImageStream((cameraImage) { + final out = + (widget._onImageAvailable ?? _onImageAvailable)(cameraImage); + if (out != null) { + _controller = null; + unawaited(cameraController.dispose()); + widget._onDone(out); + } + }); + } on CameraException catch (e, st) { + switch (e.code) { + case 'CameraAccessDenied': + widget._showNotification( + widget._deniedErrorMessage ?? 'You have denied camera access.'); + case 'CameraAccessDeniedWithoutPrompt': + // iOS only + widget._showNotification(widget._deniedWithoutPromptErrorMessage ?? + 'Please go to Settings app to enable camera access.'); + case 'CameraAccessRestricted': + // iOS only + widget._showNotification( + widget._restrictedErrorMessage ?? 'Camera access is restricted.'); + default: + _showCameraException(e, st); + } + } + + if (mounted) { + setState(() {}); + } + } + + T? _onImageAvailable(CameraImage cameraImage) { + try { + final plane = cameraImage.planes.firstOrNull; + if (plane == null) { + return null; + } + + // final image = JpegDecoder().decode(plane.bytes); + // if (image == null) { + // return; + // } + + // final abgrImage = image + // .convert(numChannels: 4) + // .getBytes(order: ChannelOrder.abgr) + // .buffer + // .asInt32List(); + + final abgrImage = plane.bytes.buffer.asInt32List(); + + final source = + RGBLuminanceSource(cameraImage.width, cameraImage.height, abgrImage); + + final bitmap = BinaryBitmap(HybridBinarizer(source)); + + final reader = QRCodeReader(); + try { + final result = reader.decode(bitmap); + return widget._onDetect?.call(result); + } on NotFoundException { + _setFrameState(_FrameState.notFound); + } on FormatReaderException { + _setFrameState(_FrameState.formatError); + } on ChecksumException { + _setFrameState(_FrameState.checksumError); + } + + // Should also catch errors from QRCodeReader + // ignore: avoid_catches_without_on_clauses + } catch (e, st) { + widget._logError('Unexpected error: $e\n$st', e, st); + } + return null; + } + + void _setFrameState(_FrameState frameState) { + if (mounted) { + if (_frameState != frameState) { + setState(() { + _frameState = frameState; + }); + } + } + } + + void _onSetFlashModeButtonPressed(FlashMode mode) { + unawaited(_setFlashMode(mode).then((_) { + if (mounted) { + setState(() {}); + } + })); + } + + Future _setFlashMode(FlashMode mode) async { + if (_controller == null) { + return; + } + + try { + await _controller!.setFlashMode(mode); + } on CameraException catch (e, st) { + _showCameraException(e, st); + rethrow; + } + } + + void _showCameraException(CameraException e, StackTrace st) { + _logCameraException(e, st); + widget._showNotification('Error: ${e.code}\n${e.description}'); + } + + void _logCameraException(CameraException e, StackTrace st) { + final code = e.code; + final message = e.description; + widget._logError( + 'CameraException: $code${message == null ? '' : '\nMessage: $message'}', + e, + st); + } + + Future _init(Completer cancel) async { + _cameras = await availableCameras(); + if (_cameras.isNotEmpty) { + await _onNewCameraSelected(_cameras.first); + } + } + + //////////////////////////////////////////////////////////////////////////// + + CameraController? _controller; + final _initWait = WaitSet(); + late final List _cameras; + var _minAvailableZoom = 1.0; + var _maxAvailableZoom = 1.0; + var _currentScale = 1.0; + var _baseScale = 1.0; + var _pointers = 0; + _FrameState _frameState = _FrameState.notFound; +} diff --git a/lib/contact_invitation/views/contact_invitation_display.dart b/lib/contact_invitation/views/contact_invitation_display.dart index 374a309..4ab840d 100644 --- a/lib/contact_invitation/views/contact_invitation_display.dart +++ b/lib/contact_invitation/views/contact_invitation_display.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:auto_size_text/auto_size_text.dart'; import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:basic_utils/basic_utils.dart'; import 'package:flutter/foundation.dart'; @@ -7,115 +8,180 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_translate/flutter_translate.dart'; +import 'package:provider/provider.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:veilid_support/veilid_support.dart'; +import '../../account_manager/account_manager.dart'; +import '../../notifications/notifications.dart'; +import '../../proto/proto.dart' as proto; import '../../theme/theme.dart'; -import '../../tools/tools.dart'; import '../contact_invitation.dart'; class ContactInvitationDisplayDialog extends StatelessWidget { const ContactInvitationDisplayDialog._({ - required this.modalContext, + required this.locator, + required this.recipient, required this.message, + required this.fingerprint, }); - final BuildContext modalContext; + final Locator locator; + final String recipient; final String message; + final String fingerprint; @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties + ..add(StringProperty('recipient', recipient)) ..add(StringProperty('message', message)) - ..add(DiagnosticsProperty('modalContext', modalContext)); + ..add(DiagnosticsProperty('locator', locator)) + ..add(StringProperty('fingerprint', fingerprint)); } - String makeTextInvite(String message, Uint8List data) { + String makeTextInvite(String recipient, String message, Uint8List data) { final invite = StringUtils.addCharAtPosition( base64UrlNoPadEncode(data), '\n', 40, repeat: true); + final to = recipient.isNotEmpty + ? '${translate('invitiation_dialog.to')}: $recipient\n' + : ''; final msg = message.isNotEmpty ? '$message\n' : ''; - return '$msg' + return '$to' + '$msg' '--- BEGIN VEILIDCHAT CONTACT INVITE ----\n' '$invite\n' - '---- END VEILIDCHAT CONTACT INVITE -----\n'; + '---- END VEILIDCHAT CONTACT INVITE -----\n' + 'Fingerprint:\n$fingerprint\n'; } @override - // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { final theme = Theme.of(context); - //final scale = theme.extension()!; final textTheme = theme.textTheme; + final scaleConfig = theme.extension()!; - final signedContactInvitationBytesV = - context.watch().state; + final generatorOutputV = context.watch().state; final cardsize = min(MediaQuery.of(context).size.shortestSide - 48.0, 400); - return PopControl( - dismissible: !signedContactInvitationBytesV.isLoading, - child: Dialog( - backgroundColor: Colors.white, - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: cardsize, - maxWidth: cardsize, - minHeight: cardsize, - maxHeight: cardsize), - child: signedContactInvitationBytesV.when( - loading: buildProgressIndicator, - data: (data) => Column(children: [ - FittedBox( - child: Text( - translate( - 'create_invitation_dialog.contact_invitation'), - style: textTheme.headlineSmall! - .copyWith(color: Colors.black))) - .paddingAll(8), - FittedBox( - child: QrImageView.withQr( - size: 300, - qr: QrCode.fromUint8List( - data: data, - errorCorrectLevel: - QrErrorCorrectLevel.L))) - .expanded(), - Text(message, - softWrap: true, - style: textTheme.labelLarge! - .copyWith(color: Colors.black)) - .paddingAll(8), - ElevatedButton.icon( - icon: const Icon(Icons.copy), - label: Text(translate( - 'create_invitation_dialog.copy_invitation')), - onPressed: () async { - showInfoToast( - context, - translate( - 'create_invitation_dialog.invitation_copied')); - await Clipboard.setData(ClipboardData( - text: makeTextInvite(message, data))); - }, - ).paddingAll(16), - ]), - error: errorPage)))); + final fingerprintText = + '${translate('create_invitation_dialog.fingerprint')}\n' + '$fingerprint'; + + return BlocListener( + bloc: locator(), + listener: (context, state) { + final listState = state.state.asData?.value; + final data = generatorOutputV.asData?.value; + + if (listState != null && data != null) { + final idx = listState.indexWhere((x) => + x.value.contactRequestInbox.recordKey.toVeilid() == data.$2); + if (idx == -1) { + // This invitation is gone, close it + Navigator.pop(context); + } + } + }, + child: PopControl( + dismissible: !generatorOutputV.isLoading, + child: Dialog( + shape: RoundedRectangleBorder( + side: const BorderSide(width: 2), + borderRadius: BorderRadius.circular( + 16 * scaleConfig.borderRadiusScale)), + backgroundColor: Colors.white, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: cardsize, + maxWidth: cardsize, + minHeight: cardsize, + maxHeight: cardsize), + child: generatorOutputV.when( + loading: buildProgressIndicator, + data: (data) => Column(children: [ + FittedBox( + child: Text( + translate('create_invitation_dialog' + '.contact_invitation'), + style: textTheme.headlineSmall! + .copyWith(color: Colors.black))) + .paddingAll(8), + FittedBox( + child: QrImageView.withQr( + size: 300, + qr: QrCode.fromUint8List( + data: data.$1, + errorCorrectLevel: + QrErrorCorrectLevel.L)), + ).expanded(), + if (recipient.isNotEmpty) + AutoSizeText(recipient, + softWrap: true, + maxLines: 2, + style: textTheme.labelLarge! + .copyWith(color: Colors.black)) + .paddingAll(8), + if (message.isNotEmpty) + Text(message, + softWrap: true, + textAlign: TextAlign.center, + maxLines: 2, + style: textTheme.labelMedium! + .copyWith(color: Colors.black)) + .paddingAll(8), + Text(fingerprintText, + softWrap: true, + textAlign: TextAlign.center, + style: textTheme.labelSmall!.copyWith( + color: Colors.black, + fontFamily: 'Source Code Pro')) + .paddingAll(2), + ElevatedButton.icon( + icon: const Icon(Icons.copy), + style: ElevatedButton.styleFrom( + foregroundColor: Colors.black, + backgroundColor: Colors.white, + side: const BorderSide()), + label: Text(translate( + 'create_invitation_dialog.copy_invitation')), + onPressed: () async { + context.read().info( + text: translate('create_invitation_dialog' + '.invitation_copied')); + await Clipboard.setData(ClipboardData( + text: makeTextInvite( + recipient, message, data.$1))); + }, + ).paddingAll(16), + ]), + error: errorPage))))); } - static Future show( - {required BuildContext context, - required InvitationGeneratorCubit Function(BuildContext) create, - required String message}) async { + static Future show({ + required BuildContext context, + required Locator locator, + required InvitationGeneratorCubit Function(BuildContext) create, + required String recipient, + required String message, + }) async { + final fingerprint = + locator().state.identityPublicKey.toString(); + await showPopControlDialog( context: context, builder: (context) => BlocProvider( create: create, child: ContactInvitationDisplayDialog._( - modalContext: context, + locator: locator, + recipient: recipient, message: message, + fingerprint: fingerprint, ))); } } diff --git a/lib/contact_invitation/views/contact_invitation_item_widget.dart b/lib/contact_invitation/views/contact_invitation_item_widget.dart index c2a93c7..b86a833 100644 --- a/lib/contact_invitation/views/contact_invitation_item_widget.dart +++ b/lib/contact_invitation/views/contact_invitation_item_widget.dart @@ -37,28 +37,38 @@ class ContactInvitationItemWidget extends StatelessWidget { final tileDisabled = disabled || context.watch().isBusy; - return SliderTile( + var title = translate('contact_list.invitation'); + if (contactInvitationRecord.recipient.isNotEmpty) { + title = contactInvitationRecord.recipient; + } else if (contactInvitationRecord.message.isNotEmpty) { + title = contactInvitationRecord.message; + } + + return StyledSlideTile( key: ObjectKey(contactInvitationRecord), disabled: tileDisabled, selected: selected, - tileScale: ScaleKind.primary, - title: contactInvitationRecord.message.isEmpty - ? translate('contact_list.invitation') - : contactInvitationRecord.message, - icon: Icons.person_add, + tileScale: ScaleKind.secondary, + title: title, + leading: const Icon(Icons.person_add), onTap: () async { if (!context.mounted) { return; } await ContactInvitationDisplayDialog.show( context: context, + locator: context.read, + recipient: contactInvitationRecord.recipient, message: contactInvitationRecord.message, - create: (context) => InvitationGeneratorCubit.value( - Uint8List.fromList(contactInvitationRecord.invitation))); + create: (context) => InvitationGeneratorCubit.value(( + Uint8List.fromList(contactInvitationRecord.invitation), + contactInvitationRecord.contactRequestInbox.recordKey + .toVeilid() + ))); }, endActions: [ - SliderTileAction( - icon: Icons.delete, + SlideTileAction( + // icon: Icons.delete, label: translate('button.delete'), actionScale: ScaleKind.tertiary, onPressed: (context) async { diff --git a/lib/contact_invitation/views/contact_invitation_list_widget.dart b/lib/contact_invitation/views/contact_invitation_list_widget.dart index 19243b7..ff3bb8d 100644 --- a/lib/contact_invitation/views/contact_invitation_list_widget.dart +++ b/lib/contact_invitation/views/contact_invitation_list_widget.dart @@ -2,6 +2,7 @@ import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; import '../../proto/proto.dart' as proto; import '../../theme/theme.dart'; @@ -31,55 +32,58 @@ class ContactInvitationListWidget extends StatefulWidget { } class ContactInvitationListWidgetState - extends State { - final ScrollController _scrollController = ScrollController(); + extends State + with SingleTickerProviderStateMixin { + late final _controller = AnimationController( + vsync: this, duration: const Duration(milliseconds: 250), value: 1); + late final _animation = + CurvedAnimation(parent: _controller, curve: Curves.easeInOut); + bool _expanded = true; @override // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { final theme = Theme.of(context); - //final textTheme = theme.textTheme; + // final textTheme = theme.textTheme; final scale = theme.extension()!; + final scaleConfig = theme.extension()!; - return Container( - width: double.infinity, - margin: const EdgeInsets.fromLTRB(4, 0, 4, 4), - decoration: ShapeDecoration( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - )), - constraints: const BoxConstraints(maxHeight: 200), - child: Container( - width: double.infinity, - decoration: ShapeDecoration( - color: scale.primaryScale.subtleBackground, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - )), - child: ListView.builder( - controller: _scrollController, - itemCount: widget.contactInvitationRecordList.length, - itemBuilder: (context, index) { - if (index < 0 || - index >= widget.contactInvitationRecordList.length) { - return null; - } - return ContactInvitationItemWidget( - contactInvitationRecord: - widget.contactInvitationRecordList[index], - disabled: widget.disabled, - key: ObjectKey(widget.contactInvitationRecordList[index])) - .paddingLTRB(4, 2, 4, 2); - }, - findChildIndexCallback: (key) { - final index = widget.contactInvitationRecordList.indexOf( - (key as ObjectKey).value! as proto.ContactInvitationRecord); - if (index == -1) { - return null; - } - return index; - }, - ).paddingLTRB(4, 6, 4, 6)), - ); + return styledExpandingSliver( + context: context, + animation: _animation, + expanded: _expanded, + backgroundColor: scaleConfig.preferBorders + ? scale.primaryScale.subtleBackground + : scale.primaryScale.subtleBorder, + onTap: () { + setState(() { + _expanded = !_expanded; + }); + _controller.animateTo(_expanded ? 1 : 0); + }, + title: translate('contacts_dialog.invitations'), + sliver: SliverList.builder( + itemCount: widget.contactInvitationRecordList.length, + itemBuilder: (context, index) { + if (index < 0 || + index >= widget.contactInvitationRecordList.length) { + return null; + } + return ContactInvitationItemWidget( + contactInvitationRecord: + widget.contactInvitationRecordList[index], + disabled: widget.disabled, + key: ObjectKey(widget.contactInvitationRecordList[index])) + .paddingLTRB(4, 2, 4, 2); + }, + findChildIndexCallback: (key) { + final index = widget.contactInvitationRecordList.indexOf( + (key as ObjectKey).value! as proto.ContactInvitationRecord); + if (index == -1) { + return null; + } + return index; + }, + )); } } diff --git a/lib/contact_invitation/views/create_invitation_dialog.dart b/lib/contact_invitation/views/create_invitation_dialog.dart index ace71d5..41e6162 100644 --- a/lib/contact_invitation/views/create_invitation_dialog.dart +++ b/lib/contact_invitation/views/create_invitation_dialog.dart @@ -5,48 +5,54 @@ import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_translate/flutter_translate.dart'; +import 'package:provider/provider.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../account_manager/account_manager.dart'; +import '../../notifications/notifications.dart'; import '../../theme/theme.dart'; -import '../../tools/tools.dart'; import '../contact_invitation.dart'; class CreateInvitationDialog extends StatefulWidget { - const CreateInvitationDialog._({required this.modalContext}); + const CreateInvitationDialog._({required this.locator}); @override - CreateInvitationDialogState createState() => CreateInvitationDialogState(); + State createState() => _CreateInvitationDialogState(); static Future show(BuildContext context) async { await StyledDialog.show( context: context, title: translate('create_invitation_dialog.title'), - child: CreateInvitationDialog._(modalContext: context)); + child: CreateInvitationDialog._(locator: context.read)); } - final BuildContext modalContext; + final Locator locator; @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties - .add(DiagnosticsProperty('modalContext', modalContext)); + properties.add(DiagnosticsProperty('locator', locator)); } } -class CreateInvitationDialogState extends State { - final _messageTextController = TextEditingController( - text: translate('create_invitation_dialog.connect_with_me')); +class _CreateInvitationDialogState extends State { + late final TextEditingController _messageTextController; + late final TextEditingController _recipientTextController; EncryptionKeyType _encryptionKeyType = EncryptionKeyType.none; - String _encryptionKey = ''; + var _encryptionKey = ''; Timestamp? _expiration; @override void initState() { + final accountInfo = widget.locator().state; + final name = accountInfo.asData?.value.profile.name ?? + translate('create_invitation_dialog.me'); + _messageTextController = TextEditingController( + text: translate('create_invitation_dialog.connect_with_me', + args: {'name': name})); + _recipientTextController = TextEditingController(); super.initState(); } @@ -87,8 +93,8 @@ class CreateInvitationDialogState extends State { if (!mounted) { return; } - showErrorToast( - context, translate('create_invitation_dialog.pin_does_not_match')); + context.read().error( + text: translate('create_invitation_dialog.pin_does_not_match')); setState(() { _encryptionKeyType = EncryptionKeyType.none; _encryptionKey = ''; @@ -125,8 +131,8 @@ class CreateInvitationDialogState extends State { if (!mounted) { return; } - showErrorToast(context, - translate('create_invitation_dialog.password_does_not_match')); + context.read().error( + text: translate('create_invitation_dialog.password_does_not_match')); setState(() { _encryptionKeyType = EncryptionKeyType.none; _encryptionKey = ''; @@ -139,102 +145,124 @@ class CreateInvitationDialogState extends State { // Start generation final contactInvitationListCubit = - widget.modalContext.read(); + widget.locator(); + final profile = + widget.locator().state.asData?.value.profile; + if (profile == null) { + return; + } final generator = contactInvitationListCubit.createInvitation( + profile: profile, encryptionKeyType: _encryptionKeyType, encryptionKey: _encryptionKey, + recipient: _recipientTextController.text, message: _messageTextController.text, expiration: _expiration); + navigator.pop(); + await ContactInvitationDisplayDialog.show( context: context, + locator: widget.locator, + recipient: _recipientTextController.text, message: _messageTextController.text, create: (context) => InvitationGeneratorCubit(generator)); - - navigator.pop(); } @override - // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { final windowSize = MediaQuery.of(context).size; final maxDialogWidth = min(windowSize.width - 64.0, 800.0 - 64.0); final maxDialogHeight = windowSize.height - 64.0; final theme = Theme.of(context); - //final scale = theme.extension()!; final textTheme = theme.textTheme; + return ConstrainedBox( constraints: BoxConstraints(maxHeight: maxDialogHeight, maxWidth: maxDialogWidth), child: SingleChildScrollView( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.all(8).scaled(context), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, + spacing: 16.scaled(context), children: [ - Text( - translate('create_invitation_dialog.message_to_contact'), - ).paddingAll(8), + TextField( + autofocus: true, + controller: _recipientTextController, + onChanged: (value) { + setState(() {}); + }, + inputFormatters: [ + LengthLimitingTextInputFormatter(128), + ], + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8).scaled(context), + hintText: + translate('create_invitation_dialog.recipient_hint'), + labelText: + translate('create_invitation_dialog.recipient_name'), + helperText: + translate('create_invitation_dialog.recipient_helper')), + ), TextField( controller: _messageTextController, inputFormatters: [ LengthLimitingTextInputFormatter(128), ], decoration: InputDecoration( - //border: const OutlineInputBorder(), - hintText: - translate('create_invitation_dialog.enter_message_hint'), - labelText: translate('create_invitation_dialog.message')), - ).paddingAll(8), - const SizedBox(height: 10), + contentPadding: const EdgeInsets.all(8).scaled(context), + hintText: translate('create_invitation_dialog.message_hint'), + labelText: + translate('create_invitation_dialog.message_label'), + helperText: + translate('create_invitation_dialog.message_helper')), + ), Text(translate('create_invitation_dialog.protect_this_invitation'), - style: textTheme.labelLarge) - .paddingAll(8), - Wrap(spacing: 5, children: [ - ChoiceChip( - label: Text(translate('create_invitation_dialog.unlocked')), - selected: _encryptionKeyType == EncryptionKeyType.none, - onSelected: _onNoneEncryptionSelected, - ), - ChoiceChip( - label: Text(translate('create_invitation_dialog.pin')), - selected: _encryptionKeyType == EncryptionKeyType.pin, - onSelected: _onPinEncryptionSelected, - ), - ChoiceChip( - label: Text(translate('create_invitation_dialog.password')), - selected: _encryptionKeyType == EncryptionKeyType.password, - onSelected: _onPasswordEncryptionSelected, - ) - ]).paddingAll(8), + style: textTheme.labelLarge), + Wrap( + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + runSpacing: 8, + spacing: 8, + children: [ + ChoiceChip( + label: Text(translate('create_invitation_dialog.unlocked')), + selected: _encryptionKeyType == EncryptionKeyType.none, + onSelected: _onNoneEncryptionSelected, + ), + ChoiceChip( + label: Text(translate('create_invitation_dialog.pin')), + selected: _encryptionKeyType == EncryptionKeyType.pin, + onSelected: _onPinEncryptionSelected, + ), + ChoiceChip( + label: Text(translate('create_invitation_dialog.password')), + selected: _encryptionKeyType == EncryptionKeyType.password, + onSelected: _onPasswordEncryptionSelected, + ) + ]).toCenter(), Container( - width: double.infinity, - height: 60, - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.all(8).scaled(context), child: ElevatedButton( - onPressed: _onGenerateButtonPressed, + onPressed: _recipientTextController.text.isNotEmpty + ? _onGenerateButtonPressed + : null, child: Text( translate('create_invitation_dialog.generate'), - ), + ).paddingAll(16.scaled(context)), ), - ), - Text(translate('create_invitation_dialog.note')).paddingAll(8), + ).toCenter(), + Text(translate('create_invitation_dialog.note')), Text( translate('create_invitation_dialog.note_text'), style: Theme.of(context).textTheme.bodySmall, - ).paddingAll(8), + ), ], ), ), ); } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty( - 'messageTextController', _messageTextController)); - } } diff --git a/lib/contact_invitation/views/invitation_dialog.dart b/lib/contact_invitation/views/invitation_dialog.dart index f97869b..f4c7fcc 100644 --- a/lib/contact_invitation/views/invitation_dialog.dart +++ b/lib/contact_invitation/views/invitation_dialog.dart @@ -1,27 +1,30 @@ import 'dart:async'; +import 'package:async_tools/async_tools.dart'; import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_translate/flutter_translate.dart'; +import 'package:provider/provider.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../account_manager/account_manager.dart'; import '../../contacts/contacts.dart'; +import '../../notifications/notifications.dart'; import '../../theme/theme.dart'; import '../../tools/tools.dart'; import '../contact_invitation.dart'; class InvitationDialog extends StatefulWidget { const InvitationDialog( - {required this.modalContext, + {required Locator locator, required this.onValidationCancelled, required this.onValidationSuccess, required this.onValidationFailed, required this.inviteControlIsValid, required this.buildInviteControl, - super.key}); + super.key}) + : _locator = locator; final void Function() onValidationCancelled; final void Function() onValidationSuccess; @@ -32,7 +35,7 @@ class InvitationDialog extends StatefulWidget { InvitationDialogState dialogState, Future Function({required Uint8List inviteData}) validateInviteData) buildInviteControl; - final BuildContext modalContext; + final Locator _locator; @override InvitationDialogState createState() => InvitationDialogState(); @@ -54,43 +57,46 @@ class InvitationDialog extends StatefulWidget { InvitationDialogState dialogState, Future Function({required Uint8List inviteData}) validateInviteData)>.has( - 'buildInviteControl', buildInviteControl)) - ..add(DiagnosticsProperty('modalContext', modalContext)); + 'buildInviteControl', buildInviteControl)); } } class InvitationDialogState extends State { - ValidContactInvitation? _validInvitation; - bool _isValidating = false; - bool _isAccepting = false; - @override void initState() { super.initState(); } - bool get isValidating => _isValidating; - bool get isAccepting => _isAccepting; + Future _onCancel() async { + final navigator = Navigator.of(context); + _cancelRequest.cancel(); + setState(() { + _isAccepting = false; + }); + navigator.pop(); + } Future _onAccept() async { final navigator = Navigator.of(context); - final activeAccountInfo = widget.modalContext.read(); - final contactList = widget.modalContext.read(); + final accountInfo = widget._locator().state; + final contactList = widget._locator(); + final profile = + widget._locator().state.asData!.value.profile; setState(() { _isAccepting = true; }); final validInvitation = _validInvitation; if (validInvitation != null) { - final acceptedContact = await validInvitation.accept(); + final acceptedContact = await validInvitation.accept(profile); if (acceptedContact != null) { // initiator when accept is received will create // contact in the case of a 'note to self' - final isSelf = activeAccountInfo.identityPublicKey == + final isSelf = accountInfo.identityPublicKey == acceptedContact.remoteIdentity.currentInstance.publicKey; if (!isSelf) { await contactList.createContact( - remoteProfile: acceptedContact.remoteProfile, + profile: acceptedContact.remoteProfile, remoteSuperIdentity: acceptedContact.remoteIdentity, remoteConversationRecordKey: acceptedContact.remoteConversationRecordKey, @@ -100,7 +106,9 @@ class InvitationDialogState extends State { } } else { if (mounted) { - showErrorToast(context, 'invitation_dialog.failed_to_accept'); + context + .read() + .error(text: 'invitation_dialog.failed_to_accept'); } } } @@ -122,7 +130,9 @@ class InvitationDialogState extends State { // do nothing right now } else { if (mounted) { - showErrorToast(context, 'invitation_dialog.failed_to_reject'); + context + .read() + .error(text: 'invitation_dialog.failed_to_reject'); } } } @@ -137,7 +147,7 @@ class InvitationDialogState extends State { }) async { try { final contactInvitationListCubit = - widget.modalContext.read(); + widget._locator(); setState(() { _isValidating = true; @@ -146,6 +156,7 @@ class InvitationDialogState extends State { final validatedContactInvitation = await contactInvitationListCubit.validateInvitation( inviteData: inviteData, + cancelRequest: _cancelRequest, getEncryptionKeyCallback: (cs, encryptionKeyType, encryptedSecret) async { String encryptionKey; @@ -205,6 +216,17 @@ class InvitationDialogState extends State { _isValidating = false; _validInvitation = validatedContactInvitation; }); + } on ContactInviteInvalidIdentityException catch (_) { + if (mounted) { + context + .read() + .error(text: translate('invitation_dialog.invalid_identity')); + } + setState(() { + _isValidating = false; + _validInvitation = null; + widget.onValidationFailed(); + }); } on ContactInviteInvalidKeyException catch (e) { String errorText; switch (e.type) { @@ -216,7 +238,7 @@ class InvitationDialogState extends State { errorText = translate('invitation_dialog.invalid_password'); } if (mounted) { - showErrorToast(context, errorText); + context.read().error(text: errorText); } setState(() { _isValidating = false; @@ -227,17 +249,26 @@ class InvitationDialogState extends State { late final String errorText; if (e is VeilidAPIExceptionTryAgain) { errorText = translate('invitation_dialog.try_again_online'); + } + if (e is VeilidAPIExceptionKeyNotFound) { + errorText = translate('invitation_dialog.key_not_found'); } else { errorText = translate('invitation_dialog.invalid_invitation'); } if (mounted) { - showErrorToast(context, errorText); + context.read().error(text: errorText); } setState(() { _isValidating = false; _validInvitation = null; widget.onValidationFailed(); }); + } on CancelException { + setState(() { + _isValidating = false; + _validInvitation = null; + widget.onValidationCancelled(); + }); } on Exception catch (e) { log.debug('exception: $e', e); setState(() { @@ -257,6 +288,11 @@ class InvitationDialogState extends State { Text(translate('invitation_dialog.validating')) .paddingLTRB(0, 0, 0, 16), buildProgressIndicator().paddingAll(16), + ElevatedButton.icon( + icon: const Icon(Icons.cancel), + label: Text(translate('button.cancel')), + onPressed: _onCancel, + ).paddingAll(16), ]).toCenter(), if (_validInvitation == null && !_isValidating && @@ -266,13 +302,13 @@ class InvitationDialogState extends State { const Icon(Icons.error).paddingAll(16) ]).toCenter(), if (_validInvitation != null && !_isValidating) - Column(children: [ - Container( - constraints: const BoxConstraints(maxHeight: 64), - width: double.infinity, - child: - ProfileWidget(profile: _validInvitation!.remoteProfile)) - .paddingLTRB(0, 0, 0, 16), + Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + ProfileWidget( + profile: _validInvitation!.remoteProfile, + byline: _validInvitation!.remoteProfile.pronouns.isEmpty + ? null + : _validInvitation!.remoteProfile.pronouns, + ).paddingLTRB(0, 0, 0, 16), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ @@ -307,13 +343,25 @@ class InvitationDialogState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: _isAccepting - ? [buildProgressIndicator().paddingAll(16)] + ? [ + buildProgressIndicator().paddingAll(16), + ] : _buildPreAccept()), ), ); return PopControl(dismissible: dismissible, child: dialog); } + //////////////////////////////////////////////////////////////////////////// + + ValidContactInvitation? _validInvitation; + bool _isValidating = false; + bool _isAccepting = false; + final _cancelRequest = CancelRequest(); + + bool get isValidating => _isValidating; + bool get isAccepting => _isAccepting; + @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); diff --git a/lib/contact_invitation/views/new_contact_bottom_sheet.dart b/lib/contact_invitation/views/new_contact_bottom_sheet.dart deleted file mode 100644 index a79a07f..0000000 --- a/lib/contact_invitation/views/new_contact_bottom_sheet.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_translate/flutter_translate.dart'; - -import '../../theme/theme.dart'; -import 'create_invitation_dialog.dart'; -import 'paste_invitation_dialog.dart'; -import 'scan_invitation_dialog.dart'; - -Widget newContactBottomSheetBuilder( - BuildContext sheetContext, BuildContext context) { - final theme = Theme.of(sheetContext); - final scale = theme.extension()!; - - return KeyboardListener( - focusNode: FocusNode(), - onKeyEvent: (ke) { - if (ke.logicalKey == LogicalKeyboardKey.escape) { - Navigator.pop(sheetContext); - } - }, - child: styledBottomSheet( - context: context, - title: translate('add_contact_sheet.new_contact'), - child: SizedBox( - height: 160, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column(children: [ - IconButton( - onPressed: () async { - Navigator.pop(sheetContext); - await CreateInvitationDialog.show(context); - }, - iconSize: 64, - icon: const Icon(Icons.contact_page), - color: scale.primaryScale.hoverBorder), - Text( - translate('add_contact_sheet.create_invite'), - ) - ]), - Column(children: [ - IconButton( - onPressed: () async { - Navigator.pop(sheetContext); - await ScanInvitationDialog.show(context); - }, - iconSize: 64, - icon: const Icon(Icons.qr_code_scanner), - color: scale.primaryScale.hoverBorder), - Text( - translate('add_contact_sheet.scan_invite'), - ) - ]), - Column(children: [ - IconButton( - onPressed: () async { - Navigator.pop(sheetContext); - await PasteInvitationDialog.show(context); - }, - iconSize: 64, - icon: const Icon(Icons.paste), - color: scale.primaryScale.hoverBorder), - Text( - translate('add_contact_sheet.paste_invite'), - ) - ]) - ]).paddingAll(16)))); -} diff --git a/lib/contact_invitation/views/paste_invitation_dialog.dart b/lib/contact_invitation/views/paste_invitation_dialog.dart index ead492b..b014fc2 100644 --- a/lib/contact_invitation/views/paste_invitation_dialog.dart +++ b/lib/contact_invitation/views/paste_invitation_dialog.dart @@ -4,36 +4,30 @@ import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_translate/flutter_translate.dart'; +import 'package:provider/provider.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../theme/theme.dart'; -import '../../tools/tools.dart'; import 'invitation_dialog.dart'; class PasteInvitationDialog extends StatefulWidget { - const PasteInvitationDialog({required this.modalContext, super.key}); + const PasteInvitationDialog({required Locator locator, super.key}) + : _locator = locator; @override PasteInvitationDialogState createState() => PasteInvitationDialogState(); static Future show(BuildContext context) async { - final modalContext = context; + final locator = context.read; await showPopControlDialog( context: context, builder: (context) => StyledDialog( title: translate('paste_invitation_dialog.title'), - child: PasteInvitationDialog(modalContext: modalContext))); + child: PasteInvitationDialog(locator: locator))); } - final BuildContext modalContext; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - .add(DiagnosticsProperty('modalContext', modalContext)); - } + final Locator _locator; } class PasteInvitationDialogState extends State { @@ -118,8 +112,8 @@ class PasteInvitationDialogState extends State { constraints: const BoxConstraints(maxHeight: 200), child: TextField( enabled: !dialogState.isValidating, - onChanged: (text) async => - _onPasteChanged(text, validateInviteData), + autofocus: true, + onChanged: (text) => _onPasteChanged(text, validateInviteData), style: monoStyle, keyboardType: TextInputType.multiline, maxLines: null, @@ -135,14 +129,11 @@ class PasteInvitationDialogState extends State { } @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - return InvitationDialog( - modalContext: widget.modalContext, - onValidationCancelled: onValidationCancelled, - onValidationSuccess: onValidationSuccess, - onValidationFailed: onValidationFailed, - inviteControlIsValid: inviteControlIsValid, - buildInviteControl: buildInviteControl); - } + Widget build(BuildContext context) => InvitationDialog( + locator: widget._locator, + onValidationCancelled: onValidationCancelled, + onValidationSuccess: onValidationSuccess, + onValidationFailed: onValidationFailed, + inviteControlIsValid: inviteControlIsValid, + buildInviteControl: buildInviteControl); } diff --git a/lib/contact_invitation/views/scan_invitation_dialog.dart b/lib/contact_invitation/views/scan_invitation_dialog.dart index ab47df0..058383d 100644 --- a/lib/contact_invitation/views/scan_invitation_dialog.dart +++ b/lib/contact_invitation/views/scan_invitation_dialog.dart @@ -2,132 +2,40 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:image/image.dart' as img; -import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:pasteboard/pasteboard.dart'; +import 'package:provider/provider.dart'; import 'package:zxing2/qrcode.dart'; +import '../../notifications/notifications.dart'; import '../../theme/theme.dart'; import '../../tools/tools.dart'; +import 'camera_qr_scanner.dart'; import 'invitation_dialog.dart'; -// class BarcodeOverlay extends CustomPainter { -// BarcodeOverlay({ -// required this.barcode, -// required this.boxFit, -// required this.capture, -// required this.size, -// }); - -// final BarcodeCapture capture; -// final Barcode barcode; -// final BoxFit boxFit; -// final Size size; - -// @override -// void paint(Canvas canvas, Size size) { -// final adjustedSize = applyBoxFit(boxFit, size, size); - -// var verticalPadding = size.height - adjustedSize.destination.height; -// var horizontalPadding = size.width - adjustedSize.destination.width; -// if (verticalPadding > 0) { -// verticalPadding = verticalPadding / 2; -// } else { -// verticalPadding = 0; -// } - -// if (horizontalPadding > 0) { -// horizontalPadding = horizontalPadding / 2; -// } else { -// horizontalPadding = 0; -// } - -// final ratioWidth = (Platform.isIOS ? capture.size.width : size.width) / -// adjustedSize.destination.width; -// final ratioHeight = (Platform.isIOS ? capture.size.height : size.height) / -// adjustedSize.destination.height; - -// final adjustedOffset = []; -// for (final offset in barcode.corners) { -// adjustedOffset.add( -// Offset( -// offset.dx / ratioWidth + horizontalPadding, -// offset.dy / ratioHeight + verticalPadding, -// ), -// ); -// } -// final cutoutPath = Path()..addPolygon(adjustedOffset, true); - -// final backgroundPaint = Paint() -// ..color = Colors.red.withOpacity(0.3) -// ..style = PaintingStyle.fill -// ..blendMode = BlendMode.dstOut; - -// canvas.drawPath(cutoutPath, backgroundPaint); -// } - -// @override -// bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -// } - -class ScannerOverlay extends CustomPainter { - ScannerOverlay(this.scanWindow); - - final Rect scanWindow; - - @override - void paint(Canvas canvas, Size size) { - final backgroundPath = Path()..addRect(Rect.largest); - final cutoutPath = Path()..addRect(scanWindow); - - final backgroundPaint = Paint() - ..color = Colors.black.withOpacity(0.5) - ..style = PaintingStyle.fill - ..blendMode = BlendMode.dstOut; - - final backgroundWithCutout = Path.combine( - PathOperation.difference, - backgroundPath, - cutoutPath, - ); - canvas.drawPath(backgroundWithCutout, backgroundPaint); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} - class ScanInvitationDialog extends StatefulWidget { - const ScanInvitationDialog({required this.modalContext, super.key}); + const ScanInvitationDialog({required Locator locator, super.key}) + : _locator = locator; @override ScanInvitationDialogState createState() => ScanInvitationDialogState(); static Future show(BuildContext context) async { - final modalContext = context; + final locator = context.read; await showPopControlDialog( context: context, builder: (context) => StyledDialog( title: translate('scan_invitation_dialog.title'), - child: ScanInvitationDialog(modalContext: modalContext))); + child: ScanInvitationDialog(locator: locator))); } - final BuildContext modalContext; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - .add(DiagnosticsProperty('modalContext', modalContext)); - } + final Locator _locator; } class ScanInvitationDialogState extends State { - bool scanned = false; + var _scanned = false; @override void initState() { @@ -136,14 +44,14 @@ class ScanInvitationDialogState extends State { void onValidationCancelled() { setState(() { - scanned = false; + _scanned = false; }); } void onValidationSuccess() {} void onValidationFailed() { setState(() { - scanned = false; + _scanned = false; }); } @@ -151,137 +59,66 @@ class ScanInvitationDialogState extends State { Future scanQRImage(BuildContext context) async { final theme = Theme.of(context); - //final textTheme = theme.textTheme; final scale = theme.extension()!; - final windowSize = MediaQuery.of(context).size; - //final maxDialogWidth = min(windowSize.width - 64.0, 800.0 - 64.0); - //final maxDialogHeight = windowSize.height - 64.0; - final scanWindow = Rect.fromCenter( - center: MediaQuery.of(context).size.center(Offset.zero), - width: 200, - height: 200, - ); - - final cameraController = MobileScannerController(); try { return showDialog( context: context, builder: (context) => Stack( fit: StackFit.expand, children: [ - MobileScanner( - fit: BoxFit.contain, - scanWindow: scanWindow, - controller: cameraController, - errorBuilder: (context, error, child) => - ScannerErrorWidget(error: error), - onDetect: (c) { - final barcode = c.barcodes.firstOrNull; - - final barcodeBytes = barcode?.rawBytes; - if (barcodeBytes != null) { - cameraController.dispose(); - Navigator.pop(context, barcodeBytes); - } - }), - CustomPaint( - painter: ScannerOverlay(scanWindow), - ), - Align( - alignment: Alignment.bottomCenter, - child: Container( - alignment: Alignment.bottomCenter, - height: 100, - color: Colors.black.withOpacity(0.4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - IconButton( - color: Colors.white, - icon: ValueListenableBuilder( - valueListenable: cameraController, - builder: (context, state, child) { - switch (state.torchState) { - case TorchState.off: - return Icon(Icons.flash_off, - color: - scale.grayScale.subtleBackground); - case TorchState.on: - return Icon(Icons.flash_on, - color: scale.primaryScale.primary); - case TorchState.auto: - return Icon(Icons.flash_auto, - color: scale.primaryScale.primary); - case TorchState.unavailable: - return Icon(Icons.no_flash, - color: scale.primaryScale.primary); - } - }, - ), - iconSize: 32, - onPressed: cameraController.toggleTorch, - ), - SizedBox( - width: windowSize.width - 120, - height: 50, - child: FittedBox( - child: Text( + CameraQRScanner( + scanSize: const Size(200, 200), + loadingBuilder: (context) => waitingPage(), + errorBuilder: (coRntext, e, st) => errorPage(e, st), + bottomRowBuilder: (context) => FittedBox( + fit: BoxFit.scaleDown, + child: Text( translate( 'scan_invitation_dialog.instructions'), - overflow: TextOverflow.fade, - style: Theme.of(context) - .textTheme - .labelLarge! - .copyWith(color: Colors.white), - ), - ), + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelLarge), ), - IconButton( - color: Colors.white, - icon: ValueListenableBuilder( - valueListenable: cameraController, - builder: (context, state, child) { - switch (state.cameraDirection) { - case CameraFacing.front: - return const Icon(Icons.camera_front); - case CameraFacing.back: - return const Icon(Icons.camera_rear); - } - }, - ), - iconSize: 32, - onPressed: cameraController.switchCamera, - ), - ], - ), - ), - ), + showNotification: (s) {}, + logError: log.error, + cameraErrorMessage: + translate('scan_invitation_dialog.camera_error'), + deniedErrorMessage: + translate('scan_invitation_dialog.permission_error'), + deniedWithoutPromptErrorMessage: + translate('scan_invitation_dialog.permission_error'), + restrictedErrorMessage: + translate('scan_invitation_dialog.permission_error'), + onDetect: (result) { + final byteSegments = result + .resultMetadata[ResultMetadataType.byteSegments]; + if (byteSegments != null) { + final segs = byteSegments as List; + + final byteData = Uint8List.fromList(segs[0].toList()); + return byteData; + } + return null; + }, + onDone: (result) { + Navigator.of(context).pop(result); + }), Align( alignment: Alignment.topRight, child: IconButton( color: Colors.white, - icon: - Icon(Icons.close, color: scale.grayScale.primary), - iconSize: 32, - onPressed: () => { - SchedulerBinding.instance - .addPostFrameCallback((_) { - cameraController.dispose(); - Navigator.pop(context); - }) - })), + icon: Icon(Icons.close, + color: scale.primaryScale.appText), + iconSize: 32.scaled(context), + onPressed: () { + Navigator.of(context).pop(); + })), ], )); - } on MobileScannerException catch (e) { - if (e.errorCode == MobileScannerErrorCode.permissionDenied) { - showErrorToast( - context, translate('scan_invitation_dialog.permission_error')); - } else { - showErrorToast(context, translate('scan_invitation_dialog.error')); - } } on Exception catch (_) { - showErrorToast(context, translate('scan_invitation_dialog.error')); + context + .read() + .error(text: translate('scan_invitation_dialog.error')); } return null; @@ -291,8 +128,9 @@ class ScanInvitationDialogState extends State { final imageBytes = await Pasteboard.image; if (imageBytes == null) { if (context.mounted) { - showErrorToast( - context, translate('scan_invitation_dialog.not_an_image')); + context + .read() + .error(text: translate('scan_invitation_dialog.not_an_image')); } return null; } @@ -300,8 +138,8 @@ class ScanInvitationDialogState extends State { final image = img.decodeImage(imageBytes); if (image == null) { if (context.mounted) { - showErrorToast(context, - translate('scan_invitation_dialog.could_not_decode_image')); + context.read().error( + text: translate('scan_invitation_dialog.could_not_decode_image')); } return null; } @@ -325,8 +163,8 @@ class ScanInvitationDialogState extends State { return Uint8List.fromList(segs[0].toList()); } on Exception catch (_) { if (context.mounted) { - showErrorToast( - context, translate('scan_invitation_dialog.not_a_valid_qr_code')); + context.read().error( + text: translate('scan_invitation_dialog.not_a_valid_qr_code')); } return null; } @@ -337,76 +175,66 @@ class ScanInvitationDialogState extends State { InvitationDialogState dialogState, Future Function({required Uint8List inviteData}) validateInviteData) { - //final theme = Theme.of(context); - //final scale = theme.extension()!; - //final textTheme = theme.textTheme; - //final height = MediaQuery.of(context).size.height; - - if (isiOS || isAndroid) { - return Column(mainAxisSize: MainAxisSize.min, children: [ - if (!scanned) - Text( - translate('scan_invitation_dialog.scan_qr_here'), - ).paddingLTRB(0, 0, 0, 8), - if (!scanned) - Container( - constraints: const BoxConstraints(maxHeight: 200), - child: ElevatedButton( - onPressed: dialogState.isValidating - ? null - : () async { - final inviteData = await scanQRImage(context); - if (inviteData != null) { - setState(() { - scanned = true; - }); - await validateInviteData(inviteData: inviteData); - } - }, - child: Text(translate('scan_invitation_dialog.scan'))), - ).paddingLTRB(0, 0, 0, 8) - ]); + if (_scanned) { + return const SizedBox.shrink(); } - return Column(mainAxisSize: MainAxisSize.min, children: [ - if (!scanned) + + final children = []; + if (isiOS || isAndroid) { + children.addAll([ Text( - translate('scan_invitation_dialog.paste_qr_here'), + translate('scan_invitation_dialog.scan_qr_here'), ).paddingLTRB(0, 0, 0, 8), - if (!scanned) Container( constraints: const BoxConstraints(maxHeight: 200), child: ElevatedButton( onPressed: dialogState.isValidating ? null : () async { - final inviteData = await pasteQRImage(context); + final inviteData = await scanQRImage(context); if (inviteData != null) { - await validateInviteData(inviteData: inviteData); setState(() { - scanned = true; + _scanned = true; }); + await validateInviteData(inviteData: inviteData); } }, - child: Text(translate('scan_invitation_dialog.paste'))), + child: Text(translate('scan_invitation_dialog.scan'))), ).paddingLTRB(0, 0, 0, 8) + ]); + } + + children.addAll([ + Text( + translate('scan_invitation_dialog.paste_qr_here'), + ).paddingLTRB(0, 0, 0, 8), + Container( + constraints: const BoxConstraints(maxHeight: 200), + child: ElevatedButton( + onPressed: dialogState.isValidating + ? null + : () async { + final inviteData = await pasteQRImage(context); + if (inviteData != null) { + await validateInviteData(inviteData: inviteData); + setState(() { + _scanned = true; + }); + } + }, + child: Text(translate('scan_invitation_dialog.paste'))), + ).paddingLTRB(0, 0, 0, 8) ]); + + return Column(mainAxisSize: MainAxisSize.min, children: children); } @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - return InvitationDialog( - modalContext: widget.modalContext, - onValidationCancelled: onValidationCancelled, - onValidationSuccess: onValidationSuccess, - onValidationFailed: onValidationFailed, - inviteControlIsValid: inviteControlIsValid, - buildInviteControl: buildInviteControl); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('scanned', scanned)); - } + Widget build(BuildContext context) => InvitationDialog( + locator: widget._locator, + onValidationCancelled: onValidationCancelled, + onValidationSuccess: onValidationSuccess, + onValidationFailed: onValidationFailed, + inviteControlIsValid: inviteControlIsValid, + buildInviteControl: buildInviteControl); } diff --git a/lib/contact_invitation/views/views.dart b/lib/contact_invitation/views/views.dart index 726f0b9..319296b 100644 --- a/lib/contact_invitation/views/views.dart +++ b/lib/contact_invitation/views/views.dart @@ -1,8 +1,8 @@ +export 'camera_qr_scanner.dart'; export 'contact_invitation_display.dart'; export 'contact_invitation_item_widget.dart'; export 'contact_invitation_list_widget.dart'; export 'create_invitation_dialog.dart'; export 'invitation_dialog.dart'; -export 'new_contact_bottom_sheet.dart'; export 'paste_invitation_dialog.dart'; export 'scan_invitation_dialog.dart'; diff --git a/lib/contacts/contacts.dart b/lib/contacts/contacts.dart index 6acdd43..08ae2e7 100644 --- a/lib/contacts/contacts.dart +++ b/lib/contacts/contacts.dart @@ -1,2 +1,3 @@ export 'cubits/cubits.dart'; +export 'models/models.dart'; export 'views/views.dart'; diff --git a/lib/contacts/cubits/contact_list_cubit.dart b/lib/contacts/cubits/contact_list_cubit.dart index 5ab14ea..a0591ad 100644 --- a/lib/contacts/cubits/contact_list_cubit.dart +++ b/lib/contacts/cubits/contact_list_cubit.dart @@ -1,79 +1,142 @@ import 'dart:async'; import 'dart:convert'; +import 'package:async_tools/async_tools.dart'; +import 'package:protobuf/protobuf.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../account_manager/account_manager.dart'; import '../../proto/proto.dart' as proto; import '../../tools/tools.dart'; -import 'conversation_cubit.dart'; +import '../models/models.dart'; ////////////////////////////////////////////////// // Mutable state for per-account contacts class ContactListCubit extends DHTShortArrayCubit { ContactListCubit({ - required ActiveAccountInfo activeAccountInfo, - required proto.Account account, - }) : _activeAccountInfo = activeAccountInfo, - super( - open: () => _open(activeAccountInfo, account), + required AccountInfo accountInfo, + required OwnedDHTRecordPointer contactListRecordPointer, + }) : super( + open: () => + _open(accountInfo.accountRecordKey, contactListRecordPointer), decodeElement: proto.Contact.fromBuffer); - static Future _open( - ActiveAccountInfo activeAccountInfo, proto.Account account) async { - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - final contactListRecordKey = account.contactList.toVeilid(); - - final dhtRecord = await DHTShortArray.openOwned(contactListRecordKey, + static Future _open(TypedKey accountRecordKey, + OwnedDHTRecordPointer contactListRecordPointer) async { + final dhtRecord = await DHTShortArray.openOwned(contactListRecordPointer, debugName: 'ContactListCubit::_open::ContactList', parent: accountRecordKey); return dhtRecord; } - Future createContact({ - required proto.Profile remoteProfile, - required SuperIdentity remoteSuperIdentity, - required TypedKey remoteConversationRecordKey, + @override + Future close() async { + await _contactProfileUpdateMap.close(); + await super.close(); + } + //////////////////////////////////////////////////////////////////////////// + // Public Interface + + void followContactProfileChanges(TypedKey localConversationRecordKey, + Stream profileStream, proto.Profile? profileState) { + _contactProfileUpdateMap + .follow(localConversationRecordKey, profileStream, profileState, + (remoteProfile) async { + if (remoteProfile == null) { + return; + } + return updateContactProfile( + localConversationRecordKey: localConversationRecordKey, + profile: remoteProfile); + }); + } + + Future updateContactProfile({ required TypedKey localConversationRecordKey, + required proto.Profile profile, + }) async { + // Update contact's remoteProfile + await operateWriteEventual((writer) async { + for (var pos = 0; pos < writer.length; pos++) { + final c = await writer.getProtobuf(proto.Contact.fromBuffer, pos); + if (c != null && + c.localConversationRecordKey.toVeilid() == + localConversationRecordKey) { + if (c.profile == profile) { + // Unchanged + break; + } + final newContact = c.deepCopy()..profile = profile; + final updated = await writer.tryWriteItemProtobuf( + proto.Contact.fromBuffer, pos, newContact); + if (!updated) { + throw const DHTExceptionOutdated(); + } + break; + } + } + }); + } + + Future updateContactFields({ + required TypedKey localConversationRecordKey, + required ContactSpec updatedContactSpec, + }) async { + // Update contact's locally-modifiable fields + await operateWriteEventual((writer) async { + for (var pos = 0; pos < writer.length; pos++) { + final c = await writer.getProtobuf(proto.Contact.fromBuffer, pos); + if (c != null && + c.localConversationRecordKey.toVeilid() == + localConversationRecordKey) { + final newContact = await updatedContactSpec.updateProto(c); + + final updated = await writer.tryWriteItemProtobuf( + proto.Contact.fromBuffer, pos, newContact); + if (!updated) { + throw const DHTExceptionOutdated(); + } + break; + } + } + }); + } + + Future createContact({ + required proto.Profile profile, + required SuperIdentity remoteSuperIdentity, + required TypedKey localConversationRecordKey, + required TypedKey remoteConversationRecordKey, }) async { // Create Contact final contact = proto.Contact() - ..editedProfile = remoteProfile - ..remoteProfile = remoteProfile + ..profile = profile ..superIdentityJson = jsonEncode(remoteSuperIdentity.toJson()) ..identityPublicKey = remoteSuperIdentity.currentInstance.typedPublicKey.toProto() - ..remoteConversationRecordKey = remoteConversationRecordKey.toProto() ..localConversationRecordKey = localConversationRecordKey.toProto() + ..remoteConversationRecordKey = remoteConversationRecordKey.toProto() ..showAvailability = false; // Add Contact to account's list - // if this fails, don't keep retrying, user can try again later - await operateWrite((writer) async { + await operateWriteEventual((writer) async { await writer.add(contact.writeToBuffer()); }); } - Future deleteContact({required proto.Contact contact}) async { - final remoteIdentityPublicKey = contact.identityPublicKey.toVeilid(); - final localConversationRecordKey = - contact.localConversationRecordKey.toVeilid(); - final remoteConversationRecordKey = - contact.remoteConversationRecordKey.toVeilid(); - + Future deleteContact( + {required TypedKey localConversationRecordKey}) async { // Remove Contact from account's list - final deletedItem = await operateWrite((writer) async { + final deletedItem = await operateWriteEventual((writer) async { for (var i = 0; i < writer.length; i++) { final item = await writer.getProtobuf(proto.Contact.fromBuffer, i); if (item == null) { throw Exception('Failed to get contact'); } - if (item.localConversationRecordKey == - contact.localConversationRecordKey) { + if (item.localConversationRecordKey.toVeilid() == + localConversationRecordKey) { await writer.remove(i); return item; } @@ -83,21 +146,21 @@ class ContactListCubit extends DHTShortArrayCubit { if (deletedItem != null) { try { - // Make a conversation cubit to manipulate the conversation - final conversationCubit = ConversationCubit( - activeAccountInfo: _activeAccountInfo, - remoteIdentityPublicKey: remoteIdentityPublicKey, - localConversationRecordKey: localConversationRecordKey, - remoteConversationRecordKey: remoteConversationRecordKey, - ); - - // Delete the local and remote conversation records - await conversationCubit.delete(); + // Mark the conversation records for deletion + await DHTRecordPool.instance + .deleteRecord(deletedItem.localConversationRecordKey.toVeilid()); } on Exception catch (e) { - log.debug('error deleting conversation records: $e', e); + log.debug('error deleting local conversation record: $e', e); + } + try { + await DHTRecordPool.instance + .deleteRecord(deletedItem.remoteConversationRecordKey.toVeilid()); + } on Exception catch (e) { + log.debug('error deleting remote conversation record: $e', e); } } } - final ActiveAccountInfo _activeAccountInfo; + final _contactProfileUpdateMap = + SingleStateProcessorMap(); } diff --git a/lib/contacts/cubits/cubits.dart b/lib/contacts/cubits/cubits.dart index 3d16d52..795d497 100644 --- a/lib/contacts/cubits/cubits.dart +++ b/lib/contacts/cubits/cubits.dart @@ -1,2 +1 @@ export 'contact_list_cubit.dart'; -export 'conversation_cubit.dart'; diff --git a/lib/contacts/models/contact_spec.dart b/lib/contacts/models/contact_spec.dart new file mode 100644 index 0000000..1596434 --- /dev/null +++ b/lib/contacts/models/contact_spec.dart @@ -0,0 +1,37 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:protobuf/protobuf.dart'; + +import '../../proto/proto.dart' as proto; + +@immutable +class ContactSpec extends Equatable { + const ContactSpec({ + required this.nickname, + required this.notes, + required this.showAvailability, + }); + + ContactSpec.fromProto(proto.Contact p) + : nickname = p.nickname, + notes = p.notes, + showAvailability = p.showAvailability; + + Future updateProto(proto.Contact old) async { + final newProto = old.deepCopy() + ..nickname = nickname + ..notes = notes + ..showAvailability = showAvailability; + + return newProto; + } + + //////////////////////////////////////////////////////////////////////////// + + final String nickname; + final String notes; + final bool showAvailability; + + @override + List get props => [nickname, notes, showAvailability]; +} diff --git a/lib/contacts/models/models.dart b/lib/contacts/models/models.dart new file mode 100644 index 0000000..d489632 --- /dev/null +++ b/lib/contacts/models/models.dart @@ -0,0 +1 @@ +export 'contact_spec.dart'; diff --git a/lib/contacts/views/availability_widget.dart b/lib/contacts/views/availability_widget.dart new file mode 100644 index 0000000..5ef6080 --- /dev/null +++ b/lib/contacts/views/availability_widget.dart @@ -0,0 +1,91 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; + +class AvailabilityWidget extends StatelessWidget { + const AvailabilityWidget( + {required this.availability, + required this.color, + this.vertical = true, + super.key}); + + static Widget availabilityIcon( + BuildContext context, + proto.Availability availability, + Color color, + ) { + late final Widget icon; + switch (availability) { + case proto.Availability.AVAILABILITY_AWAY: + icon = SvgPicture.asset('assets/images/toilet.svg', + width: 24.scaled(context), + height: 24.scaled(context), + colorFilter: ColorFilter.mode(color, BlendMode.srcATop)); + case proto.Availability.AVAILABILITY_BUSY: + icon = Icon(size: 24.scaled(context), Icons.event_busy); + case proto.Availability.AVAILABILITY_FREE: + icon = Icon(size: 24.scaled(context), Icons.event_available); + case proto.Availability.AVAILABILITY_OFFLINE: + icon = Icon(size: 24.scaled(context), Icons.cloud_off); + case proto.Availability.AVAILABILITY_UNSPECIFIED: + icon = Icon(size: 24.scaled(context), Icons.question_mark); + } + return icon; + } + + static String availabilityName(proto.Availability availability) { + late final String name; + switch (availability) { + case proto.Availability.AVAILABILITY_AWAY: + name = translate('availability.away'); + case proto.Availability.AVAILABILITY_BUSY: + name = translate('availability.busy'); + case proto.Availability.AVAILABILITY_FREE: + name = translate('availability.free'); + case proto.Availability.AVAILABILITY_OFFLINE: + name = translate('availability.offline'); + case proto.Availability.AVAILABILITY_UNSPECIFIED: + name = translate('availability.unspecified'); + } + return name; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + + final name = availabilityName(availability); + final icon = availabilityIcon(context, availability, color); + + return vertical + ? Column(mainAxisSize: MainAxisSize.min, children: [ + icon, + Text(name, style: textTheme.labelSmall!.copyWith(color: color)) + ]) + : Row(mainAxisSize: MainAxisSize.min, children: [ + icon, + Text(' $name', style: textTheme.labelLarge!.copyWith(color: color)) + ]); + } + + //////////////////////////////////////////////////////////////////////////// + + final proto.Availability availability; + final Color color; + final bool vertical; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add( + DiagnosticsProperty('availability', availability)) + ..add(DiagnosticsProperty('vertical', vertical)) + ..add(ColorProperty('color', color)); + } +} diff --git a/lib/contacts/views/contact_details_widget.dart b/lib/contacts/views/contact_details_widget.dart new file mode 100644 index 0000000..707dc1e --- /dev/null +++ b/lib/contacts/views/contact_details_widget.dart @@ -0,0 +1,51 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../proto/proto.dart' as proto; +import '../../tools/tools.dart'; +import '../contacts.dart'; + +class ContactDetailsWidget extends StatefulWidget { + const ContactDetailsWidget( + {required this.contact, this.onModifiedState, super.key}); + + @override + State createState() => _ContactDetailsWidgetState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('contact', contact)) + ..add(ObjectFlagProperty.has( + 'onModifiedState', onModifiedState)); + } + + final proto.Contact contact; + final void Function(bool)? onModifiedState; +} + +class _ContactDetailsWidgetState extends State { + @override + Widget build(BuildContext context) => SingleChildScrollView( + child: EditContactForm( + contact: widget.contact, + submitText: translate('button.update'), + submitDisabledText: translate('button.waiting_for_network'), + onModifiedState: widget.onModifiedState, + onSubmit: (updatedContactSpec) async { + final contactList = context.read(); + try { + await contactList.updateContactFields( + localConversationRecordKey: + widget.contact.localConversationRecordKey.toVeilid(), + updatedContactSpec: updatedContactSpec); + } on Exception catch (e) { + log.debug('error updating contact: $e', e); + return false; + } + return true; + })); +} diff --git a/lib/contacts/views/contact_item_widget.dart b/lib/contacts/views/contact_item_widget.dart index 3deae23..9a76be5 100644 --- a/lib/contacts/views/contact_item_widget.dart +++ b/lib/contacts/views/contact_item_widget.dart @@ -1,79 +1,101 @@ -import 'package:flutter/foundation.dart'; +import 'package:async_tools/async_tools.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import '../../chat_list/chat_list.dart'; -import '../../layout/layout.dart'; import '../../proto/proto.dart' as proto; import '../../theme/theme.dart'; -import '../contacts.dart'; + +const _kOnTap = 'onTap'; class ContactItemWidget extends StatelessWidget { const ContactItemWidget( - {required this.contact, required this.disabled, super.key}); - - final proto.Contact contact; - final bool disabled; + {required proto.Contact contact, + required bool disabled, + required bool selected, + Future Function(proto.Contact)? onTap, + Future Function(proto.Contact)? onDoubleTap, + Future Function(proto.Contact)? onDelete, + super.key}) + : _disabled = disabled, + _selected = selected, + _contact = contact, + _onTap = onTap, + _onDoubleTap = onDoubleTap, + _onDelete = onDelete; @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty('contact', contact)) - ..add(DiagnosticsProperty('disabled', disabled)); - } - - @override - // ignore: prefer_expression_function_bodies Widget build( BuildContext context, ) { - final localConversationRecordKey = - contact.localConversationRecordKey.toVeilid(); + final name = _contact.nameOrNickname; + final title = _contact.displayName; + final subtitle = _contact.profile.status; - const selected = false; // xxx: eventually when we have selectable contacts: - // activeContactCubit.state == localConversationRecordKey; + final avatar = StyledAvatar( + name: name, + size: 34.scaled(context), + ); - final tileDisabled = disabled || context.watch().isBusy; - - return SliderTile( - key: ObjectKey(contact), - disabled: tileDisabled, - selected: selected, + return StyledSlideTile( + key: ObjectKey(_contact), + disabled: _disabled, + selected: _selected, tileScale: ScaleKind.primary, - title: contact.editedProfile.name, - subtitle: contact.editedProfile.pronouns, - icon: Icons.person, - onTap: () async { - // Start a chat - final chatListCubit = context.read(); - - await chatListCubit.getOrCreateChatSingleContact(contact: contact); - // Click over to chats - if (context.mounted) { - await MainPager.of(context) - ?.pageController - .animateToPage(1, duration: 250.ms, curve: Curves.easeInOut); - } - }, + title: title, + subtitle: subtitle, + leading: avatar, + onDoubleTap: _onDoubleTap == null + ? null + : () => singleFuture((this, _kOnTap), () async { + await _onDoubleTap(_contact); + }), + onTap: _onTap == null + ? null + : () => singleFuture((this, _kOnTap), () async { + await _onTap(_contact); + }), + startActions: [ + if (_onDoubleTap != null) + SlideTileAction( + //icon: Icons.edit, + label: translate('button.chat'), + actionScale: ScaleKind.secondary, + onPressed: (_context) => + singleFuture((this, _kOnTap), () async { + await _onDoubleTap(_contact); + }), + ), + ], endActions: [ - SliderTileAction( - icon: Icons.delete, + if (_onTap != null) + SlideTileAction( + //icon: Icons.edit, + label: translate('button.edit'), + actionScale: ScaleKind.secondary, + onPressed: (_context) => + singleFuture((this, _kOnTap), () async { + await _onTap(_contact); + }), + ), + if (_onDelete != null) + SlideTileAction( + //icon: Icons.delete, label: translate('button.delete'), actionScale: ScaleKind.tertiary, - onPressed: (context) async { - final contactListCubit = context.read(); - final chatListCubit = context.read(); - - // Remove any chats for this contact - await chatListCubit.deleteChat( - localConversationRecordKey: localConversationRecordKey); - - // Delete the contact itself - await contactListCubit.deleteContact(contact: contact); - }) + onPressed: (_context) => + singleFuture((this, _kOnTap), () async { + await _onDelete(_contact); + }), + ), ], ); } + + //////////////////////////////////////////////////////////////////////////// + + final proto.Contact _contact; + final bool _disabled; + final bool _selected; + final Future Function(proto.Contact contact)? _onTap; + final Future Function(proto.Contact contact)? _onDoubleTap; + final Future Function(proto.Contact contact)? _onDelete; } diff --git a/lib/contacts/views/contact_list_widget.dart b/lib/contacts/views/contact_list_widget.dart deleted file mode 100644 index 6ef3ca0..0000000 --- a/lib/contacts/views/contact_list_widget.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:searchable_listview/searchable_listview.dart'; - -import '../../proto/proto.dart' as proto; -import '../../theme/theme.dart'; -import 'contact_item_widget.dart'; -import 'empty_contact_list_widget.dart'; - -class ContactListWidget extends StatelessWidget { - const ContactListWidget( - {required this.contactList, required this.disabled, super.key}); - final IList contactList; - final bool disabled; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(IterableProperty('contactList', contactList)) - ..add(DiagnosticsProperty('disabled', disabled)); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scale = theme.extension()!; - - return SizedBox.expand( - child: styledTitleContainer( - context: context, - title: translate('contact_list.title'), - child: SizedBox.expand( - child: (contactList.isEmpty) - ? const EmptyContactListWidget() - : SearchableList( - initialList: contactList.toList(), - itemBuilder: (c) => - ContactItemWidget(contact: c, disabled: disabled) - .paddingLTRB(0, 4, 0, 0), - filter: (value) { - final lowerValue = value.toLowerCase(); - return contactList - .where((element) => - element.editedProfile.name - .toLowerCase() - .contains(lowerValue) || - element.editedProfile.pronouns - .toLowerCase() - .contains(lowerValue)) - .toList(); - }, - spaceBetweenSearchAndList: 4, - defaultSuffixIconColor: scale.primaryScale.border, - inputDecoration: InputDecoration( - labelText: translate('contact_list.search'), - ), - ).paddingAll(8), - ))).paddingLTRB(8, 0, 8, 8); - } -} diff --git a/lib/contacts/views/contacts_browser.dart b/lib/contacts/views/contacts_browser.dart new file mode 100644 index 0000000..c04a4e4 --- /dev/null +++ b/lib/contacts/views/contacts_browser.dart @@ -0,0 +1,253 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:searchable_listview/searchable_listview.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../contact_invitation/contact_invitation.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import '../cubits/cubits.dart'; +import 'contact_item_widget.dart'; +import 'empty_contact_list_widget.dart'; + +enum ContactsBrowserElementKind { + contact, + invitation, +} + +class ContactsBrowserElement { + ContactsBrowserElement.contact(proto.Contact c) + : kind = ContactsBrowserElementKind.contact, + invitation = null, + contact = c; + ContactsBrowserElement.invitation(proto.ContactInvitationRecord i) + : kind = ContactsBrowserElementKind.invitation, + contact = null, + invitation = i; + + String get sortKey => switch (kind) { + ContactsBrowserElementKind.contact => contact!.displayName, + ContactsBrowserElementKind.invitation => + invitation!.recipient + invitation!.message + }; + + final ContactsBrowserElementKind kind; + final proto.ContactInvitationRecord? invitation; + final proto.Contact? contact; +} + +class ContactsBrowser extends StatefulWidget { + const ContactsBrowser( + {required this.onContactSelected, + required this.onContactDeleted, + required this.onStartChat, + this.selectedContactRecordKey, + super.key}); + @override + State createState() => _ContactsBrowserState(); + + final Future Function(proto.Contact? contact) onContactSelected; + final Future Function(proto.Contact contact) onContactDeleted; + final Future Function(proto.Contact contact) onStartChat; + final TypedKey? selectedContactRecordKey; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty( + 'selectedContactRecordKey', selectedContactRecordKey)) + ..add( + ObjectFlagProperty Function(proto.Contact? contact)>.has( + 'onContactSelected', onContactSelected)) + ..add( + ObjectFlagProperty Function(proto.Contact contact)>.has( + 'onStartChat', onStartChat)) + ..add( + ObjectFlagProperty Function(proto.Contact contact)>.has( + 'onContactDeleted', onContactDeleted)); + } +} + +class _ContactsBrowserState extends State + with SingleTickerProviderStateMixin { + Widget buildInvitationButton(BuildContext context) { + final theme = Theme.of(context); + final scaleScheme = theme.extension()!; + final scaleConfig = theme.extension()!; + + final menuIconColor = scaleConfig.preferBorders + ? scaleScheme.primaryScale.hoverBorder + : scaleScheme.primaryScale.hoverBorder; + // final menuBackgroundColor = scaleConfig.preferBorders + // ? scaleScheme.primaryScale.activeElementBackground + // : scaleScheme.primaryScale.activeElementBackground; + + // final menuBorderColor = scaleScheme.primaryScale.hoverBorder; + + PopupMenuEntry makeMenuButton( + {required IconData iconData, + required String text, + void Function()? onTap}) => + PopupMenuItem( + onTap: onTap, + child: Row( + spacing: 8.scaled(context), + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Icon(iconData, size: 32.scaled(context)), + Text( + text, + textScaler: MediaQuery.of(context).textScaler, + maxLines: 2, + textAlign: TextAlign.center, + ) + ])); + final inviteMenuItems = [ + makeMenuButton( + iconData: Icons.contact_page, + text: translate('add_contact_sheet.create_invite'), + onTap: () async { + await CreateInvitationDialog.show(context); + }), + makeMenuButton( + iconData: Icons.qr_code_scanner, + text: translate('add_contact_sheet.scan_invite'), + onTap: () async { + await ScanInvitationDialog.show(context); + }), + makeMenuButton( + iconData: Icons.paste, + text: translate('add_contact_sheet.paste_invite'), + onTap: () async { + await PasteInvitationDialog.show(context); + }), + ]; + + return PopupMenuButton( + itemBuilder: (_) => inviteMenuItems, + menuPadding: const EdgeInsets.symmetric(vertical: 8).scaled(context), + tooltip: translate('add_contact_sheet.add_contact'), + child: Icon( + size: 32.scaled(context), Icons.person_add, color: menuIconColor)); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + + final cilState = context.watch().state; + final contactInvitationRecordList = + cilState.state.asData?.value.map((x) => x.value).toIList() ?? + const IListConst([]); + + final ciState = context.watch().state; + final contactList = + ciState.state.asData?.value.map((x) => x.value).toIList(); + + final initialList = []; + if (contactList != null) { + initialList + .addAll(contactList.toList().map(ContactsBrowserElement.contact)); + } + if (contactInvitationRecordList.isNotEmpty) { + initialList.addAll(contactInvitationRecordList + .toList() + .map(ContactsBrowserElement.invitation)); + } + + initialList.sort((a, b) => a.sortKey.compareTo(b.sortKey)); + + return Column(children: [ + SearchableList( + initialList: initialList, + itemBuilder: (element) { + switch (element.kind) { + case ContactsBrowserElementKind.contact: + final contact = element.contact!; + return ContactItemWidget( + contact: contact, + selected: widget.selectedContactRecordKey == + contact.localConversationRecordKey.toVeilid(), + disabled: false, + onDoubleTap: _onStartChat, + onTap: onContactSelected, + onDelete: _onContactDeleted) + .paddingLTRB(0, 4.scaled(context), 0, 0); + case ContactsBrowserElementKind.invitation: + final invitation = element.invitation!; + return ContactInvitationItemWidget( + contactInvitationRecord: invitation, + disabled: false) + .paddingLTRB(0, 4.scaled(context), 0, 0); + } + }, + filter: (value) { + final lowerValue = value.toLowerCase(); + + final filtered = []; + for (final element in initialList) { + switch (element.kind) { + case ContactsBrowserElementKind.contact: + final contact = element.contact!; + if (contact.nickname.toLowerCase().contains(lowerValue) || + contact.profile.name + .toLowerCase() + .contains(lowerValue) || + contact.profile.pronouns + .toLowerCase() + .contains(lowerValue)) { + filtered.add(element); + } + case ContactsBrowserElementKind.invitation: + final invitation = element.invitation!; + if (invitation.message + .toLowerCase() + .contains(lowerValue) || + invitation.recipient + .toLowerCase() + .contains(lowerValue)) { + filtered.add(element); + } + } + } + return filtered; + }, + searchFieldHeight: 40.scaled(context), + listViewPadding: + const EdgeInsets.fromLTRB(4, 0, 4, 4).scaled(context), + searchFieldPadding: + const EdgeInsets.fromLTRB(4, 8, 4, 4).scaled(context), + emptyWidget: contactList == null + ? waitingPage( + text: translate('contact_list.loading_contacts')) + : const EmptyContactListWidget(), + defaultSuffixIconColor: scale.primaryScale.border, + searchFieldEnabled: contactList != null, + inputDecoration: + InputDecoration(labelText: translate('contact_list.search')), + secondaryWidget: buildInvitationButton(context) + .paddingLTRB(8.scaled(context), 0, 0, 0)) + .expanded() + ]); + } + + Future onContactSelected(proto.Contact contact) async { + await widget.onContactSelected(contact); + } + + Future _onStartChat(proto.Contact contact) async { + await widget.onStartChat(contact); + } + + Future _onContactDeleted(proto.Contact contact) async { + await widget.onContactDeleted(contact); + } + + //////////////////////////////////////////////////////////////////////////// +} diff --git a/lib/contacts/views/contacts_page.dart b/lib/contacts/views/contacts_page.dart new file mode 100644 index 0000000..c984b57 --- /dev/null +++ b/lib/contacts/views/contacts_page.dart @@ -0,0 +1,199 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:provider/provider.dart'; + +import '../../chat/chat.dart'; +import '../../chat_list/chat_list.dart'; +import '../../layout/layout.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import '../contacts.dart'; + +const _kDoBackArrow = 'doBackArrow'; + +class ContactsPage extends StatefulWidget { + const ContactsPage({super.key}); + + @override + State createState() => _ContactsPageState(); +} + +class _ContactsPageState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scaleTheme = theme.extension()!; + final appBarTheme = scaleTheme.appBarTheme(); + final scaleScheme = theme.extension()!; + final scale = scaleScheme.scale(ScaleKind.primary); + + final enableSplit = !isMobileSize(context); + final enableLeft = enableSplit || _selectedContact == null; + final enableRight = enableSplit || _selectedContact != null; + + return StyledScaffold( + appBar: DefaultAppBar( + context: context, + title: Text( + !enableSplit && enableRight + ? translate('contacts_dialog.edit_contact') + : translate('contacts_dialog.contacts'), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + iconSize: 24.scaled(context), + onPressed: () { + singleFuture((this, _kDoBackArrow), () async { + final confirmed = await _onContactSelected(null); + if (!enableSplit && enableRight) { + } else { + if (confirmed) { + if (context.mounted) { + Navigator.pop(context); + } + } + } + }); + }, + ), + actions: [ + if (_selectedContact != null) + IconButton( + icon: const Icon(Icons.chat_bubble), + iconSize: 24.scaled(context), + color: appBarTheme.iconColor, + tooltip: translate('contacts_dialog.new_chat'), + onPressed: () async { + await _onChatStarted(_selectedContact!); + }), + if (enableSplit && _selectedContact != null) + IconButton( + icon: const Icon(Icons.close), + iconSize: 24.scaled(context), + color: appBarTheme.iconColor, + tooltip: translate('contacts_dialog.close_contact'), + onPressed: () async { + await _onContactSelected(null); + }), + ]), + body: LayoutBuilder(builder: (context, constraint) { + final maxWidth = constraint.maxWidth; + + return ColoredBox( + color: scale.appBackground, + child: + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Offstage( + offstage: !enableLeft, + child: SizedBox( + width: enableLeft && !enableRight + ? maxWidth + : (maxWidth / 3).clamp(200, 500), + child: DecoratedBox( + decoration: + BoxDecoration(color: scale.subtleBackground), + child: ContactsBrowser( + selectedContactRecordKey: _selectedContact + ?.localConversationRecordKey + .toVeilid(), + onContactSelected: _onContactSelected, + onStartChat: _onChatStarted, + onContactDeleted: _onContactDeleted, + ).paddingLTRB(4, 0, 4, 8)))), + if (enableRight && enableLeft) + Container( + constraints: + const BoxConstraints(minWidth: 1, maxWidth: 1), + color: scale.subtleBorder), + if (enableRight) + if (_selectedContact == null) + const NoContactWidget().expanded() + else + ContactDetailsWidget( + contact: _selectedContact!, + onModifiedState: _onModifiedState) + .paddingLTRB(16, 16, 16, 16) + .expanded(), + ])); + })); + } + + void _onModifiedState(bool isModified) { + setState(() { + _isModified = isModified; + }); + } + + Future _onContactSelected(proto.Contact? contact) async { + if (contact != _selectedContact && _isModified) { + final ok = await showConfirmModal( + context: context, + title: translate('confirmation.discard_changes'), + text: translate('confirmation.are_you_sure_discard')); + if (!ok) { + return false; + } + } + setState(() { + _selectedContact = contact; + _isModified = false; + }); + return true; + } + + Future _onChatStarted(proto.Contact contact) async { + final chatListCubit = context.read(); + await chatListCubit.getOrCreateChatSingleContact(contact: contact); + + if (mounted) { + context + .read() + .setActiveChat(contact.localConversationRecordKey.toVeilid()); + Navigator.pop(context); + } + } + + Future _onContactDeleted(proto.Contact contact) async { + if (contact == _selectedContact && _isModified) { + final ok = await showConfirmModal( + context: context, + title: translate('confirmation.discard_changes'), + text: translate('confirmation.are_you_sure_discard')); + if (!ok) { + return false; + } + } + setState(() { + _selectedContact = null; + _isModified = false; + }); + + if (mounted) { + final localConversationRecordKey = + contact.localConversationRecordKey.toVeilid(); + + final contactListCubit = context.read(); + final chatListCubit = context.read(); + + // Delete the contact itself + await contactListCubit.deleteContact( + localConversationRecordKey: localConversationRecordKey); + + // Remove any chats for this contact + await chatListCubit.deleteChat( + localConversationRecordKey: localConversationRecordKey); + } + + return true; + } + + proto.Contact? _selectedContact; + var _isModified = false; +} diff --git a/lib/contacts/views/edit_contact_form.dart b/lib/contacts/views/edit_contact_form.dart new file mode 100644 index 0000000..7ab6019 --- /dev/null +++ b/lib/contacts/views/edit_contact_form.dart @@ -0,0 +1,233 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import '../models/contact_spec.dart'; +import 'availability_widget.dart'; + +const _kDoSubmitEditContact = 'doSubmitEditContact'; + +class EditContactForm extends StatefulWidget { + const EditContactForm({ + required this.contact, + required this.onSubmit, + required this.submitText, + required this.submitDisabledText, + this.onModifiedState, + super.key, + }); + + @override + State createState() => _EditContactFormState(); + + final proto.Contact contact; + final String submitText; + final String submitDisabledText; + final Future Function(ContactSpec) onSubmit; + final void Function(bool)? onModifiedState; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ObjectFlagProperty Function(ContactSpec p1)>.has( + 'onSubmit', onSubmit)) + ..add(ObjectFlagProperty.has( + 'onModifiedState', onModifiedState)) + ..add(DiagnosticsProperty('contact', contact)) + ..add(StringProperty('submitText', submitText)) + ..add(StringProperty('submitDisabledText', submitDisabledText)); + } + + static const String formFieldNickname = 'nickname'; + static const String formFieldNotes = 'notes'; + static const String formFieldShowAvailability = 'show_availability'; +} + +class _EditContactFormState extends State { + final _formKey = GlobalKey(); + + @override + void initState() { + _savedValue = ContactSpec.fromProto(widget.contact); + _currentValueNickname = _savedValue.nickname; + + super.initState(); + } + + ContactSpec _makeContactSpec() { + final nickname = _formKey.currentState! + .fields[EditContactForm.formFieldNickname]!.value as String; + final notes = _formKey + .currentState!.fields[EditContactForm.formFieldNotes]!.value as String; + final showAvailability = _formKey.currentState! + .fields[EditContactForm.formFieldShowAvailability]!.value as bool; + + return ContactSpec( + nickname: nickname, notes: notes, showAvailability: showAvailability); + } + + // Check if everything is the same and update state + void _onChanged() { + final currentValue = _makeContactSpec(); + _isModified = currentValue != _savedValue; + final onModifiedState = widget.onModifiedState; + if (onModifiedState != null) { + onModifiedState(_isModified); + } + } + + Widget _availabilityWidget(proto.Availability availability, Color color) => + AvailabilityWidget( + availability: availability, + color: color, + vertical: false, + ); + + Widget _editContactForm(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final textTheme = theme.textTheme; + + return FormBuilder( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + onChanged: _onChanged, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + styledCard( + context: context, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row(children: [ + const Spacer(), + StyledAvatar( + name: _currentValueNickname.isNotEmpty + ? _currentValueNickname + : widget.contact.profile.name, + size: 128) + .paddingLTRB(0, 0, 0, 16), + const Spacer() + ]), + SelectableText(widget.contact.profile.name, + style: textTheme.bodyLarge) + .noEditDecoratorLabel( + context, + translate('contact_form.form_name'), + ) + .paddingSymmetric(vertical: 4), + SelectableText(widget.contact.profile.pronouns, + style: textTheme.bodyLarge) + .noEditDecoratorLabel( + context, + translate('contact_form.form_pronouns'), + ) + .paddingSymmetric(vertical: 4), + Row(mainAxisSize: MainAxisSize.min, children: [ + _availabilityWidget( + widget.contact.profile.availability, + scale.primaryScale.appText), + SelectableText(widget.contact.profile.status, + style: textTheme.bodyMedium) + .paddingSymmetric(horizontal: 8) + ]) + .noEditDecoratorLabel( + context, + translate('contact_form.form_status'), + ) + .paddingSymmetric(vertical: 4), + SelectableText(widget.contact.profile.about, + minLines: 1, + maxLines: 8, + style: textTheme.bodyMedium) + .noEditDecoratorLabel( + context, + translate('contact_form.form_about'), + ) + .paddingSymmetric(vertical: 4), + SelectableText( + widget.contact.identityPublicKey.value + .toVeilid() + .toString(), + style: textTheme.bodyMedium! + .copyWith(fontFamily: 'Source Code Pro')) + .noEditDecoratorLabel( + context, + translate('contact_form.form_fingerprint'), + ) + .paddingSymmetric(vertical: 4), + ]).paddingAll(16)) + .paddingLTRB(0, 0, 0, 16), + FormBuilderTextField( + name: EditContactForm.formFieldNickname, + initialValue: _currentValueNickname, + onChanged: (x) { + setState(() { + _currentValueNickname = x ?? ''; + }); + }, + decoration: InputDecoration( + labelText: translate('contact_form.form_nickname')), + maxLength: 64, + textInputAction: TextInputAction.next, + ), + FormBuilderCheckbox( + name: EditContactForm.formFieldShowAvailability, + initialValue: _savedValue.showAvailability, + title: Text(translate('contact_form.form_show_availability'), + style: textTheme.labelMedium), + ), + FormBuilderTextField( + name: EditContactForm.formFieldNotes, + initialValue: _savedValue.notes, + minLines: 1, + maxLines: 8, + maxLength: 1024, + decoration: InputDecoration( + labelText: translate('contact_form.form_notes')), + textInputAction: TextInputAction.newline, + ), + ElevatedButton( + onPressed: _isModified ? _doSubmit : null, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.check, size: 24.scaled(context)) + .paddingLTRB(0, 0, 4, 0), + Text(widget.submitText).paddingLTRB(0, 0, 4.scaled(context), 0) + ]).paddingAll(4.scaled(context)), + ).paddingSymmetric(vertical: 4.scaled(context)).alignAtCenter(), + ], + ), + ); + } + + void _doSubmit() { + final onSubmit = widget.onSubmit; + if (_formKey.currentState?.saveAndValidate() ?? false) { + singleFuture((this, _kDoSubmitEditContact), () async { + final updatedContactSpec = _makeContactSpec(); + final saved = await onSubmit(updatedContactSpec); + if (saved) { + setState(() { + _savedValue = updatedContactSpec; + }); + _onChanged(); + } + }); + } + } + + @override + Widget build(BuildContext context) => _editContactForm(context); + + /////////////////////////////////////////////////////////////////////////// + late ContactSpec _savedValue; + late String _currentValueNickname; + bool _isModified = false; +} diff --git a/lib/contacts/views/empty_contact_list_widget.dart b/lib/contacts/views/empty_contact_list_widget.dart index db07b4a..e6912fd 100644 --- a/lib/contacts/views/empty_contact_list_widget.dart +++ b/lib/contacts/views/empty_contact_list_widget.dart @@ -17,15 +17,18 @@ class EmptyContactListWidget extends StatelessWidget { return Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.person_add_sharp, - color: scale.primaryScale.subtleBorder, - size: 48, - ), + // Icon( + // Icons.person_add_sharp, + // color: scale.primaryScale.subtleBorder, + // size: 48, + // ), Text( + textAlign: TextAlign.center, translate('contact_list.invite_people'), + //maxLines: 3, style: textTheme.bodyMedium?.copyWith( color: scale.primaryScale.subtleBorder, ), diff --git a/lib/contacts/views/no_contact_widget.dart b/lib/contacts/views/no_contact_widget.dart new file mode 100644 index 0000000..8edb5d5 --- /dev/null +++ b/lib/contacts/views/no_contact_widget.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../theme/models/scale_theme/scale_scheme.dart'; + +class NoContactWidget extends StatelessWidget { + const NoContactWidget({super.key}); + + @override + // ignore: prefer_expression_function_bodies + Widget build( + BuildContext context, + ) { + final theme = Theme.of(context); + final scale = theme.extension()!; + + return DecoratedBox( + decoration: BoxDecoration( + color: scale.primaryScale.appBackground, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.person, + color: scale.primaryScale.subtleBorder, + size: 48, + ), + Text( + textAlign: TextAlign.center, + translate('contacts_dialog.no_contact_selected'), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: scale.primaryScale.subtleBorder, + ), + ), + ], + ), + ); + } +} diff --git a/lib/contacts/views/views.dart b/lib/contacts/views/views.dart index 8c98b0f..b74aff3 100644 --- a/lib/contacts/views/views.dart +++ b/lib/contacts/views/views.dart @@ -1,3 +1,8 @@ +export 'availability_widget.dart'; +export 'contact_details_widget.dart'; export 'contact_item_widget.dart'; -export 'contact_list_widget.dart'; +export 'contacts_browser.dart'; +export 'contacts_page.dart'; +export 'edit_contact_form.dart'; export 'empty_contact_list_widget.dart'; +export 'no_contact_widget.dart'; diff --git a/lib/conversation/conversation.dart b/lib/conversation/conversation.dart new file mode 100644 index 0000000..d09042f --- /dev/null +++ b/lib/conversation/conversation.dart @@ -0,0 +1 @@ +export 'cubits/cubits.dart'; diff --git a/lib/conversation/cubits/active_conversations_bloc_map_cubit.dart b/lib/conversation/cubits/active_conversations_bloc_map_cubit.dart new file mode 100644 index 0000000..3c00eba --- /dev/null +++ b/lib/conversation/cubits/active_conversations_bloc_map_cubit.dart @@ -0,0 +1,178 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../chat_list/cubits/cubits.dart'; +import '../../contacts/contacts.dart'; +import '../../proto/proto.dart' as proto; +import '../conversation.dart'; + +@immutable +class ActiveConversationState extends Equatable { + const ActiveConversationState({ + required this.remoteIdentityPublicKey, + required this.localConversationRecordKey, + required this.remoteConversationRecordKey, + required this.localConversation, + required this.remoteConversation, + }); + + final TypedKey remoteIdentityPublicKey; + final TypedKey localConversationRecordKey; + final TypedKey remoteConversationRecordKey; + final proto.Conversation localConversation; + final proto.Conversation? remoteConversation; + + @override + List get props => [ + remoteIdentityPublicKey, + localConversationRecordKey, + remoteConversationRecordKey, + localConversation, + remoteConversation + ]; +} + +typedef ActiveConversationCubit = TransformerCubit< + AsyncValue, + AsyncValue, + ConversationCubit>; + +typedef ActiveConversationsBlocMapState + = BlocMapState>; + +// Map of localConversationRecordKey to ActiveConversationCubit +// Wraps a conversation cubit to only expose completely built conversations +// Automatically follows the state of a ChatListCubit. +// We currently only build the cubits for the chats that are active, not +// archived chats or contacts that are not actively in a chat. +// +// TODO(crioux): Polling contacts for new inactive chats is yet to be done +// +class ActiveConversationsBlocMapCubit extends BlocMapCubit, ActiveConversationCubit> + with StateMapFollower { + ActiveConversationsBlocMapCubit({ + required AccountInfo accountInfo, + required AccountRecordCubit accountRecordCubit, + required ChatListCubit chatListCubit, + required ContactListCubit contactListCubit, + }) : _accountInfo = accountInfo, + _accountRecordCubit = accountRecordCubit, + _contactListCubit = contactListCubit { + // Follow the chat list cubit + follow(chatListCubit); + } + + //////////////////////////////////////////////////////////////////////////// + // Public Interface + + //////////////////////////////////////////////////////////////////////////// + // Private Implementation + + // Add an active conversation to be tracked for changes + void _addDirectConversation( + {required TypedKey remoteIdentityPublicKey, + required TypedKey localConversationRecordKey, + required TypedKey remoteConversationRecordKey}) => + add(localConversationRecordKey, () { + // Conversation cubit the tracks the state between the local + // and remote halves of a contact's relationship with this account + final conversationCubit = ConversationCubit( + accountInfo: _accountInfo, + remoteIdentityPublicKey: remoteIdentityPublicKey, + localConversationRecordKey: localConversationRecordKey, + remoteConversationRecordKey: remoteConversationRecordKey, + ); + + // When remote conversation changes its profile, + // update our local contact + _contactListCubit.followContactProfileChanges( + localConversationRecordKey, + conversationCubit.stream.map((x) => x.map( + data: (d) => d.value.remoteConversation?.profile, + loading: (_) => null, + error: (_) => null)), + conversationCubit.state.asData?.value.remoteConversation?.profile); + + // When our local account profile changes, send it to the conversation + conversationCubit.watchAccountChanges( + _accountRecordCubit.stream, _accountRecordCubit.state); + + // Transformer that only passes through conversations where the local + // portion is not loading + // along with the contact that corresponds to the completed + // conversation + final transformedCubit = TransformerCubit< + AsyncValue, + AsyncValue, + ConversationCubit>(conversationCubit, + transform: (avstate) => avstate.when( + data: (data) => (data.localConversation == null) + ? const AsyncValue.loading() + : AsyncValue.data(ActiveConversationState( + localConversation: data.localConversation!, + remoteConversation: data.remoteConversation, + remoteIdentityPublicKey: remoteIdentityPublicKey, + localConversationRecordKey: localConversationRecordKey, + remoteConversationRecordKey: + remoteConversationRecordKey)), + loading: AsyncValue.loading, + error: AsyncValue.error)); + + return transformedCubit; + }); + + /// StateFollower ///////////////////////// + + @override + void removeFromState(TypedKey key) => remove(key); + + @override + void updateState(TypedKey key, proto.Chat? oldValue, proto.Chat newValue) { + switch (newValue.whichKind()) { + case proto.Chat_Kind.notSet: + throw StateError('unknown chat kind'); + case proto.Chat_Kind.direct: + final localConversationRecordKey = + newValue.direct.localConversationRecordKey.toVeilid(); + final remoteIdentityPublicKey = + newValue.direct.remoteMember.remoteIdentityPublicKey.toVeilid(); + final remoteConversationRecordKey = + newValue.direct.remoteMember.remoteConversationRecordKey.toVeilid(); + + if (oldValue != null) { + final oldLocalConversationRecordKey = + oldValue.direct.localConversationRecordKey.toVeilid(); + final oldRemoteIdentityPublicKey = + oldValue.direct.remoteMember.remoteIdentityPublicKey.toVeilid(); + final oldRemoteConversationRecordKey = oldValue + .direct.remoteMember.remoteConversationRecordKey + .toVeilid(); + + if (oldLocalConversationRecordKey == localConversationRecordKey && + oldRemoteIdentityPublicKey == remoteIdentityPublicKey && + oldRemoteConversationRecordKey == remoteConversationRecordKey) { + return; + } + } + + _addDirectConversation( + remoteIdentityPublicKey: remoteIdentityPublicKey, + localConversationRecordKey: localConversationRecordKey, + remoteConversationRecordKey: remoteConversationRecordKey); + + case proto.Chat_Kind.group: + break; + } + } + + //// + + final AccountInfo _accountInfo; + final AccountRecordCubit _accountRecordCubit; + final ContactListCubit _contactListCubit; +} diff --git a/lib/conversation/cubits/active_single_contact_chat_bloc_map_cubit.dart b/lib/conversation/cubits/active_single_contact_chat_bloc_map_cubit.dart new file mode 100644 index 0000000..68e1a58 --- /dev/null +++ b/lib/conversation/cubits/active_single_contact_chat_bloc_map_cubit.dart @@ -0,0 +1,113 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../chat/chat.dart'; +import '../../proto/proto.dart' as proto; +import '../conversation.dart'; +import 'active_conversations_bloc_map_cubit.dart'; + +@immutable +class _SingleContactChatState extends Equatable { + const _SingleContactChatState( + {required this.remoteIdentityPublicKey, + required this.localConversationRecordKey, + required this.remoteConversationRecordKey, + required this.localMessagesRecordKey, + required this.remoteMessagesRecordKey}); + + final TypedKey remoteIdentityPublicKey; + final TypedKey localConversationRecordKey; + final TypedKey remoteConversationRecordKey; + final TypedKey localMessagesRecordKey; + final TypedKey? remoteMessagesRecordKey; + + @override + List get props => [ + remoteIdentityPublicKey, + localConversationRecordKey, + remoteConversationRecordKey, + localMessagesRecordKey, + remoteMessagesRecordKey + ]; +} + +// Map of localConversationRecordKey to SingleContactMessagesCubit +// Wraps a SingleContactMessagesCubit to stream the latest messages to the state +// Automatically follows the state of a ActiveConversationsBlocMapCubit. +class ActiveSingleContactChatBlocMapCubit extends BlocMapCubit + with + StateMapFollower> { + ActiveSingleContactChatBlocMapCubit({ + required AccountInfo accountInfo, + required ActiveConversationsBlocMapCubit activeConversationsBlocMapCubit, + }) : _accountInfo = accountInfo { + // Follow the active conversations bloc map cubit + follow(activeConversationsBlocMapCubit); + } + + void _addConversationMessages(_SingleContactChatState state) { + update(state.localConversationRecordKey, + onUpdate: (cubit) => + cubit.updateRemoteMessagesRecordKey(state.remoteMessagesRecordKey), + onCreate: () => SingleContactMessagesCubit( + accountInfo: _accountInfo, + remoteIdentityPublicKey: state.remoteIdentityPublicKey, + localConversationRecordKey: state.localConversationRecordKey, + remoteConversationRecordKey: state.remoteConversationRecordKey, + localMessagesRecordKey: state.localMessagesRecordKey, + remoteMessagesRecordKey: state.remoteMessagesRecordKey, + )); + } + + _SingleContactChatState? _mapStateValue( + AsyncValue avInputState) { + final inputState = avInputState.asData?.value; + if (inputState == null) { + return null; + } + return _SingleContactChatState( + remoteIdentityPublicKey: inputState.remoteIdentityPublicKey, + localConversationRecordKey: inputState.localConversationRecordKey, + remoteConversationRecordKey: inputState.remoteConversationRecordKey, + localMessagesRecordKey: + inputState.localConversation.messages.toVeilid(), + remoteMessagesRecordKey: + inputState.remoteConversation?.messages.toVeilid()); + } + + /// StateFollower ///////////////////////// + + @override + void removeFromState(TypedKey key) => remove(key); + + @override + void updateState(TypedKey key, AsyncValue? oldValue, + AsyncValue newValue) { + final newState = _mapStateValue(newValue); + if (oldValue != null) { + final oldState = _mapStateValue(oldValue); + if (oldState == newState) { + return; + } + } + if (newState != null) { + _addConversationMessages(newState); + } else if (newValue.isLoading) { + addState(key, const AsyncValue.loading()); + } else { + final (error, stackTrace) = + (newValue.asError!.error, newValue.asError!.stackTrace); + addError(error, stackTrace); + addState(key, AsyncValue.error(error, stackTrace)); + } + } + + //// + final AccountInfo _accountInfo; +} diff --git a/lib/contacts/cubits/conversation_cubit.dart b/lib/conversation/cubits/conversation_cubit.dart similarity index 52% rename from lib/contacts/cubits/conversation_cubit.dart rename to lib/conversation/cubits/conversation_cubit.dart index 115ec84..329cddd 100644 --- a/lib/contacts/cubits/conversation_cubit.dart +++ b/lib/conversation/cubits/conversation_cubit.dart @@ -9,11 +9,13 @@ import 'package:async_tools/async_tools.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:meta/meta.dart'; +import 'package:protobuf/protobuf.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../account_manager/account_manager.dart'; import '../../proto/proto.dart' as proto; -import '../../tools/tools.dart'; + +const _sfUpdateAccountChange = 'updateAccountChange'; @immutable class ConversationState extends Equatable { @@ -25,52 +27,61 @@ class ConversationState extends Equatable { @override List get props => [localConversation, remoteConversation]; + + @override + String toString() => 'ConversationState(' + 'localConversation: ${DynamicDebug.toDebug(localConversation)}, ' + 'remoteConversation: ${DynamicDebug.toDebug(remoteConversation)}' + ')'; } +/// Represents the control channel between two contacts +/// Used to pass profile, identity and status changes, and the messages key for +/// 1-1 chats class ConversationCubit extends Cubit> { ConversationCubit( - {required ActiveAccountInfo activeAccountInfo, + {required AccountInfo accountInfo, required TypedKey remoteIdentityPublicKey, TypedKey? localConversationRecordKey, TypedKey? remoteConversationRecordKey}) - : _activeAccountInfo = activeAccountInfo, - _localConversationRecordKey = localConversationRecordKey, + : _accountInfo = accountInfo, _remoteIdentityPublicKey = remoteIdentityPublicKey, - _remoteConversationRecordKey = remoteConversationRecordKey, super(const AsyncValue.loading()) { - if (_localConversationRecordKey != null) { - _initWait.add(() async { - await _setLocalConversation(() async { - final accountRecordKey = _activeAccountInfo - .userLogin.accountRecordInfo.accountRecord.recordKey; + _identityWriter = _accountInfo.identityWriter; + if (localConversationRecordKey != null) { + _initWait.add((_) async { + await _setLocalConversation(() async { // Open local record key if it is specified final pool = DHTRecordPool.instance; final crypto = await _cachedConversationCrypto(); - final writer = _activeAccountInfo.identityWriter; + final writer = _identityWriter; + final record = await pool.openRecordWrite( - _localConversationRecordKey!, writer, + localConversationRecordKey, writer, debugName: 'ConversationCubit::LocalConversation', - parent: accountRecordKey, + parent: accountInfo.accountRecordKey, crypto: crypto); + return record; }); }); } - if (_remoteConversationRecordKey != null) { - _initWait.add(() async { + if (remoteConversationRecordKey != null) { + _initWait.add((cancel) async { await _setRemoteConversation(() async { - final accountRecordKey = _activeAccountInfo - .userLogin.accountRecordInfo.accountRecord.recordKey; - // Open remote record key if it is specified final pool = DHTRecordPool.instance; final crypto = await _cachedConversationCrypto(); - final record = await pool.openRecordRead(_remoteConversationRecordKey, + + final record = await pool.openRecordRead(remoteConversationRecordKey, debugName: 'ConversationCubit::RemoteConversation', - parent: accountRecordKey, + parent: + await pool.getParentRecordKey(remoteConversationRecordKey) ?? + accountInfo.accountRecordKey, crypto: crypto); + return record; }); }); @@ -80,6 +91,7 @@ class ConversationCubit extends Cubit> { @override Future close() async { await _initWait(); + await _accountSubscription?.cancel(); await _localSubscription?.cancel(); await _remoteSubscription?.cancel(); await _localConversationCubit?.close(); @@ -88,142 +100,28 @@ class ConversationCubit extends Cubit> { await super.close(); } - void _updateLocalConversationState(AsyncValue avconv) { - final newState = avconv.when( - data: (conv) { - _incrementalState = ConversationState( - localConversation: conv, - remoteConversation: _incrementalState.remoteConversation); - // return loading still if state isn't complete - if ((_localConversationRecordKey != null && - _incrementalState.localConversation == null) || - (_remoteConversationRecordKey != null && - _incrementalState.remoteConversation == null)) { - return const AsyncValue.loading(); - } - // state is complete, all required keys are open - return AsyncValue.data(_incrementalState); - }, - loading: AsyncValue.loading, - error: AsyncValue.error, - ); - emit(newState); - } + //////////////////////////////////////////////////////////////////////////// + // Public Interface - void _updateRemoteConversationState(AsyncValue avconv) { - final newState = avconv.when( - data: (conv) { - _incrementalState = ConversationState( - localConversation: _incrementalState.localConversation, - remoteConversation: conv); - // return loading still if state isn't complete - if ((_localConversationRecordKey != null && - _incrementalState.localConversation == null) || - (_remoteConversationRecordKey != null && - _incrementalState.remoteConversation == null)) { - return const AsyncValue.loading(); - } - // state is complete, all required keys are open - return AsyncValue.data(_incrementalState); - }, - loading: AsyncValue.loading, - error: AsyncValue.error, - ); - emit(newState); - } - - // Open local converation key - Future _setLocalConversation(Future Function() open) async { - assert(_localConversationCubit == null, - 'shoud not set local conversation twice'); - _localConversationCubit = DefaultDHTRecordCubit( - open: open, decodeState: proto.Conversation.fromBuffer); - _localSubscription = - _localConversationCubit!.stream.listen(_updateLocalConversationState); - } - - // Open remote converation key - Future _setRemoteConversation(Future Function() open) async { - assert(_remoteConversationCubit == null, - 'shoud not set remote conversation twice'); - _remoteConversationCubit = DefaultDHTRecordCubit( - open: open, decodeState: proto.Conversation.fromBuffer); - _remoteSubscription = - _remoteConversationCubit!.stream.listen(_updateRemoteConversationState); - } - - Future delete() async { - final pool = DHTRecordPool.instance; - - await _initWait(); - final localConversationCubit = _localConversationCubit; - final remoteConversationCubit = _remoteConversationCubit; - - final deleteSet = DelayedWaitSet(); - - if (localConversationCubit != null) { - final data = localConversationCubit.state.asData; - if (data == null) { - log.warning('could not delete local conversation'); - return false; - } - - deleteSet.add(() async { - _localConversationCubit = null; - await localConversationCubit.close(); - final conversation = data.value; - final messagesKey = conversation.messages.toVeilid(); - await pool.deleteRecord(messagesKey); - await pool.deleteRecord(_localConversationRecordKey!); - _localConversationRecordKey = null; - }); - } - - if (remoteConversationCubit != null) { - final data = remoteConversationCubit.state.asData; - if (data == null) { - log.warning('could not delete remote conversation'); - return false; - } - - deleteSet.add(() async { - _remoteConversationCubit = null; - await remoteConversationCubit.close(); - final conversation = data.value; - final messagesKey = conversation.messages.toVeilid(); - await pool.deleteRecord(messagesKey); - await pool.deleteRecord(_remoteConversationRecordKey!); - }); - } - - // Commit the delete futures - await deleteSet(); - - return true; - } - - // Initialize a local conversation - // If we were the initiator of the conversation there may be an - // incomplete 'existingConversationRecord' that we need to fill - // in now that we have the remote identity key - // The ConversationCubit must not already have a local conversation - // The callback allows for more initialization to occur and for - // cleanup to delete records upon failure of the callback - Future initLocalConversation( + /// Initialize a local conversation + /// If we were the initiator of the conversation there may be an + /// incomplete 'existingConversationRecord' that we need to fill + /// in now that we have the remote identity key + /// The ConversationCubit must not already have a local conversation + /// Returns the local conversation record key that was initialized + Future initLocalConversation( {required proto.Profile profile, - required FutureOr Function(DHTRecord) callback, TypedKey? existingConversationRecordKey}) async { - assert(_localConversationRecordKey == null, + assert(_localConversationCubit == null, 'must not have a local conversation yet'); final pool = DHTRecordPool.instance; - final accountRecordKey = - _activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; final crypto = await _cachedConversationCrypto(); - final writer = _activeAccountInfo.identityWriter; + final accountRecordKey = _accountInfo.accountRecordKey; + final writer = _accountInfo.identityWriter; - // Open with SMPL scheme for identity writer + // Open with SMPL schema for identity writer late final DHTRecord localConversationRecord; if (existingConversationRecordKey != null) { localConversationRecord = await pool.openRecordWrite( @@ -242,20 +140,15 @@ class ConversationCubit extends Cubit> { schema: DHTSchema.smpl( oCnt: 0, members: [DHTSchemaMember(mKey: writer.key, mCnt: 1)])); } - final out = localConversationRecord - // ignore: prefer_expression_function_bodies - .deleteScope((localConversation) async { - // Make messages log - return _initLocalMessages( - activeAccountInfo: _activeAccountInfo, - remoteIdentityPublicKey: _remoteIdentityPublicKey, + await localConversationRecord.deleteScope((localConversation) async { + await _initLocalMessages( localConversationKey: localConversation.key, callback: (messages) async { // Create initial local conversation key contents final conversation = proto.Conversation() ..profile = profile - ..superIdentityJson = jsonEncode( - _activeAccountInfo.localAccount.superIdentity.toJson()) + ..superIdentityJson = + jsonEncode(_accountInfo.localAccount.superIdentity.toJson()) ..messages = messages.recordKey.toProto(); // Write initial conversation to record @@ -264,32 +157,126 @@ class ConversationCubit extends Cubit> { if (update != null) { throw Exception('Failed to write local conversation'); } - final out = await callback(localConversation); - // Upon success emit the local conversation record to the state - _updateLocalConversationState(AsyncValue.data(conversation)); - - return out; + // If success, save the new local conversation + // record key in this object + localConversation.ref(); + await _setLocalConversation(() async => localConversation); }); }); - // If success, save the new local conversation record key in this object - _localConversationRecordKey = localConversationRecord.key; - await _setLocalConversation(() async => localConversationRecord); + return localConversationRecord.key; + } - return out; + /// Force refresh of conversation keys + // Future refresh() async { + // await _initWait(); + + // final lcc = _localConversationCubit; + // final rcc = _remoteConversationCubit; + + // if (lcc != null) { + // await lcc.refreshDefault(); + // } + // if (rcc != null) { + // await rcc.refreshDefault(); + // } + // } + + /// Watch for account record changes and update the conversation + void watchAccountChanges(Stream> accountStream, + AsyncValue currentState) { + assert(_accountSubscription == null, 'only watch account once'); + _accountSubscription = accountStream.listen(_updateAccountChange); + _updateAccountChange(currentState); + } + + //////////////////////////////////////////////////////////////////////////// + // Private Implementation + + void _updateAccountChange(AsyncValue avaccount) { + final account = avaccount.asData?.value; + if (account == null) { + return; + } + final cubit = _localConversationCubit; + if (cubit == null) { + return; + } + serialFuture((this, _sfUpdateAccountChange), () async { + await cubit.record?.eventualUpdateProtobuf(proto.Conversation.fromBuffer, + (old) async { + if (old == null || old.profile == account.profile) { + return null; + } + return old.deepCopy()..profile = account.profile; + }); + }); + } + + void _updateLocalConversationState(AsyncValue avconv) { + final newState = avconv.when( + data: (conv) { + _incrementalState = ConversationState( + localConversation: conv, + remoteConversation: _incrementalState.remoteConversation); + return AsyncValue.data(_incrementalState); + }, + loading: AsyncValue.loading, + error: AsyncValue.error, + ); + emit(newState); + } + + void _updateRemoteConversationState(AsyncValue avconv) { + final newState = avconv.when( + data: (conv) { + _incrementalState = ConversationState( + localConversation: _incrementalState.localConversation, + remoteConversation: conv); + return AsyncValue.data(_incrementalState); + }, + loading: AsyncValue.loading, + error: AsyncValue.error, + ); + emit(newState); + } + + // Open local converation key + Future _setLocalConversation(Future Function() open) async { + assert(_localConversationCubit == null, + 'should not set local conversation twice'); + _localConversationCubit = DefaultDHTRecordCubit( + open: open, decodeState: proto.Conversation.fromBuffer); + + await _localConversationCubit!.ready(); + + _localSubscription = + _localConversationCubit!.stream.listen(_updateLocalConversationState); + _updateLocalConversationState(_localConversationCubit!.state); + } + + // Open remote converation key + Future _setRemoteConversation(Future Function() open) async { + assert(_remoteConversationCubit == null, + 'should not set remote conversation twice'); + _remoteConversationCubit = DefaultDHTRecordCubit( + open: open, decodeState: proto.Conversation.fromBuffer); + + await _remoteConversationCubit!.ready(); + + _remoteSubscription = + _remoteConversationCubit!.stream.listen(_updateRemoteConversationState); + _updateRemoteConversationState(_remoteConversationCubit!.state); } // Initialize local messages Future _initLocalMessages({ - required ActiveAccountInfo activeAccountInfo, - required TypedKey remoteIdentityPublicKey, required TypedKey localConversationKey, required FutureOr Function(DHTLog) callback, }) async { - final crypto = - await activeAccountInfo.makeConversationCrypto(remoteIdentityPublicKey); - final writer = activeAccountInfo.identityWriter; + final crypto = await _cachedConversationCrypto(); + final writer = _identityWriter; return (await DHTLog.create( debugName: 'ConversationCubit::initLocalMessages::LocalMessages', @@ -299,57 +286,31 @@ class ConversationCubit extends Cubit> { .deleteScope((messages) async => await callback(messages)); } - // Force refresh of conversation keys - Future refresh() async { - await _initWait(); - - final lcc = _localConversationCubit; - final rcc = _remoteConversationCubit; - - if (lcc != null) { - await lcc.refreshDefault(); - } - if (rcc != null) { - await rcc.refreshDefault(); - } - } - - Future writeLocalConversation({ - required proto.Conversation conversation, - }) async { - final update = await _localConversationCubit!.record - .tryWriteProtobuf(proto.Conversation.fromBuffer, conversation); - - if (update != null) { - _updateLocalConversationState(AsyncValue.data(conversation)); - } - - return update; - } - Future _cachedConversationCrypto() async { var conversationCrypto = _conversationCrypto; if (conversationCrypto != null) { return conversationCrypto; } - conversationCrypto = await _activeAccountInfo - .makeConversationCrypto(_remoteIdentityPublicKey); - + conversationCrypto = + await _accountInfo.makeConversationCrypto(_remoteIdentityPublicKey); _conversationCrypto = conversationCrypto; return conversationCrypto; } - final ActiveAccountInfo _activeAccountInfo; + //////////////////////////////////////////////////////////////////////////// + // Fields + TypedKey get remoteIdentityPublicKey => _remoteIdentityPublicKey; + + final AccountInfo _accountInfo; + late final KeyPair _identityWriter; final TypedKey _remoteIdentityPublicKey; - TypedKey? _localConversationRecordKey; - final TypedKey? _remoteConversationRecordKey; DefaultDHTRecordCubit? _localConversationCubit; DefaultDHTRecordCubit? _remoteConversationCubit; StreamSubscription>? _localSubscription; StreamSubscription>? _remoteSubscription; - ConversationState _incrementalState = const ConversationState( + StreamSubscription>? _accountSubscription; + var _incrementalState = const ConversationState( localConversation: null, remoteConversation: null); - // VeilidCrypto? _conversationCrypto; - final WaitSet _initWait = WaitSet(); + final WaitSet _initWait = WaitSet(); } diff --git a/lib/conversation/cubits/cubits.dart b/lib/conversation/cubits/cubits.dart new file mode 100644 index 0000000..029764f --- /dev/null +++ b/lib/conversation/cubits/cubits.dart @@ -0,0 +1,3 @@ +export 'active_conversations_bloc_map_cubit.dart'; +export 'active_single_contact_chat_bloc_map_cubit.dart'; +export 'conversation_cubit.dart'; diff --git a/lib/init.dart b/lib/init.dart index cd01f97..958cd08 100644 --- a/lib/init.dart +++ b/lib/init.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:veilid_support/veilid_support.dart'; import 'account_manager/account_manager.dart'; @@ -8,17 +9,23 @@ import 'app.dart'; import 'tools/tools.dart'; import 'veilid_processor/veilid_processor.dart'; +List rootAssets = []; + class VeilidChatGlobalInit { VeilidChatGlobalInit._(); // Initialize Veilid Future _initializeVeilid() async { // Init Veilid - Veilid.instance.initializeVeilidCore( - getDefaultVeilidPlatformConfig(kIsWeb, VeilidChatApp.name)); + try { + Veilid.instance.initializeVeilidCore( + await getDefaultVeilidPlatformConfig(kIsWeb, VeilidChatApp.name)); + } on VeilidAPIExceptionAlreadyInitialized { + log.debug('Already initialized, not reinitializing veilid-core'); + } // Veilid logging - initVeilidLog(kDebugMode); + initVeilidLog(kIsDebugMode); // Startup Veilid await ProcessorRepository.instance.startup(); @@ -28,14 +35,22 @@ class VeilidChatGlobalInit { logger: (message) => log.debug('DHTRecordPool: $message')); } -// Initialize repositories + // Initialize repositories Future _initializeRepositories() async { await AccountRepository.instance.init(); } + // Initialize asset manifest + static Future loadAssetManifest() async { + final assetManifest = await AssetManifest.loadFromAssetBundle(rootBundle); + rootAssets = assetManifest.listAssets(); + } + static Future initialize() async { final veilidChatGlobalInit = VeilidChatGlobalInit._(); + await loadAssetManifest(); + log.info('Initializing Veilid'); await veilidChatGlobalInit._initializeVeilid(); log.info('Initializing Repositories'); diff --git a/lib/keyboard_shortcuts.dart b/lib/keyboard_shortcuts.dart new file mode 100644 index 0000000..7b952c8 --- /dev/null +++ b/lib/keyboard_shortcuts.dart @@ -0,0 +1,271 @@ +import 'package:animated_theme_switcher/animated_theme_switcher.dart'; +import 'package:async_tools/async_tools.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import 'init.dart'; +import 'router/router.dart'; +import 'settings/settings.dart'; +import 'theme/theme.dart'; +import 'tools/tools.dart'; +import 'veilid_processor/veilid_processor.dart'; + +class ReloadThemeIntent extends Intent { + const ReloadThemeIntent(); +} + +class ChangeBrightnessIntent extends Intent { + const ChangeBrightnessIntent(); +} + +class ChangeColorIntent extends Intent { + const ChangeColorIntent(); +} + +class AttachDetachIntent extends Intent { + const AttachDetachIntent(); +} + +class DeveloperPageIntent extends Intent { + const DeveloperPageIntent(); +} + +class DisplayScaleUpIntent extends Intent { + const DisplayScaleUpIntent(); +} + +class DisplayScaleDownIntent extends Intent { + const DisplayScaleDownIntent(); +} + +class KeyboardShortcuts extends StatelessWidget { + const KeyboardShortcuts({required this.child, super.key}); + + void reloadTheme(BuildContext context) { + singleFuture(this, () async { + log.info('Reloading theme'); + + await VeilidChatGlobalInit.loadAssetManifest(); + + final theme = + PreferencesRepository.instance.value.themePreference.themeData(); + if (context.mounted) { + ThemeSwitcher.of(context).changeTheme(theme: theme); + + // Hack to reload translations + final localizationDelegate = LocalizedApp.of(context).delegate; + await LocalizationDelegate.create( + fallbackLocale: localizationDelegate.fallbackLocale.toString(), + supportedLocales: localizationDelegate.supportedLocales + .map((x) => x.toString()) + .toList()); + } + }); + } + + void _changeBrightness(BuildContext context) { + singleFuture(this, () async { + final prefs = PreferencesRepository.instance.value; + + final oldBrightness = prefs.themePreference.brightnessPreference; + final newBrightness = BrightnessPreference.values[ + (oldBrightness.index + 1) % BrightnessPreference.values.length]; + + log.info('Changing brightness to $newBrightness'); + + final newPrefs = prefs.copyWith( + themePreference: prefs.themePreference + .copyWith(brightnessPreference: newBrightness)); + await PreferencesRepository.instance.set(newPrefs); + + if (context.mounted) { + ThemeSwitcher.of(context) + .changeTheme(theme: newPrefs.themePreference.themeData()); + } + }); + } + + void _changeColor(BuildContext context) { + singleFuture(this, () async { + final prefs = PreferencesRepository.instance.value; + final oldColor = prefs.themePreference.colorPreference; + final newColor = ColorPreference + .values[(oldColor.index + 1) % ColorPreference.values.length]; + + log.info('Changing color to $newColor'); + + final newPrefs = prefs.copyWith( + themePreference: + prefs.themePreference.copyWith(colorPreference: newColor)); + await PreferencesRepository.instance.set(newPrefs); + + if (context.mounted) { + ThemeSwitcher.of(context) + .changeTheme(theme: newPrefs.themePreference.themeData()); + } + }); + } + + void _displayScaleUp(BuildContext context) { + singleFuture(this, () async { + final prefs = PreferencesRepository.instance.value; + final oldIndex = displayScaleToIndex(prefs.themePreference.displayScale); + if (oldIndex == maxDisplayScaleIndex) { + return; + } + final newIndex = oldIndex + 1; + final newDisplayScaleName = indexToDisplayScaleName(newIndex); + + log.info('Changing display scale to $newDisplayScaleName'); + + final newPrefs = prefs.copyWith( + themePreference: prefs.themePreference + .copyWith(displayScale: indexToDisplayScale(newIndex))); + await PreferencesRepository.instance.set(newPrefs); + + if (context.mounted) { + ThemeSwitcher.of(context) + .changeTheme(theme: newPrefs.themePreference.themeData()); + } + }); + } + + void _displayScaleDown(BuildContext context) { + singleFuture(this, () async { + final prefs = PreferencesRepository.instance.value; + final oldIndex = displayScaleToIndex(prefs.themePreference.displayScale); + if (oldIndex == 0) { + return; + } + final newIndex = oldIndex - 1; + final newDisplayScaleName = indexToDisplayScaleName(newIndex); + + log.info('Changing display scale to $newDisplayScaleName'); + + final newPrefs = prefs.copyWith( + themePreference: prefs.themePreference + .copyWith(displayScale: indexToDisplayScale(newIndex))); + await PreferencesRepository.instance.set(newPrefs); + + if (context.mounted) { + ThemeSwitcher.of(context) + .changeTheme(theme: newPrefs.themePreference.themeData()); + } + }); + } + + void _attachDetach(BuildContext context) { + singleFuture(this, () async { + if (ProcessorRepository.instance.processorConnectionState.isAttached) { + log.info('Detaching'); + await Veilid.instance.detach(); + } else if (ProcessorRepository + .instance.processorConnectionState.isDetached) { + log.info('Attaching'); + await Veilid.instance.attach(); + } + }); + } + + void _developerPage(BuildContext context) { + singleFuture(this, () async { + final path = GoRouter.of(context).location(); + if (path != '/developer') { + await GoRouterHelper(context).push('/developer'); + } + }); + } + + @override + Widget build(BuildContext context) => ThemeSwitcher( + builder: (context) => Shortcuts( + shortcuts: { + ////////////////////////// Reload Theme + const SingleActivator( + LogicalKeyboardKey.keyR, + control: true, + alt: true, + ): const ReloadThemeIntent(), + ////////////////////////// Switch Brightness + const SingleActivator( + LogicalKeyboardKey.keyB, + control: true, + alt: true, + ): const ChangeBrightnessIntent(), + ////////////////////////// Change Color + const SingleActivator( + LogicalKeyboardKey.keyC, + control: true, + alt: true, + ): const ChangeColorIntent(), + ////////////////////////// Attach/Detach + if (kIsDebugMode) + const SingleActivator( + LogicalKeyboardKey.keyA, + control: true, + alt: true, + ): const AttachDetachIntent(), + ////////////////////////// Show Developer Page + const SingleActivator( + LogicalKeyboardKey.keyD, + control: true, + alt: true, + ): const DeveloperPageIntent(), + ////////////////////////// Display Scale Up + SingleActivator( + LogicalKeyboardKey.equal, + meta: isMac || isiOS, + control: !(isMac || isiOS), + ): const DisplayScaleUpIntent(), + SingleActivator( + LogicalKeyboardKey.equal, + shift: true, + meta: isMac || isiOS, + control: !(isMac || isiOS), + ): const DisplayScaleUpIntent(), + SingleActivator( + LogicalKeyboardKey.add, + shift: true, + meta: isMac || isiOS, + control: !(isMac || isiOS), + ): const DisplayScaleUpIntent(), + SingleActivator( + LogicalKeyboardKey.numpadAdd, + meta: isMac || isiOS, + control: !(isMac || isiOS), + ): const DisplayScaleUpIntent(), + ////////////////////////// Display Scale Down + SingleActivator( + LogicalKeyboardKey.minus, + meta: isMac || isiOS, + control: !(isMac || isiOS), + ): const DisplayScaleDownIntent(), + SingleActivator( + LogicalKeyboardKey.numpadSubtract, + meta: isMac || isiOS, + control: !(isMac || isiOS), + ): const DisplayScaleDownIntent(), + }, + child: Actions(actions: >{ + ReloadThemeIntent: CallbackAction( + onInvoke: (intent) => reloadTheme(context)), + ChangeBrightnessIntent: CallbackAction( + onInvoke: (intent) => _changeBrightness(context)), + ChangeColorIntent: CallbackAction( + onInvoke: (intent) => _changeColor(context)), + AttachDetachIntent: CallbackAction( + onInvoke: (intent) => _attachDetach(context)), + DeveloperPageIntent: CallbackAction( + onInvoke: (intent) => _developerPage(context)), + DisplayScaleUpIntent: CallbackAction( + onInvoke: (intent) => _displayScaleUp(context)), + DisplayScaleDownIntent: CallbackAction( + onInvoke: (intent) => _displayScaleDown(context)), + }, child: Focus(autofocus: true, child: child)))); + + ///////////////////////////////////////////////////////// + + final Widget child; +} diff --git a/lib/layout/default_app_bar.dart b/lib/layout/default_app_bar.dart index 41e3601..d13fec9 100644 --- a/lib/layout/default_app_bar.dart +++ b/lib/layout/default_app_bar.dart @@ -2,17 +2,27 @@ import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import '../theme/theme.dart'; + class DefaultAppBar extends AppBar { DefaultAppBar( - {required super.title, super.key, Widget? leading, super.actions}) + {required BuildContext context, + super.title, + super.flexibleSpace, + super.key, + Widget? leading, + super.actions}) : super( + toolbarHeight: 48.scaled(context), + leadingWidth: 40.scaled(context), leading: leading ?? Container( - margin: const EdgeInsets.all(4), + margin: const EdgeInsets.all(4).scaled(context), decoration: BoxDecoration( color: Colors.black.withAlpha(32), shape: BoxShape.circle), - child: - SvgPicture.asset('assets/images/vlogo.svg', height: 32) - .paddingAll(4))); + child: SvgPicture.asset('assets/images/vlogo.svg', + width: 24.scaled(context), + height: 24.scaled(context)) + .paddingAll(4.scaled(context)))); } diff --git a/lib/layout/home/drawer_menu/drawer_menu.dart b/lib/layout/home/drawer_menu/drawer_menu.dart new file mode 100644 index 0000000..33f2bc2 --- /dev/null +++ b/lib/layout/home/drawer_menu/drawer_menu.dart @@ -0,0 +1,422 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:go_router/go_router.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../../account_manager/account_manager.dart'; +import '../../../keyboard_shortcuts.dart'; +import '../../../theme/theme.dart'; +import '../../../tools/tools.dart'; +import '../../../veilid_processor/veilid_processor.dart'; +import 'menu_item_widget.dart'; + +const _scaleKind = ScaleKind.secondary; + +class DrawerMenu extends StatefulWidget { + const DrawerMenu({super.key}); + + @override + State createState() => _DrawerMenuState(); +} + +class _DrawerMenuState extends State { + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + void _doSwitchClick(TypedKey superIdentityRecordKey) { + singleFuture(this, () async { + await AccountRepository.instance.switchToAccount(superIdentityRecordKey); + }); + } + + void _doEditClick(TypedKey superIdentityRecordKey, + AccountSpec existingAccount, OwnedDHTRecordPointer accountRecord) { + singleFuture(this, () async { + await GoRouterHelper(context).push('/edit_account', + extra: [superIdentityRecordKey, existingAccount, accountRecord]); + }); + } + + Widget _wrapInBox( + {required Widget child, + required Color color, + required double borderRadius}) => + DecoratedBox( + decoration: ShapeDecoration( + color: color, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(borderRadius))), + child: child); + + Widget _makeAccountWidget( + {required String name, + required bool selected, + required ScaleColor scale, + required ScaleConfig scaleConfig, + required bool loggedIn, + required void Function()? callback, + required void Function()? footerCallback}) { + final theme = Theme.of(context); + + late final Color background; + late final Color hoverBackground; + late final Color activeBackground; + late final Color border; + late final Color hoverBorder; + late final Color activeBorder; + if (scaleConfig.useVisualIndicators && !scaleConfig.preferBorders) { + background = loggedIn ? scale.border : scale.subtleBorder; + hoverBackground = background; + activeBackground = background; + border = + selected ? scale.activeElementBackground : scale.elementBackground; + hoverBorder = border; + activeBorder = border; + } else { + background = selected + ? scale.elementBackground + : scale.elementBackground.withAlpha(128); + hoverBackground = scale.hoverElementBackground; + activeBackground = scale.activeElementBackground; + border = loggedIn ? scale.border : scale.subtleBorder; + hoverBorder = scale.hoverBorder; + activeBorder = scale.primary; + } + + final avatar = StyledAvatar( + name: name, + size: 34.scaled(context), + ); + + return AnimatedPadding( + padding: EdgeInsets.fromLTRB(selected ? 0 : 8, selected ? 0 : 2, + selected ? 0 : 8, selected ? 0 : 2) + .scaled(context), + duration: const Duration(milliseconds: 50), + child: MenuItemWidget( + title: name, + headerWidget: avatar, + titleStyle: theme.textTheme.titleSmall! + .copyWith(color: scaleConfig.useVisualIndicators ? border : null), + foregroundColor: scale.primary, + backgroundColor: background, + backgroundHoverColor: hoverBackground, + backgroundFocusColor: activeBackground, + borderColor: + (scaleConfig.preferBorders || scaleConfig.useVisualIndicators) + ? border + : null, + borderHoverColor: + (scaleConfig.preferBorders || scaleConfig.useVisualIndicators) + ? hoverBorder + : null, + borderFocusColor: + (scaleConfig.preferBorders || scaleConfig.useVisualIndicators) + ? activeBorder + : null, + borderRadius: 12 * scaleConfig.borderRadiusScale, + callback: callback, + footerButtonIcon: loggedIn ? Icons.edit_outlined : null, + footerCallback: footerCallback, + footerButtonIconColor: + scaleConfig.preferBorders ? scale.border : scale.borderText, + footerButtonIconHoverColor: + (scaleConfig.preferBorders || scaleConfig.useVisualIndicators) + ? null + : hoverBorder, + footerButtonIconFocusColor: + (scaleConfig.preferBorders || scaleConfig.useVisualIndicators) + ? null + : activeBorder, + minHeight: 48.scaled(context), + )); + } + + List _getAccountList( + {required IList localAccounts, + required TypedKey? activeLocalAccount, + required PerAccountCollectionBlocMapState + perAccountCollectionBlocMapState}) { + final theme = Theme.of(context); + final scaleScheme = theme.extension()!; + final scaleConfig = theme.extension()!; + final scale = scaleScheme.scale(_scaleKind); + + final loggedInAccounts = []; + final loggedOutAccounts = []; + + for (final la in localAccounts) { + final superIdentityRecordKey = la.superIdentity.recordKey; + + // See if this account is logged in + final perAccountState = + perAccountCollectionBlocMapState.get(superIdentityRecordKey); + final avAccountRecordState = perAccountState?.avAccountRecordState; + if (perAccountState != null && avAccountRecordState != null) { + // Account is logged in + final loggedInAccount = avAccountRecordState.when( + data: (value) => _makeAccountWidget( + name: value.profile.name, + scale: scale, + scaleConfig: scaleConfig, + selected: superIdentityRecordKey == activeLocalAccount, + loggedIn: true, + callback: () { + _doSwitchClick(superIdentityRecordKey); + }, + footerCallback: () { + _doEditClick( + superIdentityRecordKey, + AccountSpec.fromProto(value), + perAccountState.accountInfo.userLogin!.accountRecordInfo + .accountRecord); + }), + loading: () => _wrapInBox( + child: buildProgressIndicator(), + color: scaleScheme.grayScale.subtleBorder, + borderRadius: 12 * scaleConfig.borderRadiusScale), + error: (err, st) => _wrapInBox( + child: errorPage(err, st), + color: scaleScheme.errorScale.subtleBorder, + borderRadius: 12 * scaleConfig.borderRadiusScale), + ); + loggedInAccounts + .add(loggedInAccount.paddingLTRB(0, 0, 0, 8.scaled(context))); + } else { + // Account is not logged in + final scale = theme.extension()!.grayScale; + final loggedOutAccount = _makeAccountWidget( + name: la.name, + scale: scale, + scaleConfig: scaleConfig, + selected: superIdentityRecordKey == activeLocalAccount, + loggedIn: false, + callback: () => {_doSwitchClick(superIdentityRecordKey)}, + footerCallback: null, + ); + loggedOutAccounts.add(loggedOutAccount); + } + } + + // Assemble main menu + return [...loggedInAccounts, ...loggedOutAccounts]; + } + + Widget _getButton( + {required Icon icon, + required ScaleColor scale, + required ScaleConfig scaleConfig, + required String tooltip, + required void Function()? onPressed}) { + late final Color background; + late final Color hoverBackground; + late final Color activeBackground; + late final Color border; + late final Color hoverBorder; + late final Color activeBorder; + if (scaleConfig.useVisualIndicators && !scaleConfig.preferBorders) { + background = scale.border; + hoverBackground = scale.hoverBorder; + activeBackground = scale.primary; + border = scale.elementBackground; + hoverBorder = scale.hoverElementBackground; + activeBorder = scale.activeElementBackground; + } else { + background = scale.elementBackground; + hoverBackground = scale.hoverElementBackground; + activeBackground = scale.activeElementBackground; + border = scale.border; + hoverBorder = scale.hoverBorder; + activeBorder = scale.primary; + } + return IconButton( + icon: icon, + padding: const EdgeInsets.all(12), + color: border, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.hovered)) { + return hoverBackground; + } + if (states.contains(WidgetState.focused)) { + return activeBackground; + } + return background; + }), shape: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.hovered)) { + return RoundedRectangleBorder( + side: BorderSide(color: hoverBorder, width: 2), + borderRadius: BorderRadius.all( + Radius.circular(12 * scaleConfig.borderRadiusScale))); + } + if (states.contains(WidgetState.focused)) { + return RoundedRectangleBorder( + side: BorderSide(color: activeBorder, width: 2), + borderRadius: BorderRadius.all( + Radius.circular(12 * scaleConfig.borderRadiusScale))); + } + return RoundedRectangleBorder( + side: BorderSide(color: border, width: 2), + borderRadius: BorderRadius.all( + Radius.circular(12 * scaleConfig.borderRadiusScale))); + })), + tooltip: tooltip, + onPressed: onPressed); + } + + Widget _getBottomButtons() { + final theme = Theme.of(context); + final scaleScheme = theme.extension()!; + final scaleConfig = theme.extension()!; + final scale = scaleScheme.scale(_scaleKind); + + final settingsButton = _getButton( + icon: const Icon( + Icons.settings, + applyTextScaling: true, + ), + tooltip: translate('menu.settings_tooltip'), + scale: scale, + scaleConfig: scaleConfig, + onPressed: () async { + await GoRouterHelper(context).push('/settings'); + }).paddingLTRB(0, 0, 16, 0); + + final addButton = _getButton( + icon: const Icon( + Icons.add, + applyTextScaling: true, + ), + tooltip: translate('menu.add_account_tooltip'), + scale: scale, + scaleConfig: scaleConfig, + onPressed: () async { + await GoRouterHelper(context).push('/new_account'); + }).paddingLTRB(0, 0, 16, 0); + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [settingsButton, addButton]).paddingLTRB(0, 16, 0, 16); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scaleScheme = theme.extension()!; + final scaleConfig = theme.extension()!; + final scale = scaleScheme.scale(_scaleKind); + //final textTheme = theme.textTheme; + final localAccounts = context.watch().state; + final perAccountCollectionBlocMapState = + context.watch().state; + final activeLocalAccount = context.watch().state; + final gradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + scale.border, + scale.subtleBorder, + ]); + + Widget menu = DecoratedBox( + decoration: ShapeDecoration( + shadows: themedShadow(scaleConfig, scale), + gradient: scaleConfig.useVisualIndicators ? null : gradient, + color: scaleConfig.useVisualIndicators + ? (scaleConfig.preferBorders + ? scale.appBackground + : scale.subtleBorder) + : null, + shape: RoundedRectangleBorder( + side: scaleConfig.preferBorders + ? BorderSide(color: scale.primary, width: 2) + : BorderSide.none, + borderRadius: BorderRadius.only( + topRight: Radius.circular(16 * scaleConfig.borderRadiusScale), + bottomRight: + Radius.circular(16 * scaleConfig.borderRadiusScale)))), + child: Column(children: [ + FittedBox( + fit: BoxFit.scaleDown, + child: ColorFiltered( + colorFilter: ColorFilter.mode( + theme.brightness == Brightness.light + ? scale.primary + : scale.border, + scaleConfig.preferBorders + ? BlendMode.modulate + : BlendMode.dst), + child: Row(children: [ + // SvgPicture.asset( + // height: 48, + // 'assets/images/icon.svg', + // colorFilter: scaleConfig.useVisualIndicators + // ? grayColorFilter + // : null) + // .paddingLTRB(0, 0, 16, 0), + GestureDetector( + onLongPress: () { + context + .findAncestorWidgetOfExactType()! + .reloadTheme(context); + }, + child: SvgPicture.asset( + height: 48, + 'assets/images/title.svg', + colorFilter: scaleConfig.useVisualIndicators + ? grayColorFilter + : src96StencilFilter)), + ]))), + Text(translate('menu.accounts'), + style: theme.textTheme.titleMedium!.copyWith( + color: scaleConfig.preferBorders + ? scale.border + : scale.borderText)) + .paddingLTRB(0, 16, 0, 16), + ListView( + shrinkWrap: true, + children: _getAccountList( + localAccounts: localAccounts, + activeLocalAccount: activeLocalAccount, + perAccountCollectionBlocMapState: + perAccountCollectionBlocMapState)) + .expanded(), + _getBottomButtons(), + Row(children: [ + Text('${translate('menu.version')} $packageInfoVersion', + style: theme.textTheme.labelMedium!.copyWith( + color: scaleConfig.preferBorders + ? scale.hoverBorder + : scale.subtleBackground)), + const Spacer(), + SignalStrengthMeterWidget( + color: scaleConfig.preferBorders + ? scale.hoverBorder + : scale.subtleBackground, + inactiveColor: scaleConfig.preferBorders + ? scale.border + : scale.elementBackground, + ), + ]) + ]).paddingAll(16), + ); + + if (scaleConfig.preferBorders || scaleConfig.useVisualIndicators) { + menu = menu.paddingLTRB(0, 2, 2, 2); + } + + return menu; + } +} diff --git a/lib/layout/home/drawer_menu/menu_item_widget.dart b/lib/layout/home/drawer_menu/menu_item_widget.dart new file mode 100644 index 0000000..80e466b --- /dev/null +++ b/lib/layout/home/drawer_menu/menu_item_widget.dart @@ -0,0 +1,141 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../theme/views/preferences/preferences.dart'; + +class MenuItemWidget extends StatelessWidget { + const MenuItemWidget({ + required this.title, + required this.titleStyle, + required this.foregroundColor, + this.headerWidget, + this.widthBox, + this.callback, + this.backgroundColor, + this.backgroundHoverColor, + this.backgroundFocusColor, + this.borderColor, + this.borderHoverColor, + this.borderFocusColor, + this.borderRadius, + this.footerButtonIcon, + this.footerButtonIconColor, + this.footerButtonIconHoverColor, + this.footerButtonIconFocusColor, + this.footerCallback, + this.minHeight = 0, + super.key, + }); + + @override + Widget build(BuildContext context) => TextButton( + onPressed: callback, + style: TextButton.styleFrom(foregroundColor: foregroundColor).copyWith( + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.hovered)) { + return backgroundHoverColor; + } + if (states.contains(WidgetState.focused)) { + return backgroundFocusColor; + } + return backgroundColor; + }), + overlayColor: + WidgetStateProperty.resolveWith((states) => backgroundHoverColor), + side: WidgetStateBorderSide.resolveWith((states) { + if (states.contains(WidgetState.hovered)) { + return borderColor != null + ? BorderSide(width: 2, color: borderHoverColor!) + : null; + } + if (states.contains(WidgetState.focused)) { + return borderColor != null + ? BorderSide(width: 2, color: borderFocusColor!) + : null; + } + return borderColor != null + ? BorderSide(width: 2, color: borderColor!) + : null; + }), + shape: WidgetStateProperty.all(RoundedRectangleBorder( + borderRadius: BorderRadius.circular(borderRadius ?? 0)))), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: minHeight), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (headerWidget != null) headerWidget!, + if (widthBox != null) widthBox!, + Expanded( + child: FittedBox( + alignment: Alignment.centerLeft, + fit: BoxFit.scaleDown, + child: Text( + title, + style: titleStyle, + ).paddingAll(8)), + ), + if (footerButtonIcon != null) + IconButton( + color: footerButtonIconColor, + focusColor: footerButtonIconFocusColor, + hoverColor: footerButtonIconHoverColor, + icon: Icon( + footerButtonIcon, + size: 24.scaled(context), + ), + onPressed: footerCallback), + ], + ).paddingAll(2), + )); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('textStyle', titleStyle)) + ..add(ObjectFlagProperty.has('callback', callback)) + ..add(DiagnosticsProperty('foregroundColor', foregroundColor)) + ..add(StringProperty('title', title)) + ..add( + DiagnosticsProperty('footerButtonIcon', footerButtonIcon)) + ..add(ObjectFlagProperty.has( + 'footerCallback', footerCallback)) + ..add(ColorProperty('footerButtonIconColor', footerButtonIconColor)) + ..add(ColorProperty( + 'footerButtonIconHoverColor', footerButtonIconHoverColor)) + ..add(ColorProperty( + 'footerButtonIconFocusColor', footerButtonIconFocusColor)) + ..add(ColorProperty('backgroundColor', backgroundColor)) + ..add(ColorProperty('backgroundHoverColor', backgroundHoverColor)) + ..add(ColorProperty('backgroundFocusColor', backgroundFocusColor)) + ..add(ColorProperty('borderColor', borderColor)) + ..add(DoubleProperty('borderRadius', borderRadius)) + ..add(ColorProperty('borderHoverColor', borderHoverColor)) + ..add(ColorProperty('borderFocusColor', borderFocusColor)) + ..add(DoubleProperty('minHeight', minHeight)); + } + + //////////////////////////////////////////////////////////////////////////// + + final String title; + final Widget? headerWidget; + final Widget? widthBox; + final TextStyle titleStyle; + final Color foregroundColor; + final void Function()? callback; + final IconData? footerButtonIcon; + final void Function()? footerCallback; + final Color? backgroundColor; + final Color? backgroundHoverColor; + final Color? backgroundFocusColor; + final Color? borderColor; + final double? borderRadius; + final Color? borderHoverColor; + final Color? borderFocusColor; + final Color? footerButtonIconColor; + final Color? footerButtonIconHoverColor; + final Color? footerButtonIconFocusColor; + final double minHeight; +} diff --git a/lib/layout/home/home.dart b/lib/layout/home/home.dart index 5b1b3d1..741483b 100644 --- a/lib/layout/home/home.dart +++ b/lib/layout/home/home.dart @@ -1,6 +1,7 @@ +export 'drawer_menu/drawer_menu.dart'; export 'home_account_invalid.dart'; export 'home_account_locked.dart'; export 'home_account_missing.dart'; -export 'home_account_ready/home_account_ready.dart'; +export 'home_account_ready.dart'; export 'home_no_active.dart'; -export 'home_shell.dart'; +export 'home_screen.dart'; diff --git a/lib/layout/home/home_account_missing.dart b/lib/layout/home/home_account_missing.dart index d9c0aad..a2e4db4 100644 --- a/lib/layout/home/home_account_missing.dart +++ b/lib/layout/home/home_account_missing.dart @@ -21,13 +21,3 @@ class HomeAccountMissingState extends State { @override Widget build(BuildContext context) => const Text('Account missing'); } - -// xxx click to delete missing account or add to postframecallback - // Future.delayed(0.ms, () async { - // await showErrorModal(context, translate('home.missing_account_title'), - // translate('home.missing_account_text')); - // // Delete account - // await AccountRepository.instance.deleteLocalAccount(activeUserLogin); - // // Switch to no active user login - // await AccountRepository.instance.switchToAccount(null); - // }); \ No newline at end of file diff --git a/lib/layout/home/home_account_ready.dart b/lib/layout/home/home_account_ready.dart new file mode 100644 index 0000000..a829248 --- /dev/null +++ b/lib/layout/home/home_account_ready.dart @@ -0,0 +1,225 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:flutter_zoom_drawer/flutter_zoom_drawer.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../chat/chat.dart'; +import '../../chat_list/chat_list.dart'; +import '../../contacts/contacts.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; + +class HomeAccountReady extends StatefulWidget { + const HomeAccountReady({super.key}); + + @override + State createState() => _HomeAccountReadyState(); +} + +class _HomeAccountReadyState extends State { + @override + void initState() { + super.initState(); + } + + Widget buildMenuButton() => Builder(builder: (context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + return AspectRatio( + aspectRatio: 1, + child: IconButton( + icon: Icon( + size: 32.scaled(context), + Icons.menu, + ), + color: scaleConfig.preferBorders + ? scale.primaryScale.border + : scale.primaryScale.borderText, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + scaleConfig.preferBorders + ? scale.primaryScale.hoverElementBackground + : scale.primaryScale.hoverBorder), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + side: !scaleConfig.useVisualIndicators + ? BorderSide.none + : BorderSide( + strokeAlign: BorderSide.strokeAlignCenter, + color: scaleConfig.preferBorders + ? scale.primaryScale.border + : scale.primaryScale.borderText, + width: 2), + borderRadius: BorderRadius.all(Radius.circular( + 8 * scaleConfig.borderRadiusScale))), + )), + tooltip: translate('menu.accounts_menu_tooltip'), + onPressed: () async { + final ctrl = context.read(); + await ctrl.toggle?.call(); + })); + }); + + Widget buildContactsButton() => Builder(builder: (context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + return AspectRatio( + aspectRatio: 1, + child: IconButton( + icon: Icon( + size: 32.scaled(context), + Icons.contacts, + ), + color: scaleConfig.preferBorders + ? scale.primaryScale.border + : scale.primaryScale.borderText, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + scaleConfig.preferBorders + ? scale.primaryScale.hoverElementBackground + : scale.primaryScale.hoverBorder), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + side: !scaleConfig.useVisualIndicators + ? BorderSide.none + : BorderSide( + strokeAlign: BorderSide.strokeAlignCenter, + color: scaleConfig.preferBorders + ? scale.primaryScale.border + : scale.primaryScale.borderText, + width: 2), + borderRadius: BorderRadius.all(Radius.circular( + 8 * scaleConfig.borderRadiusScale))), + )), + tooltip: translate('menu.contacts_tooltip'), + onPressed: () async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ContactsPage(), + ), + ); + })); + }); + + Widget buildLeftPane(BuildContext context) => Builder( + builder: (context) => Material( + color: Colors.transparent, + child: Builder(builder: (context) { + final profile = context.select( + (c) => c.state.asData!.value.profile); + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + return ColoredBox( + color: scaleConfig.preferBorders + ? scale.primaryScale.subtleBackground + : scale.primaryScale.subtleBorder, + child: Column(children: [ + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + buildMenuButton().paddingLTRB(0, 0, 8, 0), + ProfileWidget( + profile: profile, + ).expanded(), + buildContactsButton().paddingLTRB(8, 0, 0, 0), + ])).paddingAll(8), + const ChatListWidget().expanded() + ])); + }))); + + Widget buildRightPane(BuildContext context) { + final activeChatCubit = context.watch(); + final activeChatLocalConversationKey = activeChatCubit.state; + if (activeChatLocalConversationKey == null) { + return const NoConversationWidget(); + } + return Material( + color: Colors.transparent, + child: Builder( + builder: (context) => ChatComponentWidget.singleContact( + context: context, + localConversationRecordKey: activeChatLocalConversationKey, + onCancel: () { + activeChatCubit.setActiveChat(null); + }, + onClose: () { + activeChatCubit.setActiveChat(null); + }, + key: ValueKey(activeChatLocalConversationKey)))); + } + + @override + Widget build(BuildContext context) { + final isSmallScreen = isMobileSize(context); + + final theme = Theme.of(context); + final scaleScheme = theme.extension()!; + final scaleConfig = theme.extension()!; + final scale = scaleScheme.scale(ScaleKind.primary); + + final activeChat = context.watch().state; + final hasActiveChat = activeChat != null; + + return LayoutBuilder(builder: (context, constraints) { + const leftColumnSize = 320.0; + + late final bool visibleLeft; + late final bool visibleRight; + late final double leftWidth; + late final double rightWidth; + if (isSmallScreen) { + if (hasActiveChat) { + visibleLeft = false; + visibleRight = true; + leftWidth = leftColumnSize; + rightWidth = constraints.maxWidth; + } else { + visibleLeft = true; + visibleRight = false; + leftWidth = constraints.maxWidth; + rightWidth = 400; // whatever + } + } else { + visibleLeft = true; + visibleRight = true; + leftWidth = leftColumnSize; + rightWidth = constraints.maxWidth - + leftColumnSize - + (scaleConfig.useVisualIndicators ? 2 : 0); + } + + return Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Offstage( + offstage: !visibleLeft, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: leftWidth), + child: buildLeftPane(context))) + .withThemedShadow(scaleConfig, scale), + if (scaleConfig.useVisualIndicators) + Offstage( + offstage: !(visibleLeft && visibleRight), + child: SizedBox( + width: 2, + height: double.infinity, + child: ColoredBox( + color: scaleConfig.preferBorders + ? scale.subtleBorder + : scale.subtleBackground))), + Offstage( + offstage: !visibleRight, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: constraints.maxHeight, maxWidth: rightWidth), + child: buildRightPane(context), + )), + ]); + }); + } +} diff --git a/lib/layout/home/home_account_ready/home_account_ready.dart b/lib/layout/home/home_account_ready/home_account_ready.dart deleted file mode 100644 index b198f0b..0000000 --- a/lib/layout/home/home_account_ready/home_account_ready.dart +++ /dev/null @@ -1,3 +0,0 @@ -export 'home_account_ready_chat.dart'; -export 'home_account_ready_main.dart'; -export 'home_account_ready_shell.dart'; diff --git a/lib/layout/home/home_account_ready/home_account_ready_chat.dart b/lib/layout/home/home_account_ready/home_account_ready_chat.dart deleted file mode 100644 index 6e1868c..0000000 --- a/lib/layout/home/home_account_ready/home_account_ready_chat.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import '../../../chat/chat.dart'; -import '../../../tools/tools.dart'; - -class HomeAccountReadyChat extends StatefulWidget { - const HomeAccountReadyChat({super.key}); - - @override - HomeAccountReadyChatState createState() => HomeAccountReadyChatState(); -} - -class HomeAccountReadyChatState extends State { - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((_) async { - await changeWindowSetup( - TitleBarStyle.normal, OrientationCapability.normal); - }); - } - - @override - void dispose() { - super.dispose(); - } - - Widget buildChatComponent(BuildContext context) { - final activeChatLocalConversationKey = - context.watch().state; - if (activeChatLocalConversationKey == null) { - return const NoConversationWidget(); - } - return ChatComponentWidget.builder( - localConversationRecordKey: activeChatLocalConversationKey, - key: ValueKey(activeChatLocalConversationKey)); - } - - @override - Widget build(BuildContext context) => SafeArea( - child: buildChatComponent(context), - ); -} diff --git a/lib/layout/home/home_account_ready/home_account_ready_main.dart b/lib/layout/home/home_account_ready/home_account_ready_main.dart deleted file mode 100644 index 9fec3ce..0000000 --- a/lib/layout/home/home_account_ready/home_account_ready_main.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:go_router/go_router.dart'; - -import '../../../account_manager/account_manager.dart'; -import '../../../chat/chat.dart'; -import '../../../theme/theme.dart'; -import '../../../tools/tools.dart'; -import 'main_pager/main_pager.dart'; - -class HomeAccountReadyMain extends StatefulWidget { - const HomeAccountReadyMain({super.key}); - - @override - State createState() => _HomeAccountReadyMainState(); -} - -class _HomeAccountReadyMainState extends State { - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((_) async { - await changeWindowSetup( - TitleBarStyle.normal, OrientationCapability.normal); - }); - } - - Widget buildUserPanel() => Builder(builder: (context) { - final account = context.watch().state; - final theme = Theme.of(context); - final scale = theme.extension()!; - - return Column(children: [ - Row(children: [ - IconButton( - icon: const Icon(Icons.settings), - color: scale.secondaryScale.borderText, - constraints: const BoxConstraints.expand(height: 64, width: 64), - style: ButtonStyle( - backgroundColor: - WidgetStateProperty.all(scale.primaryScale.hoverBorder), - shape: WidgetStateProperty.all(const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(16))))), - tooltip: translate('app_bar.settings_tooltip'), - onPressed: () async { - await GoRouterHelper(context).push('/settings'); - }).paddingLTRB(0, 0, 8, 0), - asyncValueBuilder(account, - (_, account) => ProfileWidget(profile: account.profile)) - .expanded(), - ]).paddingAll(8), - const MainPager().expanded() - ]); - }); - - Widget buildPhone(BuildContext context) => - Material(color: Colors.transparent, child: buildUserPanel()); - - Widget buildTabletLeftPane(BuildContext context) => Builder( - builder: (context) => - Material(color: Colors.transparent, child: buildUserPanel())); - - Widget buildTabletRightPane(BuildContext context) { - final activeChatLocalConversationKey = - context.watch().state; - if (activeChatLocalConversationKey == null) { - return const NoConversationWidget(); - } - return ChatComponentWidget.builder( - localConversationRecordKey: activeChatLocalConversationKey, - ); - } - - // ignore: prefer_expression_function_bodies - Widget buildTablet(BuildContext context) { - final w = MediaQuery.of(context).size.width; - final theme = Theme.of(context); - final scale = theme.extension()!; - - final children = [ - ConstrainedBox( - constraints: const BoxConstraints(minWidth: 300, maxWidth: 300), - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: w / 2), - child: buildTabletLeftPane(context))), - SizedBox( - width: 2, - height: double.infinity, - child: ColoredBox(color: scale.primaryScale.hoverBorder)), - Expanded(child: buildTabletRightPane(context)), - ]; - - return Row( - children: children, - ); - } - - @override - Widget build(BuildContext context) => responsiveVisibility( - context: context, - phone: false, - ) - ? buildTablet(context) - : buildPhone(context); -} diff --git a/lib/layout/home/home_account_ready/home_account_ready_shell.dart b/lib/layout/home/home_account_ready/home_account_ready_shell.dart deleted file mode 100644 index c41185b..0000000 --- a/lib/layout/home/home_account_ready/home_account_ready_shell.dart +++ /dev/null @@ -1,159 +0,0 @@ -import 'package:async_tools/async_tools.dart'; -import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:veilid_support/veilid_support.dart'; - -import '../../../account_manager/account_manager.dart'; -import '../../../chat/chat.dart'; -import '../../../chat_list/chat_list.dart'; -import '../../../contact_invitation/contact_invitation.dart'; -import '../../../contacts/contacts.dart'; -import '../../../router/router.dart'; -import '../../../theme/theme.dart'; - -class HomeAccountReadyShell extends StatefulWidget { - factory HomeAccountReadyShell( - {required BuildContext context, required Widget child, Key? key}) { - // These must exist in order for the account to - // be considered 'ready' for this widget subtree - final activeLocalAccount = context.read().state!; - final activeAccountInfo = context.read(); - final routerCubit = context.read(); - - return HomeAccountReadyShell._( - activeLocalAccount: activeLocalAccount, - activeAccountInfo: activeAccountInfo, - routerCubit: routerCubit, - key: key, - child: child); - } - const HomeAccountReadyShell._( - {required this.activeLocalAccount, - required this.activeAccountInfo, - required this.routerCubit, - required this.child, - super.key}); - - @override - HomeAccountReadyShellState createState() => HomeAccountReadyShellState(); - - final Widget child; - final TypedKey activeLocalAccount; - final ActiveAccountInfo activeAccountInfo; - final RouterCubit routerCubit; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty( - 'activeLocalAccount', activeLocalAccount)) - ..add(DiagnosticsProperty( - 'activeAccountInfo', activeAccountInfo)) - ..add(DiagnosticsProperty('routerCubit', routerCubit)); - } -} - -class HomeAccountReadyShellState extends State { - final SingleStateProcessor - _singleInvitationStatusProcessor = SingleStateProcessor(); - - @override - void initState() { - super.initState(); - } - - // Process all accepted or rejected invitations - void _invitationStatusListener( - BuildContext context, WaitingInvitationsBlocMapState state) { - _singleInvitationStatusProcessor.updateState(state, (newState) async { - final contactListCubit = context.read(); - final contactInvitationListCubit = - context.read(); - - for (final entry in newState.entries) { - final contactRequestInboxRecordKey = entry.key; - final invStatus = entry.value.asData?.value; - // Skip invitations that have not yet been accepted or rejected - if (invStatus == null) { - continue; - } - - // Delete invitation and process the accepted or rejected contact - final acceptedContact = invStatus.acceptedContact; - if (acceptedContact != null) { - await contactInvitationListCubit.deleteInvitation( - accepted: true, - contactRequestInboxRecordKey: contactRequestInboxRecordKey); - - // Accept - await contactListCubit.createContact( - remoteProfile: acceptedContact.remoteProfile, - remoteSuperIdentity: acceptedContact.remoteIdentity, - remoteConversationRecordKey: - acceptedContact.remoteConversationRecordKey, - localConversationRecordKey: - acceptedContact.localConversationRecordKey, - ); - } else { - // Reject - await contactInvitationListCubit.deleteInvitation( - accepted: false, - contactRequestInboxRecordKey: contactRequestInboxRecordKey); - } - } - }); - } - - @override - Widget build(BuildContext context) { - final account = context.watch().state.asData?.value; - if (account == null) { - return waitingPage(); - } - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => ContactInvitationListCubit( - activeAccountInfo: widget.activeAccountInfo, - account: account)), - BlocProvider( - create: (context) => ContactListCubit( - activeAccountInfo: widget.activeAccountInfo, - account: account)), - BlocProvider( - create: (context) => ActiveChatCubit(null) - ..withStateListen((event) { - widget.routerCubit.setHasActiveChat(event != null); - })), - BlocProvider( - create: (context) => ChatListCubit( - activeAccountInfo: widget.activeAccountInfo, - activeChatCubit: context.read(), - account: account)), - BlocProvider( - create: (context) => ActiveConversationsBlocMapCubit( - activeAccountInfo: widget.activeAccountInfo, - contactListCubit: context.read()) - ..follow(context.read())), - BlocProvider( - create: (context) => ActiveSingleContactChatBlocMapCubit( - activeAccountInfo: widget.activeAccountInfo, - contactListCubit: context.read(), - chatListCubit: context.read()) - ..follow(context.read())), - BlocProvider( - create: (context) => WaitingInvitationsBlocMapCubit( - activeAccountInfo: widget.activeAccountInfo, account: account) - ..follow(context.read())) - ], - child: MultiBlocListener(listeners: [ - BlocListener( - listener: _invitationStatusListener, - ) - ], child: widget.child)); - } -} diff --git a/lib/layout/home/home_account_ready/main_pager/account_page.dart b/lib/layout/home/home_account_ready/main_pager/account_page.dart deleted file mode 100644 index 0d8650e..0000000 --- a/lib/layout/home/home_account_ready/main_pager/account_page.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_translate/flutter_translate.dart'; - -import '../../../../contact_invitation/contact_invitation.dart'; -import '../../../../contacts/contacts.dart'; -import '../../../../theme/theme.dart'; - -class AccountPage extends StatefulWidget { - const AccountPage({ - super.key, - }); - - @override - AccountPageState createState() => AccountPageState(); -} - -class AccountPageState extends State { - @override - void initState() { - super.initState(); - } - - @override - void dispose() { - super.dispose(); - } - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - final theme = Theme.of(context); - final textTheme = theme.textTheme; - final scale = theme.extension()!; - - final cilState = context.watch().state; - final cilBusy = cilState.busy; - final contactInvitationRecordList = - cilState.state.asData?.value.map((x) => x.value).toIList() ?? - const IListConst([]); - - final ciState = context.watch().state; - final ciBusy = ciState.busy; - final contactList = - ciState.state.asData?.value.map((x) => x.value).toIList() ?? - const IListConst([]); - - return SizedBox( - child: Column(children: [ - if (contactInvitationRecordList.isNotEmpty) - ExpansionTile( - tilePadding: const EdgeInsets.fromLTRB(8, 0, 8, 0), - backgroundColor: scale.primaryScale.border, - collapsedBackgroundColor: scale.primaryScale.border, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - collapsedShape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - title: Text( - translate('account_page.contact_invitations'), - textAlign: TextAlign.center, - style: textTheme.titleMedium! - .copyWith(color: scale.primaryScale.borderText), - ), - iconColor: scale.primaryScale.borderText, - initiallyExpanded: true, - children: [ - ContactInvitationListWidget( - contactInvitationRecordList: contactInvitationRecordList, - disabled: cilBusy) - ], - ).paddingLTRB(8, 0, 8, 8), - ContactListWidget(contactList: contactList, disabled: ciBusy).expanded(), - ])); - } -} diff --git a/lib/layout/home/home_account_ready/main_pager/bottom_sheet_action_button.dart b/lib/layout/home/home_account_ready/main_pager/bottom_sheet_action_button.dart deleted file mode 100644 index c34e478..0000000 --- a/lib/layout/home/home_account_ready/main_pager/bottom_sheet_action_button.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -class BottomSheetActionButton extends StatefulWidget { - const BottomSheetActionButton( - {required this.bottomSheetBuilder, - required this.builder, - this.foregroundColor, - this.backgroundColor, - this.shape, - super.key}); - final Color? foregroundColor; - final Color? backgroundColor; - final ShapeBorder? shape; - final Widget Function(BuildContext) builder; - final Widget Function(BuildContext) bottomSheetBuilder; - - @override - BottomSheetActionButtonState createState() => BottomSheetActionButtonState(); - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(ObjectFlagProperty.has( - 'bottomSheetBuilder', bottomSheetBuilder)) - ..add(ColorProperty('foregroundColor', foregroundColor)) - ..add(ColorProperty('backgroundColor', backgroundColor)) - ..add(DiagnosticsProperty('shape', shape)) - ..add(ObjectFlagProperty.has( - 'builder', builder)); - } -} - -class BottomSheetActionButtonState extends State { - bool _showFab = true; - - @override - void initState() { - super.initState(); - } - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - // - return _showFab - ? FloatingActionButton( - elevation: 0, - hoverElevation: 0, - shape: widget.shape, - foregroundColor: widget.foregroundColor, - backgroundColor: widget.backgroundColor, - child: widget.builder(context), - onPressed: () async { - await showModalBottomSheet( - context: context, builder: widget.bottomSheetBuilder); - }, - ) - : Container(); - } - - void showFloatingActionButton(bool value) { - setState(() { - _showFab = value; - }); - } -} diff --git a/lib/layout/home/home_account_ready/main_pager/chats_page.dart b/lib/layout/home/home_account_ready/main_pager/chats_page.dart deleted file mode 100644 index bdea8e3..0000000 --- a/lib/layout/home/home_account_ready/main_pager/chats_page.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/material.dart'; - -import '../../../../chat_list/chat_list.dart'; - -class ChatsPage extends StatefulWidget { - const ChatsPage({super.key}); - - @override - ChatsPageState createState() => ChatsPageState(); -} - -class ChatsPageState extends State { - @override - void initState() { - super.initState(); - } - - @override - void dispose() { - super.dispose(); - } - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - return Column(children: [ - const ChatSingleContactListWidget().expanded(), - ]); - } -} diff --git a/lib/layout/home/home_account_ready/main_pager/main_pager.dart b/lib/layout/home/home_account_ready/main_pager/main_pager.dart deleted file mode 100644 index cdd6ac5..0000000 --- a/lib/layout/home/home_account_ready/main_pager/main_pager.dart +++ /dev/null @@ -1,206 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter_animate/flutter_animate.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:preload_page_view/preload_page_view.dart'; -import 'package:stylish_bottom_bar/stylish_bottom_bar.dart'; - -import '../../../../chat/chat.dart'; -import '../../../../contact_invitation/contact_invitation.dart'; -import '../../../../theme/theme.dart'; -import 'account_page.dart'; -import 'bottom_sheet_action_button.dart'; -import 'chats_page.dart'; - -class MainPager extends StatefulWidget { - const MainPager({super.key}); - - @override - MainPagerState createState() => MainPagerState(); - - static MainPagerState? of(BuildContext context) => - context.findAncestorStateOfType(); -} - -class MainPagerState extends State with TickerProviderStateMixin { - ////////////////////////////////////////////////////////////////// - - var _currentPage = 0; - final pageController = PreloadPageController(); - - final _selectedIconList = [Icons.person, Icons.chat]; - // final _unselectedIconList = [ - // Icons.chat_outlined, - // Icons.person_outlined - // ]; - final _fabIconList = [ - Icons.person_add_sharp, - Icons.add_comment_sharp, - ]; - final _bottomLabelList = [ - translate('pager.contacts'), - translate('pager.chats'), - ]; - - ////////////////////////////////////////////////////////////////// - - @override - void initState() { - super.initState(); - } - - @override - void dispose() { - pageController.dispose(); - super.dispose(); - } - - bool onScrollNotification(ScrollNotification notification) { - if (notification is UserScrollNotification && - notification.metrics.axis == Axis.vertical) { - switch (notification.direction) { - case ScrollDirection.forward: - // _hideBottomBarAnimationController.reverse(); - // _fabAnimationController.forward(from: 0); - break; - case ScrollDirection.reverse: - // _hideBottomBarAnimationController.forward(); - // _fabAnimationController.reverse(from: 1); - break; - case ScrollDirection.idle: - break; - } - } - return false; - } - - BottomBarItem buildBottomBarItem(int index) { - final theme = Theme.of(context); - final scale = theme.extension()!; - return BottomBarItem( - title: Text(_bottomLabelList[index]), - icon: - Icon(_selectedIconList[index], color: scale.primaryScale.borderText), - selectedIcon: - Icon(_selectedIconList[index], color: scale.primaryScale.borderText), - backgroundColor: scale.primaryScale.borderText, - //badge: const Text('9+'), - //showBadge: true, - ); - } - - List _buildBottomBarItems() { - final bottomBarItems = List.empty(growable: true); - for (var index = 0; index < _bottomLabelList.length; index++) { - final item = buildBottomBarItem(index); - bottomBarItems.add(item); - } - return bottomBarItems; - } - - Future scanContactInvitationDialog(BuildContext context) async { - await showDialog( - context: context, - // ignore: prefer_expression_function_bodies - builder: (context) { - return AlertDialog( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(20)), - ), - contentPadding: const EdgeInsets.only( - top: 10, - ), - title: const Text( - 'Scan Contact Invite', - style: TextStyle(fontSize: 24), - ), - content: ScanInvitationDialog( - modalContext: context, - )); - }); - } - - Widget _bottomSheetBuilder(BuildContext sheetContext, BuildContext context) { - if (_currentPage == 0) { - // New contact invitation - return newContactBottomSheetBuilder(sheetContext, context); - } else if (_currentPage == 1) { - // New chat - return newChatBottomSheetBuilder(sheetContext, context); - } else { - // Unknown error - return debugPage('unknown page'); - } - } - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scale = theme.extension()!; - - return Scaffold( - //extendBody: true, - backgroundColor: Colors.transparent, - body: NotificationListener( - onNotification: onScrollNotification, - child: PreloadPageView( - controller: pageController, - preloadPagesCount: 2, - onPageChanged: (index) { - setState(() { - _currentPage = index; - }); - }, - children: const [ - AccountPage(), - ChatsPage(), - ])), - // appBar: AppBar( - // toolbarHeight: 24, - // title: Text( - // 'C', - // style: Theme.of(context).textTheme.headlineSmall, - // ), - // ), - bottomNavigationBar: StylishBottomBar( - backgroundColor: scale.primaryScale.hoverBorder, - option: AnimatedBarOptions( - inkEffect: true, - inkColor: scale.primaryScale.hoverPrimary, - opacity: 0.3, - ), - items: _buildBottomBarItems(), - hasNotch: true, - fabLocation: StylishBarFabLocation.end, - currentIndex: _currentPage, - onTap: (index) async { - await pageController.animateToPage(index, - duration: 250.ms, curve: Curves.easeInOut); - }, - ), - - floatingActionButton: BottomSheetActionButton( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(14))), - foregroundColor: scale.secondaryScale.borderText, - backgroundColor: scale.secondaryScale.hoverBorder, - builder: (context) => Icon( - _fabIconList[_currentPage], - color: scale.secondaryScale.borderText, - ), - bottomSheetBuilder: (sheetContext) => - _bottomSheetBuilder(sheetContext, context)), - floatingActionButtonLocation: FloatingActionButtonLocation.endDocked, - ); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty( - 'pageController', pageController)); - } -} diff --git a/lib/layout/home/home_screen.dart b/lib/layout/home/home_screen.dart new file mode 100644 index 0000000..2aefcd3 --- /dev/null +++ b/lib/layout/home/home_screen.dart @@ -0,0 +1,244 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:flutter_zoom_drawer/flutter_zoom_drawer.dart'; +import 'package:provider/provider.dart'; +import 'package:transitioned_indexed_stack/transitioned_indexed_stack.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../settings/settings.dart'; +import '../../theme/theme.dart'; +import 'drawer_menu/drawer_menu.dart'; +import 'home_account_invalid.dart'; +import 'home_account_locked.dart'; +import 'home_account_missing.dart'; +import 'home_account_ready.dart'; +import 'home_no_active.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + HomeScreenState createState() => HomeScreenState(); + + static HomeScreenState? of(BuildContext context) => + context.findAncestorStateOfType(); +} + +class HomeScreenState extends State + with SingleTickerProviderStateMixin { + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((_) async { + final localAccounts = context.read().state; + final activeLocalAccount = context.read().state; + final activeIndex = localAccounts + .indexWhere((x) => x.superIdentity.recordKey == activeLocalAccount); + final canClose = activeIndex != -1; + + final displayBetaWarning = context + .read() + .state + .asData + ?.value + .notificationsPreference + .displayBetaWarning ?? + true; + if (displayBetaWarning) { + await _doBetaDialog(context); + } + + if (!canClose) { + await _zoomDrawerController.open!(); + } + }); + super.initState(); + } + + Future _doBetaDialog(BuildContext context) async { + var displayBetaWarning = true; + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + await showAlertWidgetModal( + context: context, + title: translate('splash.beta_title'), + child: Builder( + builder: (context) => + Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.warning, size: 64.scaled(context)), + RichText( + textScaler: MediaQuery.of(context).textScaler, + textAlign: TextAlign.center, + text: TextSpan( + children: [ + TextSpan( + text: translate('splash.beta_text'), + style: theme.textTheme.bodyMedium! + .copyWith(color: scale.primaryScale.appText), + ), + TextSpan( + text: 'https://veilid.com/chat/knownissues', + style: theme.textTheme.bodyMedium!.copyWith( + color: scaleConfig.useVisualIndicators + ? scale.secondaryScale.primaryText + : scale.secondaryScale.primary, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => launchUrlString( + 'https://veilid.com/chat/knownissues'), + ), + ], + ), + ), + Row(mainAxisSize: MainAxisSize.min, children: [ + StatefulBuilder( + builder: (context, setState) => Checkbox( + value: displayBetaWarning, + onChanged: (value) { + setState(() { + displayBetaWarning = value ?? true; + }); + }, + )), + Text( + translate('settings_page.display_beta_warning'), + style: theme.textTheme.bodyMedium! + .copyWith(color: scale.primaryScale.appText), + ), + ]), + ]), + )); + + final preferencesInstance = PreferencesRepository.instance; + await preferencesInstance.set(preferencesInstance.value.copyWith( + notificationsPreference: preferencesInstance + .value.notificationsPreference + .copyWith(displayBetaWarning: displayBetaWarning))); + } + + Widget _buildAccountPage( + BuildContext context, + TypedKey superIdentityRecordKey, + PerAccountCollectionState perAccountCollectionState) { + switch (perAccountCollectionState.accountInfo.status) { + case AccountInfoStatus.accountInvalid: + return const HomeAccountInvalid(); + case AccountInfoStatus.accountLocked: + return const HomeAccountLocked(); + case AccountInfoStatus.accountUnlocked: + // Are we ready to render? + if (!perAccountCollectionState.isReady) { + return waitingPage(); + } + + // Re-export all ready blocs to the account display subtree + final pages = >[ + const MaterialPage(child: HomeAccountReady()) + ]; + return perAccountCollectionState.provideReady( + child: Navigator(onDidRemovePage: pages.remove, pages: pages)); + } + } + + Widget _applyPageBorder(Widget child) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + return ValueListenableBuilder( + valueListenable: _zoomDrawerController.stateNotifier!, + child: child, + builder: (context, drawerState, staticChild) => clipBorder( + clipEnabled: drawerState != DrawerState.closed, + borderEnabled: + scaleConfig.preferBorders && scaleConfig.useVisualIndicators, + borderRadius: 16 * scaleConfig.borderRadiusScale, + borderColor: scale.primaryScale.border, + child: staticChild!)); + } + + Widget _buildAccountPageView(BuildContext context) { + final localAccounts = context.watch().state; + final activeLocalAccount = context.watch().state; + final perAccountCollectionBlocMapState = + context.watch().state; + + final activeIndex = localAccounts + .indexWhere((x) => x.superIdentity.recordKey == activeLocalAccount); + if (activeIndex == -1) { + return _applyPageBorder(const HomeNoActive()); + } + + final accountPages = []; + + for (var i = 0; i < localAccounts.length; i++) { + final superIdentityRecordKey = localAccounts[i].superIdentity.recordKey; + final perAccountCollectionState = + perAccountCollectionBlocMapState.get(superIdentityRecordKey); + if (perAccountCollectionState == null) { + return HomeAccountMissing(key: ValueKey(superIdentityRecordKey)); + } + final accountPage = _buildAccountPage( + context, superIdentityRecordKey, perAccountCollectionState); + accountPages.add(_applyPageBorder(accountPage)); + } + + return SlideIndexedStack( + index: activeIndex, + beginSlideOffset: const Offset(1, 0), + children: accountPages, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final localAccounts = context.watch().state; + final activeLocalAccount = context.watch().state; + final activeIndex = localAccounts + .indexWhere((x) => x.superIdentity.recordKey == activeLocalAccount); + final canClose = activeIndex != -1; + + final drawer = Scaffold( + backgroundColor: Colors.transparent, + body: ZoomDrawer( + controller: _zoomDrawerController, + menuScreen: Builder(builder: (context) { + final zoomDrawer = ZoomDrawer.of(context); + zoomDrawer!.stateNotifier.addListener(() { + if (zoomDrawer.isOpen()) { + FocusManager.instance.primaryFocus?.unfocus(); + } + }); + return const DrawerMenu(); + }), + mainScreen: Provider.value( + value: _zoomDrawerController, + child: Builder(builder: _buildAccountPageView)), + borderRadius: 0, + angle: 0, + openCurve: Curves.fastEaseInToSlowEaseOut, + closeCurve: Curves.fastEaseInToSlowEaseOut, + menuScreenTapClose: canClose, + mainScreenTapClose: canClose, + disableDragGesture: !canClose, + mainScreenScale: .25, + slideWidth: min(360, MediaQuery.of(context).size.width * 0.9), + )); + + return DefaultTextStyle(style: theme.textTheme.bodySmall!, child: drawer); + } + + //////////////////////////////////////////////////////////////////////////// + + final _zoomDrawerController = ZoomDrawerController(); +} diff --git a/lib/layout/home/home_shell.dart b/lib/layout/home/home_shell.dart deleted file mode 100644 index 8851730..0000000 --- a/lib/layout/home/home_shell.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:provider/provider.dart'; - -import '../../account_manager/account_manager.dart'; -import '../../theme/theme.dart'; -import 'home_account_invalid.dart'; -import 'home_account_locked.dart'; -import 'home_account_missing.dart'; -import 'home_no_active.dart'; - -class HomeShell extends StatefulWidget { - const HomeShell({required this.accountReadyBuilder, super.key}); - - @override - HomeShellState createState() => HomeShellState(); - - final Builder accountReadyBuilder; -} - -class HomeShellState extends State { - @override - void initState() { - super.initState(); - } - - @override - void dispose() { - super.dispose(); - } - - Widget buildWithLogin(BuildContext context) { - final activeLocalAccount = context.watch().state; - - if (activeLocalAccount == null) { - // If no logged in user is active, show the loading panel - return const HomeNoActive(); - } - - final accountInfo = - AccountRepository.instance.getAccountInfo(activeLocalAccount); - - switch (accountInfo.status) { - case AccountInfoStatus.noAccount: - return const HomeAccountMissing(); - case AccountInfoStatus.accountInvalid: - return const HomeAccountInvalid(); - case AccountInfoStatus.accountLocked: - return const HomeAccountLocked(); - case AccountInfoStatus.accountReady: - return Provider.value( - value: accountInfo.activeAccountInfo!, - child: BlocProvider( - create: (context) => AccountRecordCubit( - open: () async => AccountRepository.instance - .openAccountRecord( - accountInfo.activeAccountInfo!.userLogin)), - child: widget.accountReadyBuilder)); - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scale = theme.extension()!; - - // XXX: eventually write account switcher here - return SafeArea( - child: DecoratedBox( - decoration: BoxDecoration( - color: scale.primaryScale.activeElementBackground), - child: buildWithLogin(context))); - } -} diff --git a/lib/layout/layout.dart b/lib/layout/layout.dart index 985a099..a744264 100644 --- a/lib/layout/layout.dart +++ b/lib/layout/layout.dart @@ -1,4 +1,3 @@ export 'default_app_bar.dart'; export 'home/home.dart'; -export 'home/home_account_ready/main_pager/main_pager.dart'; export 'splash.dart'; diff --git a/lib/layout/splash.dart b/lib/layout/splash.dart index 2113193..c3af797 100644 --- a/lib/layout/splash.dart +++ b/lib/layout/splash.dart @@ -11,16 +11,11 @@ class Splash extends StatefulWidget { State createState() => _SplashState(); } -class _SplashState extends State { - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((_) async { - await changeWindowSetup( - TitleBarStyle.hidden, OrientationCapability.normal); - }); - } +class _SplashState extends WindowSetupState { + _SplashState() + : super( + titleBarStyle: TitleBarStyle.hidden, + orientationCapability: OrientationCapability.portraitOnly); @override Widget build(BuildContext context) => PopScope( diff --git a/lib/main.dart b/lib/main.dart index d8bd6df..7193e06 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:intl/date_symbol_data_local.dart'; + import 'package:stack_trace/stack_trace.dart'; import 'app.dart'; @@ -35,7 +36,7 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); await PreferencesRepository.instance.init(); final initialThemeData = - PreferencesRepository.instance.value.themePreferences.themeData(); + PreferencesRepository.instance.value.themePreference.themeData(); // Manage window on desktop platforms await initializeWindowControl(); @@ -45,6 +46,9 @@ void main() async { fallbackLocale: 'en_US', supportedLocales: ['en_US']); await initializeDateFormatting(); + // Get package info + await initPackageInfo(); + // Run the app // Hot reloads will only restart this part, not Veilid runApp(LocalizedApp(localizationDelegate, diff --git a/lib/notifications/cubits/notifications_cubit.dart b/lib/notifications/cubits/notifications_cubit.dart new file mode 100644 index 0000000..769e64f --- /dev/null +++ b/lib/notifications/cubits/notifications_cubit.dart @@ -0,0 +1,26 @@ +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../notifications.dart'; + +class NotificationsCubit extends Cubit { + NotificationsCubit(super.initialState); + + void info({required String text, String? title}) { + emit(state.copyWith( + queue: state.queue.add(NotificationItem( + type: NotificationType.info, text: text, title: title)))); + } + + void error({required String text, String? title}) { + emit(state.copyWith( + queue: state.queue.add(NotificationItem( + type: NotificationType.info, text: text, title: title)))); + } + + IList popAll() { + final out = state.queue; + emit(state.copyWith(queue: state.queue.clear())); + return out; + } +} diff --git a/lib/notifications/models/models.dart b/lib/notifications/models/models.dart new file mode 100644 index 0000000..52f3c2b --- /dev/null +++ b/lib/notifications/models/models.dart @@ -0,0 +1,2 @@ +export 'notifications_preference.dart'; +export 'notifications_state.dart'; diff --git a/lib/notifications/models/notifications_preference.dart b/lib/notifications/models/notifications_preference.dart new file mode 100644 index 0000000..913cb25 --- /dev/null +++ b/lib/notifications/models/notifications_preference.dart @@ -0,0 +1,69 @@ +import 'package:change_case/change_case.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'notifications_preference.freezed.dart'; +part 'notifications_preference.g.dart'; + +@freezed +sealed class NotificationsPreference with _$NotificationsPreference { + const factory NotificationsPreference({ + @Default(true) bool displayBetaWarning, + @Default(true) bool enableBadge, + @Default(true) bool enableNotifications, + @Default(MessageNotificationContent.nameAndContent) + MessageNotificationContent messageNotificationContent, + @Default(NotificationMode.inAppOrPush) + NotificationMode onInvitationAcceptedMode, + @Default(SoundEffect.beepBaDeep) SoundEffect onInvitationAcceptedSound, + @Default(NotificationMode.inAppOrPush) + NotificationMode onMessageReceivedMode, + @Default(SoundEffect.boop) SoundEffect onMessageReceivedSound, + @Default(SoundEffect.bonk) SoundEffect onMessageSentSound, + }) = _NotificationsPreference; + + factory NotificationsPreference.fromJson(dynamic json) => + _$NotificationsPreferenceFromJson(json as Map); + + static const NotificationsPreference defaults = NotificationsPreference(); +} + +enum NotificationMode { + none, + inApp, + push, + inAppOrPush; + + factory NotificationMode.fromJson(dynamic j) => + NotificationMode.values.byName((j as String).toCamelCase()); + String toJson() => name.toPascalCase(); + + static const NotificationMode defaults = NotificationMode.none; +} + +enum MessageNotificationContent { + nothing, + nameOnly, + nameAndContent; + + factory MessageNotificationContent.fromJson(dynamic j) => + MessageNotificationContent.values.byName((j as String).toCamelCase()); + String toJson() => name.toPascalCase(); + + static const MessageNotificationContent defaults = + MessageNotificationContent.nothing; +} + +enum SoundEffect { + none, + bonk, + boop, + baDeep, + beepBaDeep, + custom; + + factory SoundEffect.fromJson(dynamic j) => + SoundEffect.values.byName((j as String).toCamelCase()); + String toJson() => name.toPascalCase(); + + static const SoundEffect defaults = SoundEffect.none; +} diff --git a/lib/notifications/models/notifications_preference.freezed.dart b/lib/notifications/models/notifications_preference.freezed.dart new file mode 100644 index 0000000..55f700a --- /dev/null +++ b/lib/notifications/models/notifications_preference.freezed.dart @@ -0,0 +1,364 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'notifications_preference.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$NotificationsPreference { + bool get displayBetaWarning; + bool get enableBadge; + bool get enableNotifications; + MessageNotificationContent get messageNotificationContent; + NotificationMode get onInvitationAcceptedMode; + SoundEffect get onInvitationAcceptedSound; + NotificationMode get onMessageReceivedMode; + SoundEffect get onMessageReceivedSound; + SoundEffect get onMessageSentSound; + + /// Create a copy of NotificationsPreference + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $NotificationsPreferenceCopyWith get copyWith => + _$NotificationsPreferenceCopyWithImpl( + this as NotificationsPreference, _$identity); + + /// Serializes this NotificationsPreference to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is NotificationsPreference && + (identical(other.displayBetaWarning, displayBetaWarning) || + other.displayBetaWarning == displayBetaWarning) && + (identical(other.enableBadge, enableBadge) || + other.enableBadge == enableBadge) && + (identical(other.enableNotifications, enableNotifications) || + other.enableNotifications == enableNotifications) && + (identical(other.messageNotificationContent, + messageNotificationContent) || + other.messageNotificationContent == + messageNotificationContent) && + (identical( + other.onInvitationAcceptedMode, onInvitationAcceptedMode) || + other.onInvitationAcceptedMode == onInvitationAcceptedMode) && + (identical(other.onInvitationAcceptedSound, + onInvitationAcceptedSound) || + other.onInvitationAcceptedSound == onInvitationAcceptedSound) && + (identical(other.onMessageReceivedMode, onMessageReceivedMode) || + other.onMessageReceivedMode == onMessageReceivedMode) && + (identical(other.onMessageReceivedSound, onMessageReceivedSound) || + other.onMessageReceivedSound == onMessageReceivedSound) && + (identical(other.onMessageSentSound, onMessageSentSound) || + other.onMessageSentSound == onMessageSentSound)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + displayBetaWarning, + enableBadge, + enableNotifications, + messageNotificationContent, + onInvitationAcceptedMode, + onInvitationAcceptedSound, + onMessageReceivedMode, + onMessageReceivedSound, + onMessageSentSound); + + @override + String toString() { + return 'NotificationsPreference(displayBetaWarning: $displayBetaWarning, enableBadge: $enableBadge, enableNotifications: $enableNotifications, messageNotificationContent: $messageNotificationContent, onInvitationAcceptedMode: $onInvitationAcceptedMode, onInvitationAcceptedSound: $onInvitationAcceptedSound, onMessageReceivedMode: $onMessageReceivedMode, onMessageReceivedSound: $onMessageReceivedSound, onMessageSentSound: $onMessageSentSound)'; + } +} + +/// @nodoc +abstract mixin class $NotificationsPreferenceCopyWith<$Res> { + factory $NotificationsPreferenceCopyWith(NotificationsPreference value, + $Res Function(NotificationsPreference) _then) = + _$NotificationsPreferenceCopyWithImpl; + @useResult + $Res call( + {bool displayBetaWarning, + bool enableBadge, + bool enableNotifications, + MessageNotificationContent messageNotificationContent, + NotificationMode onInvitationAcceptedMode, + SoundEffect onInvitationAcceptedSound, + NotificationMode onMessageReceivedMode, + SoundEffect onMessageReceivedSound, + SoundEffect onMessageSentSound}); +} + +/// @nodoc +class _$NotificationsPreferenceCopyWithImpl<$Res> + implements $NotificationsPreferenceCopyWith<$Res> { + _$NotificationsPreferenceCopyWithImpl(this._self, this._then); + + final NotificationsPreference _self; + final $Res Function(NotificationsPreference) _then; + + /// Create a copy of NotificationsPreference + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? displayBetaWarning = null, + Object? enableBadge = null, + Object? enableNotifications = null, + Object? messageNotificationContent = null, + Object? onInvitationAcceptedMode = null, + Object? onInvitationAcceptedSound = null, + Object? onMessageReceivedMode = null, + Object? onMessageReceivedSound = null, + Object? onMessageSentSound = null, + }) { + return _then(_self.copyWith( + displayBetaWarning: null == displayBetaWarning + ? _self.displayBetaWarning + : displayBetaWarning // ignore: cast_nullable_to_non_nullable + as bool, + enableBadge: null == enableBadge + ? _self.enableBadge + : enableBadge // ignore: cast_nullable_to_non_nullable + as bool, + enableNotifications: null == enableNotifications + ? _self.enableNotifications + : enableNotifications // ignore: cast_nullable_to_non_nullable + as bool, + messageNotificationContent: null == messageNotificationContent + ? _self.messageNotificationContent + : messageNotificationContent // ignore: cast_nullable_to_non_nullable + as MessageNotificationContent, + onInvitationAcceptedMode: null == onInvitationAcceptedMode + ? _self.onInvitationAcceptedMode + : onInvitationAcceptedMode // ignore: cast_nullable_to_non_nullable + as NotificationMode, + onInvitationAcceptedSound: null == onInvitationAcceptedSound + ? _self.onInvitationAcceptedSound + : onInvitationAcceptedSound // ignore: cast_nullable_to_non_nullable + as SoundEffect, + onMessageReceivedMode: null == onMessageReceivedMode + ? _self.onMessageReceivedMode + : onMessageReceivedMode // ignore: cast_nullable_to_non_nullable + as NotificationMode, + onMessageReceivedSound: null == onMessageReceivedSound + ? _self.onMessageReceivedSound + : onMessageReceivedSound // ignore: cast_nullable_to_non_nullable + as SoundEffect, + onMessageSentSound: null == onMessageSentSound + ? _self.onMessageSentSound + : onMessageSentSound // ignore: cast_nullable_to_non_nullable + as SoundEffect, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _NotificationsPreference implements NotificationsPreference { + const _NotificationsPreference( + {this.displayBetaWarning = true, + this.enableBadge = true, + this.enableNotifications = true, + this.messageNotificationContent = + MessageNotificationContent.nameAndContent, + this.onInvitationAcceptedMode = NotificationMode.inAppOrPush, + this.onInvitationAcceptedSound = SoundEffect.beepBaDeep, + this.onMessageReceivedMode = NotificationMode.inAppOrPush, + this.onMessageReceivedSound = SoundEffect.boop, + this.onMessageSentSound = SoundEffect.bonk}); + factory _NotificationsPreference.fromJson(Map json) => + _$NotificationsPreferenceFromJson(json); + + @override + @JsonKey() + final bool displayBetaWarning; + @override + @JsonKey() + final bool enableBadge; + @override + @JsonKey() + final bool enableNotifications; + @override + @JsonKey() + final MessageNotificationContent messageNotificationContent; + @override + @JsonKey() + final NotificationMode onInvitationAcceptedMode; + @override + @JsonKey() + final SoundEffect onInvitationAcceptedSound; + @override + @JsonKey() + final NotificationMode onMessageReceivedMode; + @override + @JsonKey() + final SoundEffect onMessageReceivedSound; + @override + @JsonKey() + final SoundEffect onMessageSentSound; + + /// Create a copy of NotificationsPreference + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$NotificationsPreferenceCopyWith<_NotificationsPreference> get copyWith => + __$NotificationsPreferenceCopyWithImpl<_NotificationsPreference>( + this, _$identity); + + @override + Map toJson() { + return _$NotificationsPreferenceToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _NotificationsPreference && + (identical(other.displayBetaWarning, displayBetaWarning) || + other.displayBetaWarning == displayBetaWarning) && + (identical(other.enableBadge, enableBadge) || + other.enableBadge == enableBadge) && + (identical(other.enableNotifications, enableNotifications) || + other.enableNotifications == enableNotifications) && + (identical(other.messageNotificationContent, + messageNotificationContent) || + other.messageNotificationContent == + messageNotificationContent) && + (identical( + other.onInvitationAcceptedMode, onInvitationAcceptedMode) || + other.onInvitationAcceptedMode == onInvitationAcceptedMode) && + (identical(other.onInvitationAcceptedSound, + onInvitationAcceptedSound) || + other.onInvitationAcceptedSound == onInvitationAcceptedSound) && + (identical(other.onMessageReceivedMode, onMessageReceivedMode) || + other.onMessageReceivedMode == onMessageReceivedMode) && + (identical(other.onMessageReceivedSound, onMessageReceivedSound) || + other.onMessageReceivedSound == onMessageReceivedSound) && + (identical(other.onMessageSentSound, onMessageSentSound) || + other.onMessageSentSound == onMessageSentSound)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + displayBetaWarning, + enableBadge, + enableNotifications, + messageNotificationContent, + onInvitationAcceptedMode, + onInvitationAcceptedSound, + onMessageReceivedMode, + onMessageReceivedSound, + onMessageSentSound); + + @override + String toString() { + return 'NotificationsPreference(displayBetaWarning: $displayBetaWarning, enableBadge: $enableBadge, enableNotifications: $enableNotifications, messageNotificationContent: $messageNotificationContent, onInvitationAcceptedMode: $onInvitationAcceptedMode, onInvitationAcceptedSound: $onInvitationAcceptedSound, onMessageReceivedMode: $onMessageReceivedMode, onMessageReceivedSound: $onMessageReceivedSound, onMessageSentSound: $onMessageSentSound)'; + } +} + +/// @nodoc +abstract mixin class _$NotificationsPreferenceCopyWith<$Res> + implements $NotificationsPreferenceCopyWith<$Res> { + factory _$NotificationsPreferenceCopyWith(_NotificationsPreference value, + $Res Function(_NotificationsPreference) _then) = + __$NotificationsPreferenceCopyWithImpl; + @override + @useResult + $Res call( + {bool displayBetaWarning, + bool enableBadge, + bool enableNotifications, + MessageNotificationContent messageNotificationContent, + NotificationMode onInvitationAcceptedMode, + SoundEffect onInvitationAcceptedSound, + NotificationMode onMessageReceivedMode, + SoundEffect onMessageReceivedSound, + SoundEffect onMessageSentSound}); +} + +/// @nodoc +class __$NotificationsPreferenceCopyWithImpl<$Res> + implements _$NotificationsPreferenceCopyWith<$Res> { + __$NotificationsPreferenceCopyWithImpl(this._self, this._then); + + final _NotificationsPreference _self; + final $Res Function(_NotificationsPreference) _then; + + /// Create a copy of NotificationsPreference + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? displayBetaWarning = null, + Object? enableBadge = null, + Object? enableNotifications = null, + Object? messageNotificationContent = null, + Object? onInvitationAcceptedMode = null, + Object? onInvitationAcceptedSound = null, + Object? onMessageReceivedMode = null, + Object? onMessageReceivedSound = null, + Object? onMessageSentSound = null, + }) { + return _then(_NotificationsPreference( + displayBetaWarning: null == displayBetaWarning + ? _self.displayBetaWarning + : displayBetaWarning // ignore: cast_nullable_to_non_nullable + as bool, + enableBadge: null == enableBadge + ? _self.enableBadge + : enableBadge // ignore: cast_nullable_to_non_nullable + as bool, + enableNotifications: null == enableNotifications + ? _self.enableNotifications + : enableNotifications // ignore: cast_nullable_to_non_nullable + as bool, + messageNotificationContent: null == messageNotificationContent + ? _self.messageNotificationContent + : messageNotificationContent // ignore: cast_nullable_to_non_nullable + as MessageNotificationContent, + onInvitationAcceptedMode: null == onInvitationAcceptedMode + ? _self.onInvitationAcceptedMode + : onInvitationAcceptedMode // ignore: cast_nullable_to_non_nullable + as NotificationMode, + onInvitationAcceptedSound: null == onInvitationAcceptedSound + ? _self.onInvitationAcceptedSound + : onInvitationAcceptedSound // ignore: cast_nullable_to_non_nullable + as SoundEffect, + onMessageReceivedMode: null == onMessageReceivedMode + ? _self.onMessageReceivedMode + : onMessageReceivedMode // ignore: cast_nullable_to_non_nullable + as NotificationMode, + onMessageReceivedSound: null == onMessageReceivedSound + ? _self.onMessageReceivedSound + : onMessageReceivedSound // ignore: cast_nullable_to_non_nullable + as SoundEffect, + onMessageSentSound: null == onMessageSentSound + ? _self.onMessageSentSound + : onMessageSentSound // ignore: cast_nullable_to_non_nullable + as SoundEffect, + )); + } +} + +// dart format on diff --git a/lib/notifications/models/notifications_preference.g.dart b/lib/notifications/models/notifications_preference.g.dart new file mode 100644 index 0000000..d1d5e89 --- /dev/null +++ b/lib/notifications/models/notifications_preference.g.dart @@ -0,0 +1,50 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'notifications_preference.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_NotificationsPreference _$NotificationsPreferenceFromJson( + Map json) => + _NotificationsPreference( + displayBetaWarning: json['display_beta_warning'] as bool? ?? true, + enableBadge: json['enable_badge'] as bool? ?? true, + enableNotifications: json['enable_notifications'] as bool? ?? true, + messageNotificationContent: json['message_notification_content'] == null + ? MessageNotificationContent.nameAndContent + : MessageNotificationContent.fromJson( + json['message_notification_content']), + onInvitationAcceptedMode: json['on_invitation_accepted_mode'] == null + ? NotificationMode.inAppOrPush + : NotificationMode.fromJson(json['on_invitation_accepted_mode']), + onInvitationAcceptedSound: json['on_invitation_accepted_sound'] == null + ? SoundEffect.beepBaDeep + : SoundEffect.fromJson(json['on_invitation_accepted_sound']), + onMessageReceivedMode: json['on_message_received_mode'] == null + ? NotificationMode.inAppOrPush + : NotificationMode.fromJson(json['on_message_received_mode']), + onMessageReceivedSound: json['on_message_received_sound'] == null + ? SoundEffect.boop + : SoundEffect.fromJson(json['on_message_received_sound']), + onMessageSentSound: json['on_message_sent_sound'] == null + ? SoundEffect.bonk + : SoundEffect.fromJson(json['on_message_sent_sound']), + ); + +Map _$NotificationsPreferenceToJson( + _NotificationsPreference instance) => + { + 'display_beta_warning': instance.displayBetaWarning, + 'enable_badge': instance.enableBadge, + 'enable_notifications': instance.enableNotifications, + 'message_notification_content': + instance.messageNotificationContent.toJson(), + 'on_invitation_accepted_mode': instance.onInvitationAcceptedMode.toJson(), + 'on_invitation_accepted_sound': + instance.onInvitationAcceptedSound.toJson(), + 'on_message_received_mode': instance.onMessageReceivedMode.toJson(), + 'on_message_received_sound': instance.onMessageReceivedSound.toJson(), + 'on_message_sent_sound': instance.onMessageSentSound.toJson(), + }; diff --git a/lib/notifications/models/notifications_state.dart b/lib/notifications/models/notifications_state.dart new file mode 100644 index 0000000..6248bba --- /dev/null +++ b/lib/notifications/models/notifications_state.dart @@ -0,0 +1,23 @@ +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'notifications_state.freezed.dart'; + +enum NotificationType { + info, + error, +} + +@freezed +sealed class NotificationItem with _$NotificationItem { + const factory NotificationItem( + {required NotificationType type, + required String text, + String? title}) = _NotificationItem; +} + +@freezed +sealed class NotificationsState with _$NotificationsState { + const factory NotificationsState({required IList queue}) = + _NotificationsState; +} diff --git a/lib/notifications/models/notifications_state.freezed.dart b/lib/notifications/models/notifications_state.freezed.dart new file mode 100644 index 0000000..8633702 --- /dev/null +++ b/lib/notifications/models/notifications_state.freezed.dart @@ -0,0 +1,308 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'notifications_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$NotificationItem { + NotificationType get type; + String get text; + String? get title; + + /// Create a copy of NotificationItem + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $NotificationItemCopyWith get copyWith => + _$NotificationItemCopyWithImpl( + this as NotificationItem, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is NotificationItem && + (identical(other.type, type) || other.type == type) && + (identical(other.text, text) || other.text == text) && + (identical(other.title, title) || other.title == title)); + } + + @override + int get hashCode => Object.hash(runtimeType, type, text, title); + + @override + String toString() { + return 'NotificationItem(type: $type, text: $text, title: $title)'; + } +} + +/// @nodoc +abstract mixin class $NotificationItemCopyWith<$Res> { + factory $NotificationItemCopyWith( + NotificationItem value, $Res Function(NotificationItem) _then) = + _$NotificationItemCopyWithImpl; + @useResult + $Res call({NotificationType type, String text, String? title}); +} + +/// @nodoc +class _$NotificationItemCopyWithImpl<$Res> + implements $NotificationItemCopyWith<$Res> { + _$NotificationItemCopyWithImpl(this._self, this._then); + + final NotificationItem _self; + final $Res Function(NotificationItem) _then; + + /// Create a copy of NotificationItem + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? type = null, + Object? text = null, + Object? title = freezed, + }) { + return _then(_self.copyWith( + type: null == type + ? _self.type + : type // ignore: cast_nullable_to_non_nullable + as NotificationType, + text: null == text + ? _self.text + : text // ignore: cast_nullable_to_non_nullable + as String, + title: freezed == title + ? _self.title + : title // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// @nodoc + +class _NotificationItem implements NotificationItem { + const _NotificationItem({required this.type, required this.text, this.title}); + + @override + final NotificationType type; + @override + final String text; + @override + final String? title; + + /// Create a copy of NotificationItem + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$NotificationItemCopyWith<_NotificationItem> get copyWith => + __$NotificationItemCopyWithImpl<_NotificationItem>(this, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _NotificationItem && + (identical(other.type, type) || other.type == type) && + (identical(other.text, text) || other.text == text) && + (identical(other.title, title) || other.title == title)); + } + + @override + int get hashCode => Object.hash(runtimeType, type, text, title); + + @override + String toString() { + return 'NotificationItem(type: $type, text: $text, title: $title)'; + } +} + +/// @nodoc +abstract mixin class _$NotificationItemCopyWith<$Res> + implements $NotificationItemCopyWith<$Res> { + factory _$NotificationItemCopyWith( + _NotificationItem value, $Res Function(_NotificationItem) _then) = + __$NotificationItemCopyWithImpl; + @override + @useResult + $Res call({NotificationType type, String text, String? title}); +} + +/// @nodoc +class __$NotificationItemCopyWithImpl<$Res> + implements _$NotificationItemCopyWith<$Res> { + __$NotificationItemCopyWithImpl(this._self, this._then); + + final _NotificationItem _self; + final $Res Function(_NotificationItem) _then; + + /// Create a copy of NotificationItem + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? type = null, + Object? text = null, + Object? title = freezed, + }) { + return _then(_NotificationItem( + type: null == type + ? _self.type + : type // ignore: cast_nullable_to_non_nullable + as NotificationType, + text: null == text + ? _self.text + : text // ignore: cast_nullable_to_non_nullable + as String, + title: freezed == title + ? _self.title + : title // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// @nodoc +mixin _$NotificationsState { + IList get queue; + + /// Create a copy of NotificationsState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $NotificationsStateCopyWith get copyWith => + _$NotificationsStateCopyWithImpl( + this as NotificationsState, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is NotificationsState && + const DeepCollectionEquality().equals(other.queue, queue)); + } + + @override + int get hashCode => + Object.hash(runtimeType, const DeepCollectionEquality().hash(queue)); + + @override + String toString() { + return 'NotificationsState(queue: $queue)'; + } +} + +/// @nodoc +abstract mixin class $NotificationsStateCopyWith<$Res> { + factory $NotificationsStateCopyWith( + NotificationsState value, $Res Function(NotificationsState) _then) = + _$NotificationsStateCopyWithImpl; + @useResult + $Res call({IList queue}); +} + +/// @nodoc +class _$NotificationsStateCopyWithImpl<$Res> + implements $NotificationsStateCopyWith<$Res> { + _$NotificationsStateCopyWithImpl(this._self, this._then); + + final NotificationsState _self; + final $Res Function(NotificationsState) _then; + + /// Create a copy of NotificationsState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? queue = null, + }) { + return _then(_self.copyWith( + queue: null == queue + ? _self.queue + : queue // ignore: cast_nullable_to_non_nullable + as IList, + )); + } +} + +/// @nodoc + +class _NotificationsState implements NotificationsState { + const _NotificationsState({required this.queue}); + + @override + final IList queue; + + /// Create a copy of NotificationsState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$NotificationsStateCopyWith<_NotificationsState> get copyWith => + __$NotificationsStateCopyWithImpl<_NotificationsState>(this, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _NotificationsState && + const DeepCollectionEquality().equals(other.queue, queue)); + } + + @override + int get hashCode => + Object.hash(runtimeType, const DeepCollectionEquality().hash(queue)); + + @override + String toString() { + return 'NotificationsState(queue: $queue)'; + } +} + +/// @nodoc +abstract mixin class _$NotificationsStateCopyWith<$Res> + implements $NotificationsStateCopyWith<$Res> { + factory _$NotificationsStateCopyWith( + _NotificationsState value, $Res Function(_NotificationsState) _then) = + __$NotificationsStateCopyWithImpl; + @override + @useResult + $Res call({IList queue}); +} + +/// @nodoc +class __$NotificationsStateCopyWithImpl<$Res> + implements _$NotificationsStateCopyWith<$Res> { + __$NotificationsStateCopyWithImpl(this._self, this._then); + + final _NotificationsState _self; + final $Res Function(_NotificationsState) _then; + + /// Create a copy of NotificationsState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? queue = null, + }) { + return _then(_NotificationsState( + queue: null == queue + ? _self.queue + : queue // ignore: cast_nullable_to_non_nullable + as IList, + )); + } +} + +// dart format on diff --git a/lib/notifications/notifications.dart b/lib/notifications/notifications.dart new file mode 100644 index 0000000..0426651 --- /dev/null +++ b/lib/notifications/notifications.dart @@ -0,0 +1,3 @@ +export 'cubits/notifications_cubit.dart'; +export 'models/models.dart'; +export 'views/views.dart'; diff --git a/lib/notifications/views/notifications_preferences.dart b/lib/notifications/views/notifications_preferences.dart new file mode 100644 index 0000000..ba06699 --- /dev/null +++ b/lib/notifications/views/notifications_preferences.dart @@ -0,0 +1,254 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../settings/settings.dart'; +import '../../theme/theme.dart'; +import '../notifications.dart'; + +Widget buildSettingsPageNotificationPreferences( + {required BuildContext context}) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + final textTheme = theme.textTheme; + + final preferencesRepository = PreferencesRepository.instance; + final notificationsPreference = + preferencesRepository.value.notificationsPreference; + + Future updatePreferences( + NotificationsPreference newNotificationsPreference) async { + final newPrefs = preferencesRepository.value + .copyWith(notificationsPreference: newNotificationsPreference); + await preferencesRepository.set(newPrefs); + } + + List> notificationModeItems() { + final out = >[]; + final items = [ + (NotificationMode.none, true, translate('settings_page.none')), + (NotificationMode.inApp, true, translate('settings_page.in_app')), + (NotificationMode.push, false, translate('settings_page.push')), + ( + NotificationMode.inAppOrPush, + true, + translate('settings_page.in_app_or_push') + ), + ]; + for (final x in items) { + out.add(DropdownMenuItem( + value: x.$1, + enabled: x.$2, + child: Text( + x.$3, + softWrap: false, + style: textTheme.labelMedium, + textAlign: TextAlign.center, + ).fit(fit: BoxFit.scaleDown))); + } + return out; + } + + List> soundEffectItems() { + final out = >[]; + final items = [ + (SoundEffect.none, true, translate('settings_page.none')), + (SoundEffect.bonk, true, translate('settings_page.bonk')), + (SoundEffect.boop, true, translate('settings_page.boop')), + (SoundEffect.baDeep, true, translate('settings_page.badeep')), + (SoundEffect.beepBaDeep, true, translate('settings_page.beep_badeep')), + (SoundEffect.custom, false, translate('settings_page.custom')), + ]; + for (final x in items) { + out.add(DropdownMenuItem( + value: x.$1, + enabled: x.$2, + child: Text( + x.$3, + softWrap: false, + style: textTheme.labelMedium, + textAlign: TextAlign.center, + ).fit(fit: BoxFit.scaleDown))); + } + return out; + } + + List> + messageNotificationContentItems() { + final out = >[]; + final items = [ + ( + MessageNotificationContent.nameAndContent, + true, + translate('settings_page.name_and_content') + ), + ( + MessageNotificationContent.nameOnly, + true, + translate('settings_page.name_only') + ), + ( + MessageNotificationContent.nothing, + true, + translate('settings_page.nothing') + ), + ]; + for (final x in items) { + out.add(DropdownMenuItem( + value: x.$1, + enabled: x.$2, + child: Text( + x.$3, + softWrap: false, + style: textTheme.labelMedium, + textAlign: TextAlign.center, + ))); + } + return out; + } + + // Invitation accepted + Widget notificationSettingsItem( + {required String title, + required bool notificationsEnabled, + NotificationMode? deliveryValue, + SoundEffect? soundValue, + Future Function(NotificationMode)? onNotificationModeChanged, + Future Function(SoundEffect)? onSoundChanged}) => + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8.scaled(context), + children: [ + Text('$title:', style: textTheme.titleMedium), + Wrap( + spacing: 8.scaled(context), // gap between adjacent chips + runSpacing: 8.scaled(context), // gap between lines + children: [ + if (deliveryValue != null) + IntrinsicWidth( + child: StyledDropdown( + decoratorLabel: translate('settings_page.delivery'), + items: notificationModeItems(), + value: deliveryValue, + onChanged: !notificationsEnabled + ? null + : onNotificationModeChanged, + )), + if (soundValue != null) + IntrinsicWidth( + child: StyledDropdown( + decoratorLabel: translate('settings_page.sound'), + items: soundEffectItems(), + value: soundValue, + onChanged: !notificationsEnabled ? null : onSoundChanged, + )) + ]) + ]).paddingAll(4.scaled(context)); + + return InputDecorator( + decoration: InputDecoration( + labelText: translate('settings_page.notifications'), + border: OutlineInputBorder( + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale), + borderSide: BorderSide(width: 2, color: scale.primaryScale.border), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8.scaled(context), + children: [ + // Display Beta Warning + StyledCheckbox( + label: translate('settings_page.display_beta_warning'), + value: notificationsPreference.displayBetaWarning, + onChanged: (value) async { + final newNotificationsPreference = notificationsPreference + .copyWith(displayBetaWarning: value); + + await updatePreferences(newNotificationsPreference); + }), + // Enable Badge + StyledCheckbox( + label: translate('settings_page.enable_badge'), + value: notificationsPreference.enableBadge, + onChanged: (value) async { + final newNotificationsPreference = + notificationsPreference.copyWith(enableBadge: value); + await updatePreferences(newNotificationsPreference); + }), + // Enable Notifications + StyledCheckbox( + label: translate('settings_page.enable_notifications'), + value: notificationsPreference.enableNotifications, + onChanged: (value) async { + final newNotificationsPreference = notificationsPreference + .copyWith(enableNotifications: value); + await updatePreferences(newNotificationsPreference); + }), + StyledDropdown( + items: messageNotificationContentItems(), + value: notificationsPreference.messageNotificationContent, + decoratorLabel: + translate('settings_page.message_notification_content'), + onChanged: !notificationsPreference.enableNotifications + ? null + : (value) async { + final newNotificationsPreference = notificationsPreference + .copyWith(messageNotificationContent: value); + await updatePreferences(newNotificationsPreference); + }, + ).paddingAll(4.scaled(context)), + + // Notifications + + // Invitation accepted + notificationSettingsItem( + title: translate('settings_page.invitation_accepted'), + notificationsEnabled: + notificationsPreference.enableNotifications, + deliveryValue: notificationsPreference.onInvitationAcceptedMode, + soundValue: notificationsPreference.onInvitationAcceptedSound, + onNotificationModeChanged: (value) async { + final newNotificationsPreference = notificationsPreference + .copyWith(onInvitationAcceptedMode: value); + await updatePreferences(newNotificationsPreference); + }, + onSoundChanged: (value) async { + final newNotificationsPreference = notificationsPreference + .copyWith(onInvitationAcceptedSound: value); + await updatePreferences(newNotificationsPreference); + }), + + // Message received + notificationSettingsItem( + title: translate('settings_page.message_received'), + notificationsEnabled: + notificationsPreference.enableNotifications, + deliveryValue: notificationsPreference.onMessageReceivedMode, + soundValue: notificationsPreference.onMessageReceivedSound, + onNotificationModeChanged: (value) async { + final newNotificationsPreference = notificationsPreference + .copyWith(onMessageReceivedMode: value); + await updatePreferences(newNotificationsPreference); + }, + onSoundChanged: (value) async { + final newNotificationsPreference = notificationsPreference + .copyWith(onMessageReceivedSound: value); + await updatePreferences(newNotificationsPreference); + }), + + // Message sent + notificationSettingsItem( + title: translate('settings_page.message_sent'), + notificationsEnabled: + notificationsPreference.enableNotifications, + soundValue: notificationsPreference.onMessageSentSound, + onSoundChanged: (value) async { + final newNotificationsPreference = notificationsPreference + .copyWith(onMessageSentSound: value); + await updatePreferences(newNotificationsPreference); + }), + ]).paddingAll(4.scaled(context))); +} diff --git a/lib/notifications/views/notifications_widget.dart b/lib/notifications/views/notifications_widget.dart new file mode 100644 index 0000000..73fac5f --- /dev/null +++ b/lib/notifications/views/notifications_widget.dart @@ -0,0 +1,93 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:toastification/toastification.dart'; + +import '../../theme/theme.dart'; +import '../notifications.dart'; + +class NotificationsWidget extends StatelessWidget { + const NotificationsWidget({required Widget child, super.key}) + : _child = child; + + //////////////////////////////////////////////////////////////////////////// + // Public API + + @override + Widget build(BuildContext context) { + final notificationsCubit = context.read(); + + return BlocListener( + bloc: notificationsCubit, + listener: (context, state) { + if (state.queue.isNotEmpty) { + final queue = notificationsCubit.popAll(); + for (final notificationItem in queue) { + switch (notificationItem.type) { + case NotificationType.info: + _info( + context: context, + text: notificationItem.text, + title: notificationItem.title); + case NotificationType.error: + _error( + context: context, + text: notificationItem.text, + title: notificationItem.title); + } + } + } + }, + child: _child); + } + + //////////////////////////////////////////////////////////////////////////// + // Private Implementation + + void _toast( + {required BuildContext context, + required String text, + required ScaleToastTheme toastTheme, + String? title}) { + toastification.show( + context: context, + title: title != null + ? Text(title) + .copyWith(style: toastTheme.titleTextStyle) + .paddingLTRB(0, 0, 0, 8) + : null, + description: Text(text).copyWith(style: toastTheme.descriptionTextStyle), + icon: toastTheme.icon, + primaryColor: toastTheme.primaryColor, + backgroundColor: toastTheme.backgroundColor, + foregroundColor: toastTheme.foregroundColor, + padding: toastTheme.padding, + borderRadius: toastTheme.borderRadius, + borderSide: toastTheme.borderSide, + autoCloseDuration: const Duration(seconds: 2), + animationDuration: const Duration(milliseconds: 500), + ); + } + + void _info( + {required BuildContext context, required String text, String? title}) { + final theme = Theme.of(context); + final toastTheme = + theme.extension()!.toastTheme(ScaleToastKind.info); + + _toast(context: context, text: text, toastTheme: toastTheme, title: title); + } + + void _error( + {required BuildContext context, required String text, String? title}) { + final theme = Theme.of(context); + final toastTheme = + theme.extension()!.toastTheme(ScaleToastKind.error); + + _toast(context: context, text: text, toastTheme: toastTheme, title: title); + } + + //////////////////////////////////////////////////////////////////////////// + + final Widget _child; +} diff --git a/lib/notifications/views/views.dart b/lib/notifications/views/views.dart new file mode 100644 index 0000000..48a03b0 --- /dev/null +++ b/lib/notifications/views/views.dart @@ -0,0 +1,2 @@ +export 'notifications_preferences.dart'; +export 'notifications_widget.dart'; diff --git a/lib/proto/extensions.dart b/lib/proto/extensions.dart index 25b8558..2f4ad68 100644 --- a/lib/proto/extensions.dart +++ b/lib/proto/extensions.dart @@ -29,3 +29,22 @@ extension MessageExt on proto.Message { static int compareTimestamp(proto.Message a, proto.Message b) => a.timestamp.compareTo(b.timestamp); } + +extension ContactExt on proto.Contact { + String get nameOrNickname => nickname.isNotEmpty ? nickname : profile.name; + String get displayName => + nickname.isNotEmpty ? '$nickname (${profile.name})' : profile.name; +} + +extension ChatExt on proto.Chat { + TypedKey get localConversationRecordKey { + switch (whichKind()) { + case proto.Chat_Kind.direct: + return direct.localConversationRecordKey.toVeilid(); + case proto.Chat_Kind.group: + return group.localConversationRecordKey.toVeilid(); + case proto.Chat_Kind.notSet: + throw StateError('unknown chat kind'); + } + } +} diff --git a/lib/proto/proto.dart b/lib/proto/proto.dart index 6ad8432..21c988a 100644 --- a/lib/proto/proto.dart +++ b/lib/proto/proto.dart @@ -1,3 +1,6 @@ +import 'package:veilid_support/veilid_support.dart'; +import 'veilidchat.pb.dart' as vcproto; + export 'package:veilid_support/dht_support/proto/proto.dart'; export 'package:veilid_support/proto/proto.dart'; @@ -6,3 +9,292 @@ export 'veilidchat.pb.dart'; export 'veilidchat.pbenum.dart'; export 'veilidchat.pbjson.dart'; export 'veilidchat.pbserver.dart'; + +void registerVeilidchatProtoToDebug() { + dynamic toDebug(dynamic obj) { + if (obj is vcproto.DHTDataReference) { + return { + 'dhtData': obj.dhtData, + 'hash': obj.hash, + }; + } + if (obj is vcproto.BlockStoreDataReference) { + return { + 'block': obj.block, + }; + } + if (obj is vcproto.DataReference) { + return { + 'kind': obj.whichKind(), + if (obj.whichKind() == vcproto.DataReference_Kind.dhtData) + 'dhtData': obj.dhtData, + if (obj.whichKind() == vcproto.DataReference_Kind.blockStoreData) + 'blockStoreData': obj.blockStoreData, + }; + } + if (obj is vcproto.Attachment) { + return { + 'kind': obj.whichKind(), + if (obj.whichKind() == vcproto.Attachment_Kind.media) + 'media': obj.media, + 'signature': obj.signature, + }; + } + if (obj is vcproto.AttachmentMedia) { + return { + 'mime': obj.mime, + 'name': obj.name, + 'content': obj.content, + }; + } + if (obj is vcproto.Permissions) { + return { + 'canAddMembers': obj.canAddMembers, + 'canEditInfo': obj.canEditInfo, + 'moderated': obj.moderated, + }; + } + if (obj is vcproto.Membership) { + return { + 'watchers': obj.watchers, + 'moderated': obj.moderated, + 'talkers': obj.talkers, + 'moderators': obj.moderators, + 'admins': obj.admins, + }; + } + if (obj is vcproto.ChatSettings) { + return { + 'title': obj.title, + 'description': obj.description, + 'icon': obj.icon, + 'defaultExpiration': obj.defaultExpiration, + }; + } + if (obj is vcproto.ChatSettings) { + return { + 'title': obj.title, + 'description': obj.description, + 'icon': obj.icon, + 'defaultExpiration': obj.defaultExpiration, + }; + } + if (obj is vcproto.Message) { + return { + 'id': obj.id, + 'author': obj.author, + 'timestamp': obj.timestamp, + 'kind': obj.whichKind(), + if (obj.whichKind() == vcproto.Message_Kind.text) 'text': obj.text, + if (obj.whichKind() == vcproto.Message_Kind.secret) + 'secret': obj.secret, + if (obj.whichKind() == vcproto.Message_Kind.delete) + 'delete': obj.delete, + if (obj.whichKind() == vcproto.Message_Kind.erase) 'erase': obj.erase, + if (obj.whichKind() == vcproto.Message_Kind.settings) + 'settings': obj.settings, + if (obj.whichKind() == vcproto.Message_Kind.permissions) + 'permissions': obj.permissions, + if (obj.whichKind() == vcproto.Message_Kind.membership) + 'membership': obj.membership, + if (obj.whichKind() == vcproto.Message_Kind.moderation) + 'moderation': obj.moderation, + 'signature': obj.signature, + }; + } + if (obj is vcproto.Message_Text) { + return { + 'text': obj.text, + 'topic': obj.topic, + 'replyId': obj.replyId, + 'expiration': obj.expiration, + 'viewLimit': obj.viewLimit, + 'attachments': obj.attachments, + }; + } + if (obj is vcproto.Message_Secret) { + return { + 'ciphertext': obj.ciphertext, + 'expiration': obj.expiration, + }; + } + if (obj is vcproto.Message_ControlDelete) { + return { + 'ids': obj.ids, + }; + } + if (obj is vcproto.Message_ControlErase) { + return { + 'timestamp': obj.timestamp, + }; + } + if (obj is vcproto.Message_ControlSettings) { + return { + 'settings': obj.settings, + }; + } + if (obj is vcproto.Message_ControlPermissions) { + return { + 'permissions': obj.permissions, + }; + } + if (obj is vcproto.Message_ControlMembership) { + return { + 'membership': obj.membership, + }; + } + if (obj is vcproto.Message_ControlModeration) { + return { + 'acceptedIds': obj.acceptedIds, + 'rejectdIds': obj.rejectedIds, + }; + } + if (obj is vcproto.Message_ControlModeration) { + return { + 'acceptedIds': obj.acceptedIds, + 'rejectdIds': obj.rejectedIds, + }; + } + if (obj is vcproto.Message_ControlReadReceipt) { + return { + 'readIds': obj.readIds, + }; + } + if (obj is vcproto.ReconciledMessage) { + return { + 'content': obj.content, + 'reconciledTime': obj.reconciledTime, + }; + } + if (obj is vcproto.Conversation) { + return { + 'profile': obj.profile, + 'superIdentityJson': obj.superIdentityJson, + 'messages': obj.messages + }; + } + if (obj is vcproto.ChatMember) { + return { + 'remoteIdentityPublicKey': obj.remoteIdentityPublicKey, + 'remoteConversationRecordKey': obj.remoteConversationRecordKey, + }; + } + if (obj is vcproto.DirectChat) { + return { + 'settings': obj.settings, + 'localConversationRecordKey': obj.localConversationRecordKey, + 'remoteMember': obj.remoteMember, + }; + } + if (obj is vcproto.GroupChat) { + return { + 'settings': obj.settings, + 'membership': obj.membership, + 'permissions': obj.permissions, + 'localConversationRecordKey': obj.localConversationRecordKey, + 'remoteMembers': obj.remoteMembers, + }; + } + if (obj is vcproto.Chat) { + return { + 'kind': obj.whichKind(), + if (obj.whichKind() == vcproto.Chat_Kind.direct) 'direct': obj.direct, + if (obj.whichKind() == vcproto.Chat_Kind.group) 'group': obj.group, + }; + } + if (obj is vcproto.Profile) { + return { + 'name': obj.name, + 'pronouns': obj.pronouns, + 'about': obj.about, + 'status': obj.status, + 'availability': obj.availability, + 'avatar': obj.avatar, + 'timestamp': obj.timestamp, + }; + } + if (obj is vcproto.Account) { + return { + 'profile': obj.profile, + 'invisible': obj.invisible, + 'autoAwayTimeoutMin': obj.autoAwayTimeoutMin, + 'contact_list': obj.contactList, + 'contactInvitationRecords': obj.contactInvitationRecords, + 'chatList': obj.chatList, + 'groupChatList': obj.groupChatList, + 'freeMessage': obj.freeMessage, + 'busyMessage': obj.busyMessage, + 'awayMessage': obj.awayMessage, + 'autodetectAway': obj.autodetectAway, + }; + } + if (obj is vcproto.Contact) { + return { + 'nickname': obj.nickname, + 'profile': obj.profile, + 'superIdentityJson': obj.superIdentityJson, + 'identityPublicKey': obj.identityPublicKey, + 'remoteConversationRecordKey': obj.remoteConversationRecordKey, + 'localConversationRecordKey': obj.localConversationRecordKey, + 'showAvailability': obj.showAvailability, + 'notes': obj.notes, + }; + } + if (obj is vcproto.ContactInvitation) { + return { + 'contactRequestInboxKey': obj.contactRequestInboxKey, + 'writerSecret': obj.writerSecret, + }; + } + if (obj is vcproto.SignedContactInvitation) { + return { + 'contactInvitation': obj.contactInvitation, + 'identitySignature': obj.identitySignature, + }; + } + if (obj is vcproto.ContactRequest) { + return { + 'encryptionKeyType': obj.encryptionKeyType, + 'private': obj.private, + }; + } + if (obj is vcproto.ContactRequestPrivate) { + return { + 'writerKey': obj.writerKey, + 'profile': obj.profile, + 'superIdentityRecordKey': obj.superIdentityRecordKey, + 'chatRecordKey': obj.chatRecordKey, + 'expiration': obj.expiration, + }; + } + if (obj is vcproto.ContactResponse) { + return { + 'accept': obj.accept, + 'superIdentityRecordKey': obj.superIdentityRecordKey, + 'remoteConversationRecordKey': obj.remoteConversationRecordKey, + }; + } + if (obj is vcproto.SignedContactResponse) { + return { + 'contactResponse': obj.contactResponse, + 'identitySignature': obj.identitySignature, + }; + } + if (obj is vcproto.ContactInvitationRecord) { + return { + 'contactRequestInbox': obj.contactRequestInbox, + 'writerKey': obj.writerKey, + 'writerSecret': obj.writerSecret, + 'localConversationRecordKey': obj.localConversationRecordKey, + 'expiration': obj.expiration, + 'invitation': obj.invitation, + 'message': obj.message, + 'recipient': obj.recipient, + }; + } + + return obj; + } + + DynamicDebug.registerToDebug(toDebug); +} diff --git a/lib/proto/veilidchat.pb.dart b/lib/proto/veilidchat.pb.dart index 1e0395b..67805ae 100644 --- a/lib/proto/veilidchat.pb.dart +++ b/lib/proto/veilidchat.pb.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields // ignore_for_file: unnecessary_import, unnecessary_this, unused_import @@ -20,13 +20,234 @@ import 'veilidchat.pbenum.dart'; export 'veilidchat.pbenum.dart'; +/// Reference to data on the DHT +class DHTDataReference extends $pb.GeneratedMessage { + factory DHTDataReference({ + $0.TypedKey? dhtData, + $0.TypedKey? hash, + }) { + final $result = create(); + if (dhtData != null) { + $result.dhtData = dhtData; + } + if (hash != null) { + $result.hash = hash; + } + return $result; + } + DHTDataReference._() : super(); + factory DHTDataReference.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory DHTDataReference.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DHTDataReference', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..aOM<$0.TypedKey>(1, _omitFieldNames ? '' : 'dhtData', subBuilder: $0.TypedKey.create) + ..aOM<$0.TypedKey>(2, _omitFieldNames ? '' : 'hash', subBuilder: $0.TypedKey.create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + DHTDataReference clone() => DHTDataReference()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + DHTDataReference copyWith(void Function(DHTDataReference) updates) => super.copyWith((message) => updates(message as DHTDataReference)) as DHTDataReference; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static DHTDataReference create() => DHTDataReference._(); + DHTDataReference createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static DHTDataReference getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static DHTDataReference? _defaultInstance; + + @$pb.TagNumber(1) + $0.TypedKey get dhtData => $_getN(0); + @$pb.TagNumber(1) + set dhtData($0.TypedKey v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasDhtData() => $_has(0); + @$pb.TagNumber(1) + void clearDhtData() => clearField(1); + @$pb.TagNumber(1) + $0.TypedKey ensureDhtData() => $_ensure(0); + + @$pb.TagNumber(2) + $0.TypedKey get hash => $_getN(1); + @$pb.TagNumber(2) + set hash($0.TypedKey v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasHash() => $_has(1); + @$pb.TagNumber(2) + void clearHash() => clearField(2); + @$pb.TagNumber(2) + $0.TypedKey ensureHash() => $_ensure(1); +} + +/// Reference to data on the BlockStore +class BlockStoreDataReference extends $pb.GeneratedMessage { + factory BlockStoreDataReference({ + $0.TypedKey? block, + }) { + final $result = create(); + if (block != null) { + $result.block = block; + } + return $result; + } + BlockStoreDataReference._() : super(); + factory BlockStoreDataReference.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory BlockStoreDataReference.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'BlockStoreDataReference', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..aOM<$0.TypedKey>(1, _omitFieldNames ? '' : 'block', subBuilder: $0.TypedKey.create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + BlockStoreDataReference clone() => BlockStoreDataReference()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + BlockStoreDataReference copyWith(void Function(BlockStoreDataReference) updates) => super.copyWith((message) => updates(message as BlockStoreDataReference)) as BlockStoreDataReference; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static BlockStoreDataReference create() => BlockStoreDataReference._(); + BlockStoreDataReference createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static BlockStoreDataReference getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static BlockStoreDataReference? _defaultInstance; + + @$pb.TagNumber(1) + $0.TypedKey get block => $_getN(0); + @$pb.TagNumber(1) + set block($0.TypedKey v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasBlock() => $_has(0); + @$pb.TagNumber(1) + void clearBlock() => clearField(1); + @$pb.TagNumber(1) + $0.TypedKey ensureBlock() => $_ensure(0); +} + +enum DataReference_Kind { + dhtData, + blockStoreData, + notSet +} + +/// DataReference +/// Pointer to data somewhere in Veilid +/// Abstraction over DHTData and BlockStore +class DataReference extends $pb.GeneratedMessage { + factory DataReference({ + DHTDataReference? dhtData, + BlockStoreDataReference? blockStoreData, + }) { + final $result = create(); + if (dhtData != null) { + $result.dhtData = dhtData; + } + if (blockStoreData != null) { + $result.blockStoreData = blockStoreData; + } + return $result; + } + DataReference._() : super(); + factory DataReference.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory DataReference.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, DataReference_Kind> _DataReference_KindByTag = { + 1 : DataReference_Kind.dhtData, + 2 : DataReference_Kind.blockStoreData, + 0 : DataReference_Kind.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DataReference', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..oo(0, [1, 2]) + ..aOM(1, _omitFieldNames ? '' : 'dhtData', subBuilder: DHTDataReference.create) + ..aOM(2, _omitFieldNames ? '' : 'blockStoreData', subBuilder: BlockStoreDataReference.create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + DataReference clone() => DataReference()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + DataReference copyWith(void Function(DataReference) updates) => super.copyWith((message) => updates(message as DataReference)) as DataReference; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static DataReference create() => DataReference._(); + DataReference createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static DataReference getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static DataReference? _defaultInstance; + + DataReference_Kind whichKind() => _DataReference_KindByTag[$_whichOneof(0)]!; + void clearKind() => clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + DHTDataReference get dhtData => $_getN(0); + @$pb.TagNumber(1) + set dhtData(DHTDataReference v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasDhtData() => $_has(0); + @$pb.TagNumber(1) + void clearDhtData() => clearField(1); + @$pb.TagNumber(1) + DHTDataReference ensureDhtData() => $_ensure(0); + + @$pb.TagNumber(2) + BlockStoreDataReference get blockStoreData => $_getN(1); + @$pb.TagNumber(2) + set blockStoreData(BlockStoreDataReference v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasBlockStoreData() => $_has(1); + @$pb.TagNumber(2) + void clearBlockStoreData() => clearField(2); + @$pb.TagNumber(2) + BlockStoreDataReference ensureBlockStoreData() => $_ensure(1); +} + enum Attachment_Kind { media, notSet } +/// A single attachment class Attachment extends $pb.GeneratedMessage { - factory Attachment() => create(); + factory Attachment({ + AttachmentMedia? media, + $0.Signature? signature, + }) { + final $result = create(); + if (media != null) { + $result.media = media; + } + if (signature != null) { + $result.signature = signature; + } + return $result; + } Attachment._() : super(); factory Attachment.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Attachment.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -77,6 +298,7 @@ class Attachment extends $pb.GeneratedMessage { @$pb.TagNumber(1) AttachmentMedia ensureMedia() => $_ensure(0); + /// Author signature over all attachment fields and content fields and bytes @$pb.TagNumber(2) $0.Signature get signature => $_getN(1); @$pb.TagNumber(2) @@ -89,8 +311,25 @@ class Attachment extends $pb.GeneratedMessage { $0.Signature ensureSignature() => $_ensure(1); } +/// A file, audio, image, or video attachment class AttachmentMedia extends $pb.GeneratedMessage { - factory AttachmentMedia() => create(); + factory AttachmentMedia({ + $core.String? mime, + $core.String? name, + DataReference? content, + }) { + final $result = create(); + if (mime != null) { + $result.mime = mime; + } + if (name != null) { + $result.name = name; + } + if (content != null) { + $result.content = content; + } + return $result; + } AttachmentMedia._() : super(); factory AttachmentMedia.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory AttachmentMedia.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -98,7 +337,7 @@ class AttachmentMedia extends $pb.GeneratedMessage { static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'AttachmentMedia', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) ..aOS(1, _omitFieldNames ? '' : 'mime') ..aOS(2, _omitFieldNames ? '' : 'name') - ..aOM<$1.DataReference>(3, _omitFieldNames ? '' : 'content', subBuilder: $1.DataReference.create) + ..aOM(3, _omitFieldNames ? '' : 'content', subBuilder: DataReference.create) ..hasRequiredFields = false ; @@ -123,6 +362,7 @@ class AttachmentMedia extends $pb.GeneratedMessage { static AttachmentMedia getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static AttachmentMedia? _defaultInstance; + /// MIME type of the data @$pb.TagNumber(1) $core.String get mime => $_getSZ(0); @$pb.TagNumber(1) @@ -132,6 +372,7 @@ class AttachmentMedia extends $pb.GeneratedMessage { @$pb.TagNumber(1) void clearMime() => clearField(1); + /// Title or filename @$pb.TagNumber(2) $core.String get name => $_getSZ(1); @$pb.TagNumber(2) @@ -141,20 +382,38 @@ class AttachmentMedia extends $pb.GeneratedMessage { @$pb.TagNumber(2) void clearName() => clearField(2); + /// Pointer to the data content @$pb.TagNumber(3) - $1.DataReference get content => $_getN(2); + DataReference get content => $_getN(2); @$pb.TagNumber(3) - set content($1.DataReference v) { setField(3, v); } + set content(DataReference v) { setField(3, v); } @$pb.TagNumber(3) $core.bool hasContent() => $_has(2); @$pb.TagNumber(3) void clearContent() => clearField(3); @$pb.TagNumber(3) - $1.DataReference ensureContent() => $_ensure(2); + DataReference ensureContent() => $_ensure(2); } +/// Permissions of a chat class Permissions extends $pb.GeneratedMessage { - factory Permissions() => create(); + factory Permissions({ + Scope? canAddMembers, + Scope? canEditInfo, + $core.bool? moderated, + }) { + final $result = create(); + if (canAddMembers != null) { + $result.canAddMembers = canAddMembers; + } + if (canEditInfo != null) { + $result.canEditInfo = canEditInfo; + } + if (moderated != null) { + $result.moderated = moderated; + } + return $result; + } Permissions._() : super(); factory Permissions.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Permissions.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -187,6 +446,7 @@ class Permissions extends $pb.GeneratedMessage { static Permissions getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static Permissions? _defaultInstance; + /// Parties in this scope or higher can add members to their own group or lower @$pb.TagNumber(1) Scope get canAddMembers => $_getN(0); @$pb.TagNumber(1) @@ -196,6 +456,7 @@ class Permissions extends $pb.GeneratedMessage { @$pb.TagNumber(1) void clearCanAddMembers() => clearField(1); + /// Parties in this scope or higher can change the 'info' of a group @$pb.TagNumber(2) Scope get canEditInfo => $_getN(1); @$pb.TagNumber(2) @@ -205,6 +466,7 @@ class Permissions extends $pb.GeneratedMessage { @$pb.TagNumber(2) void clearCanEditInfo() => clearField(2); + /// If moderation is enabled or not. @$pb.TagNumber(3) $core.bool get moderated => $_getBF(2); @$pb.TagNumber(3) @@ -215,8 +477,33 @@ class Permissions extends $pb.GeneratedMessage { void clearModerated() => clearField(3); } +/// The membership of a chat class Membership extends $pb.GeneratedMessage { - factory Membership() => create(); + factory Membership({ + $core.Iterable<$0.TypedKey>? watchers, + $core.Iterable<$0.TypedKey>? moderated, + $core.Iterable<$0.TypedKey>? talkers, + $core.Iterable<$0.TypedKey>? moderators, + $core.Iterable<$0.TypedKey>? admins, + }) { + final $result = create(); + if (watchers != null) { + $result.watchers.addAll(watchers); + } + if (moderated != null) { + $result.moderated.addAll(moderated); + } + if (talkers != null) { + $result.talkers.addAll(talkers); + } + if (moderators != null) { + $result.moderators.addAll(moderators); + } + if (admins != null) { + $result.admins.addAll(admins); + } + return $result; + } Membership._() : super(); factory Membership.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Membership.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -251,24 +538,50 @@ class Membership extends $pb.GeneratedMessage { static Membership getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static Membership? _defaultInstance; + /// Conversation keys for parties in the 'watchers' group @$pb.TagNumber(1) $core.List<$0.TypedKey> get watchers => $_getList(0); + /// Conversation keys for parties in the 'moderated' group @$pb.TagNumber(2) $core.List<$0.TypedKey> get moderated => $_getList(1); + /// Conversation keys for parties in the 'talkers' group @$pb.TagNumber(3) $core.List<$0.TypedKey> get talkers => $_getList(2); + /// Conversation keys for parties in the 'moderators' group @$pb.TagNumber(4) $core.List<$0.TypedKey> get moderators => $_getList(3); + /// Conversation keys for parties in the 'admins' group @$pb.TagNumber(5) $core.List<$0.TypedKey> get admins => $_getList(4); } +/// The chat settings class ChatSettings extends $pb.GeneratedMessage { - factory ChatSettings() => create(); + factory ChatSettings({ + $core.String? title, + $core.String? description, + DataReference? icon, + $fixnum.Int64? defaultExpiration, + }) { + final $result = create(); + if (title != null) { + $result.title = title; + } + if (description != null) { + $result.description = description; + } + if (icon != null) { + $result.icon = icon; + } + if (defaultExpiration != null) { + $result.defaultExpiration = defaultExpiration; + } + return $result; + } ChatSettings._() : super(); factory ChatSettings.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory ChatSettings.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -276,7 +589,7 @@ class ChatSettings extends $pb.GeneratedMessage { static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ChatSettings', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) ..aOS(1, _omitFieldNames ? '' : 'title') ..aOS(2, _omitFieldNames ? '' : 'description') - ..aOM<$1.DataReference>(3, _omitFieldNames ? '' : 'icon', subBuilder: $1.DataReference.create) + ..aOM(3, _omitFieldNames ? '' : 'icon', subBuilder: DataReference.create) ..a<$fixnum.Int64>(4, _omitFieldNames ? '' : 'defaultExpiration', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO) ..hasRequiredFields = false ; @@ -302,6 +615,7 @@ class ChatSettings extends $pb.GeneratedMessage { static ChatSettings getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static ChatSettings? _defaultInstance; + /// Title for the chat @$pb.TagNumber(1) $core.String get title => $_getSZ(0); @$pb.TagNumber(1) @@ -311,6 +625,7 @@ class ChatSettings extends $pb.GeneratedMessage { @$pb.TagNumber(1) void clearTitle() => clearField(1); + /// Description for the chat @$pb.TagNumber(2) $core.String get description => $_getSZ(1); @$pb.TagNumber(2) @@ -320,17 +635,19 @@ class ChatSettings extends $pb.GeneratedMessage { @$pb.TagNumber(2) void clearDescription() => clearField(2); + /// Icon for the chat @$pb.TagNumber(3) - $1.DataReference get icon => $_getN(2); + DataReference get icon => $_getN(2); @$pb.TagNumber(3) - set icon($1.DataReference v) { setField(3, v); } + set icon(DataReference v) { setField(3, v); } @$pb.TagNumber(3) $core.bool hasIcon() => $_has(2); @$pb.TagNumber(3) void clearIcon() => clearField(3); @$pb.TagNumber(3) - $1.DataReference ensureIcon() => $_ensure(2); + DataReference ensureIcon() => $_ensure(2); + /// Default message expiration duration (in us) @$pb.TagNumber(4) $fixnum.Int64 get defaultExpiration => $_getI64(3); @$pb.TagNumber(4) @@ -341,8 +658,37 @@ class ChatSettings extends $pb.GeneratedMessage { void clearDefaultExpiration() => clearField(4); } +/// A text message class Message_Text extends $pb.GeneratedMessage { - factory Message_Text() => create(); + factory Message_Text({ + $core.String? text, + $core.String? topic, + $core.List<$core.int>? replyId, + $fixnum.Int64? expiration, + $core.int? viewLimit, + $core.Iterable? attachments, + }) { + final $result = create(); + if (text != null) { + $result.text = text; + } + if (topic != null) { + $result.topic = topic; + } + if (replyId != null) { + $result.replyId = replyId; + } + if (expiration != null) { + $result.expiration = expiration; + } + if (viewLimit != null) { + $result.viewLimit = viewLimit; + } + if (attachments != null) { + $result.attachments.addAll(attachments); + } + return $result; + } Message_Text._() : super(); factory Message_Text.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Message_Text.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -378,6 +724,7 @@ class Message_Text extends $pb.GeneratedMessage { static Message_Text getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static Message_Text? _defaultInstance; + /// Text of the message @$pb.TagNumber(1) $core.String get text => $_getSZ(0); @$pb.TagNumber(1) @@ -387,6 +734,7 @@ class Message_Text extends $pb.GeneratedMessage { @$pb.TagNumber(1) void clearText() => clearField(1); + /// Topic of the message / Content warning @$pb.TagNumber(2) $core.String get topic => $_getSZ(1); @$pb.TagNumber(2) @@ -396,6 +744,7 @@ class Message_Text extends $pb.GeneratedMessage { @$pb.TagNumber(2) void clearTopic() => clearField(2); + /// Message id replied to (author id + message id) @$pb.TagNumber(3) $core.List<$core.int> get replyId => $_getN(2); @$pb.TagNumber(3) @@ -405,6 +754,7 @@ class Message_Text extends $pb.GeneratedMessage { @$pb.TagNumber(3) void clearReplyId() => clearField(3); + /// Message expiration timestamp @$pb.TagNumber(4) $fixnum.Int64 get expiration => $_getI64(3); @$pb.TagNumber(4) @@ -414,6 +764,7 @@ class Message_Text extends $pb.GeneratedMessage { @$pb.TagNumber(4) void clearExpiration() => clearField(4); + /// Message view limit before deletion @$pb.TagNumber(5) $core.int get viewLimit => $_getIZ(4); @$pb.TagNumber(5) @@ -423,12 +774,26 @@ class Message_Text extends $pb.GeneratedMessage { @$pb.TagNumber(5) void clearViewLimit() => clearField(5); + /// Attachments on the message @$pb.TagNumber(6) $core.List get attachments => $_getList(5); } +/// A secret message class Message_Secret extends $pb.GeneratedMessage { - factory Message_Secret() => create(); + factory Message_Secret({ + $core.List<$core.int>? ciphertext, + $fixnum.Int64? expiration, + }) { + final $result = create(); + if (ciphertext != null) { + $result.ciphertext = ciphertext; + } + if (expiration != null) { + $result.expiration = expiration; + } + return $result; + } Message_Secret._() : super(); factory Message_Secret.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Message_Secret.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -460,6 +825,7 @@ class Message_Secret extends $pb.GeneratedMessage { static Message_Secret getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static Message_Secret? _defaultInstance; + /// Text message protobuf encrypted by a key @$pb.TagNumber(1) $core.List<$core.int> get ciphertext => $_getN(0); @$pb.TagNumber(1) @@ -469,6 +835,8 @@ class Message_Secret extends $pb.GeneratedMessage { @$pb.TagNumber(1) void clearCiphertext() => clearField(1); + /// Secret expiration timestamp + /// This is the time after which an un-revealed secret will get deleted @$pb.TagNumber(2) $fixnum.Int64 get expiration => $_getI64(1); @$pb.TagNumber(2) @@ -479,8 +847,18 @@ class Message_Secret extends $pb.GeneratedMessage { void clearExpiration() => clearField(2); } +/// A 'delete' control message +/// Deletes a set of messages by their ids class Message_ControlDelete extends $pb.GeneratedMessage { - factory Message_ControlDelete() => create(); + factory Message_ControlDelete({ + $core.Iterable<$core.List<$core.int>>? ids, + }) { + final $result = create(); + if (ids != null) { + $result.ids.addAll(ids); + } + return $result; + } Message_ControlDelete._() : super(); factory Message_ControlDelete.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Message_ControlDelete.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -515,8 +893,18 @@ class Message_ControlDelete extends $pb.GeneratedMessage { $core.List<$core.List<$core.int>> get ids => $_getList(0); } +/// An 'erase' control message +/// Deletes a set of messages from before some timestamp class Message_ControlErase extends $pb.GeneratedMessage { - factory Message_ControlErase() => create(); + factory Message_ControlErase({ + $fixnum.Int64? timestamp, + }) { + final $result = create(); + if (timestamp != null) { + $result.timestamp = timestamp; + } + return $result; + } Message_ControlErase._() : super(); factory Message_ControlErase.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Message_ControlErase.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -547,6 +935,8 @@ class Message_ControlErase extends $pb.GeneratedMessage { static Message_ControlErase getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static Message_ControlErase? _defaultInstance; + /// The latest timestamp to delete messages before + /// If this is zero then all messages are cleared @$pb.TagNumber(1) $fixnum.Int64 get timestamp => $_getI64(0); @$pb.TagNumber(1) @@ -557,8 +947,17 @@ class Message_ControlErase extends $pb.GeneratedMessage { void clearTimestamp() => clearField(1); } +/// A 'change settings' control message class Message_ControlSettings extends $pb.GeneratedMessage { - factory Message_ControlSettings() => create(); + factory Message_ControlSettings({ + ChatSettings? settings, + }) { + final $result = create(); + if (settings != null) { + $result.settings = settings; + } + return $result; + } Message_ControlSettings._() : super(); factory Message_ControlSettings.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Message_ControlSettings.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -601,8 +1000,18 @@ class Message_ControlSettings extends $pb.GeneratedMessage { ChatSettings ensureSettings() => $_ensure(0); } +/// A 'change permissions' control message +/// Changes the permissions of a chat class Message_ControlPermissions extends $pb.GeneratedMessage { - factory Message_ControlPermissions() => create(); + factory Message_ControlPermissions({ + Permissions? permissions, + }) { + final $result = create(); + if (permissions != null) { + $result.permissions = permissions; + } + return $result; + } Message_ControlPermissions._() : super(); factory Message_ControlPermissions.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Message_ControlPermissions.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -645,8 +1054,18 @@ class Message_ControlPermissions extends $pb.GeneratedMessage { Permissions ensurePermissions() => $_ensure(0); } +/// A 'change membership' control message +/// Changes the class Message_ControlMembership extends $pb.GeneratedMessage { - factory Message_ControlMembership() => create(); + factory Message_ControlMembership({ + Membership? membership, + }) { + final $result = create(); + if (membership != null) { + $result.membership = membership; + } + return $result; + } Message_ControlMembership._() : super(); factory Message_ControlMembership.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Message_ControlMembership.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -689,8 +1108,22 @@ class Message_ControlMembership extends $pb.GeneratedMessage { Membership ensureMembership() => $_ensure(0); } +/// A 'moderation' control message +/// Accepts or rejects a set of messages class Message_ControlModeration extends $pb.GeneratedMessage { - factory Message_ControlModeration() => create(); + factory Message_ControlModeration({ + $core.Iterable<$core.List<$core.int>>? acceptedIds, + $core.Iterable<$core.List<$core.int>>? rejectedIds, + }) { + final $result = create(); + if (acceptedIds != null) { + $result.acceptedIds.addAll(acceptedIds); + } + if (rejectedIds != null) { + $result.rejectedIds.addAll(rejectedIds); + } + return $result; + } Message_ControlModeration._() : super(); factory Message_ControlModeration.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Message_ControlModeration.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -729,8 +1162,17 @@ class Message_ControlModeration extends $pb.GeneratedMessage { $core.List<$core.List<$core.int>> get rejectedIds => $_getList(1); } +/// A 'read receipt' control message class Message_ControlReadReceipt extends $pb.GeneratedMessage { - factory Message_ControlReadReceipt() => create(); + factory Message_ControlReadReceipt({ + $core.Iterable<$core.List<$core.int>>? readIds, + }) { + final $result = create(); + if (readIds != null) { + $result.readIds.addAll(readIds); + } + return $result; + } Message_ControlReadReceipt._() : super(); factory Message_ControlReadReceipt.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Message_ControlReadReceipt.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -774,11 +1216,69 @@ enum Message_Kind { permissions, membership, moderation, + readReceipt, notSet } +/// A single message as part of a series of messages class Message extends $pb.GeneratedMessage { - factory Message() => create(); + factory Message({ + $core.List<$core.int>? id, + $0.TypedKey? author, + $fixnum.Int64? timestamp, + Message_Text? text, + Message_Secret? secret, + Message_ControlDelete? delete, + Message_ControlErase? erase, + Message_ControlSettings? settings, + Message_ControlPermissions? permissions, + Message_ControlMembership? membership, + Message_ControlModeration? moderation, + $0.Signature? signature, + Message_ControlReadReceipt? readReceipt, + }) { + final $result = create(); + if (id != null) { + $result.id = id; + } + if (author != null) { + $result.author = author; + } + if (timestamp != null) { + $result.timestamp = timestamp; + } + if (text != null) { + $result.text = text; + } + if (secret != null) { + $result.secret = secret; + } + if (delete != null) { + $result.delete = delete; + } + if (erase != null) { + $result.erase = erase; + } + if (settings != null) { + $result.settings = settings; + } + if (permissions != null) { + $result.permissions = permissions; + } + if (membership != null) { + $result.membership = membership; + } + if (moderation != null) { + $result.moderation = moderation; + } + if (signature != null) { + $result.signature = signature; + } + if (readReceipt != null) { + $result.readReceipt = readReceipt; + } + return $result; + } Message._() : super(); factory Message.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Message.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -792,10 +1292,11 @@ class Message extends $pb.GeneratedMessage { 9 : Message_Kind.permissions, 10 : Message_Kind.membership, 11 : Message_Kind.moderation, + 13 : Message_Kind.readReceipt, 0 : Message_Kind.notSet }; static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Message', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) - ..oo(0, [4, 5, 6, 7, 8, 9, 10, 11]) + ..oo(0, [4, 5, 6, 7, 8, 9, 10, 11, 13]) ..a<$core.List<$core.int>>(1, _omitFieldNames ? '' : 'id', $pb.PbFieldType.OY) ..aOM<$0.TypedKey>(2, _omitFieldNames ? '' : 'author', subBuilder: $0.TypedKey.create) ..a<$fixnum.Int64>(3, _omitFieldNames ? '' : 'timestamp', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO) @@ -808,6 +1309,7 @@ class Message extends $pb.GeneratedMessage { ..aOM(10, _omitFieldNames ? '' : 'membership', subBuilder: Message_ControlMembership.create) ..aOM(11, _omitFieldNames ? '' : 'moderation', subBuilder: Message_ControlModeration.create) ..aOM<$0.Signature>(12, _omitFieldNames ? '' : 'signature', subBuilder: $0.Signature.create) + ..aOM(13, _omitFieldNames ? '' : 'readReceipt', protoName: 'readReceipt', subBuilder: Message_ControlReadReceipt.create) ..hasRequiredFields = false ; @@ -835,6 +1337,8 @@ class Message extends $pb.GeneratedMessage { Message_Kind whichKind() => _Message_KindByTag[$_whichOneof(0)]!; void clearKind() => clearField($_whichOneof(0)); + /// Unique id for this author stream + /// Calculated from the hash of the previous message from this author @$pb.TagNumber(1) $core.List<$core.int> get id => $_getN(0); @$pb.TagNumber(1) @@ -844,6 +1348,7 @@ class Message extends $pb.GeneratedMessage { @$pb.TagNumber(1) void clearId() => clearField(1); + /// Author of the message (identity public key) @$pb.TagNumber(2) $0.TypedKey get author => $_getN(1); @$pb.TagNumber(2) @@ -855,6 +1360,7 @@ class Message extends $pb.GeneratedMessage { @$pb.TagNumber(2) $0.TypedKey ensureAuthor() => $_ensure(1); + /// Time the message was sent according to sender @$pb.TagNumber(3) $fixnum.Int64 get timestamp => $_getI64(2); @$pb.TagNumber(3) @@ -952,6 +1458,7 @@ class Message extends $pb.GeneratedMessage { @$pb.TagNumber(11) Message_ControlModeration ensureModeration() => $_ensure(10); + /// Author signature over all of the fields and attachment signatures @$pb.TagNumber(12) $0.Signature get signature => $_getN(11); @$pb.TagNumber(12) @@ -962,10 +1469,34 @@ class Message extends $pb.GeneratedMessage { void clearSignature() => clearField(12); @$pb.TagNumber(12) $0.Signature ensureSignature() => $_ensure(11); + + @$pb.TagNumber(13) + Message_ControlReadReceipt get readReceipt => $_getN(12); + @$pb.TagNumber(13) + set readReceipt(Message_ControlReadReceipt v) { setField(13, v); } + @$pb.TagNumber(13) + $core.bool hasReadReceipt() => $_has(12); + @$pb.TagNumber(13) + void clearReadReceipt() => clearField(13); + @$pb.TagNumber(13) + Message_ControlReadReceipt ensureReadReceipt() => $_ensure(12); } +/// Locally stored messages for chats class ReconciledMessage extends $pb.GeneratedMessage { - factory ReconciledMessage() => create(); + factory ReconciledMessage({ + Message? content, + $fixnum.Int64? reconciledTime, + }) { + final $result = create(); + if (content != null) { + $result.content = content; + } + if (reconciledTime != null) { + $result.reconciledTime = reconciledTime; + } + return $result; + } ReconciledMessage._() : super(); factory ReconciledMessage.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory ReconciledMessage.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -997,6 +1528,7 @@ class ReconciledMessage extends $pb.GeneratedMessage { static ReconciledMessage getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static ReconciledMessage? _defaultInstance; + /// The message as sent @$pb.TagNumber(1) Message get content => $_getN(0); @$pb.TagNumber(1) @@ -1008,6 +1540,7 @@ class ReconciledMessage extends $pb.GeneratedMessage { @$pb.TagNumber(1) Message ensureContent() => $_ensure(0); + /// The timestamp the message was reconciled @$pb.TagNumber(2) $fixnum.Int64 get reconciledTime => $_getI64(1); @$pb.TagNumber(2) @@ -1018,8 +1551,36 @@ class ReconciledMessage extends $pb.GeneratedMessage { void clearReconciledTime() => clearField(2); } +/// The means of direct communications that is synchronized between +/// two users. Visible and encrypted for the other party. +/// Includes communications for: +/// * Profile changes +/// * Identity changes +/// * 1-1 chat messages +/// * Group chat messages +/// +/// DHT Schema: SMPL(0,1,[identityPublicKey]) +/// DHT Key (UnicastOutbox): localConversation +/// DHT Secret: None +/// Encryption: DH(IdentityA, IdentityB) class Conversation extends $pb.GeneratedMessage { - factory Conversation() => create(); + factory Conversation({ + Profile? profile, + $core.String? superIdentityJson, + $0.TypedKey? messages, + }) { + final $result = create(); + if (profile != null) { + $result.profile = profile; + } + if (superIdentityJson != null) { + $result.superIdentityJson = superIdentityJson; + } + if (messages != null) { + $result.messages = messages; + } + return $result; + } Conversation._() : super(); factory Conversation.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Conversation.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -1052,6 +1613,7 @@ class Conversation extends $pb.GeneratedMessage { static Conversation getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static Conversation? _defaultInstance; + /// Profile to publish to friend @$pb.TagNumber(1) Profile get profile => $_getN(0); @$pb.TagNumber(1) @@ -1063,6 +1625,7 @@ class Conversation extends $pb.GeneratedMessage { @$pb.TagNumber(1) Profile ensureProfile() => $_ensure(0); + /// SuperIdentity (JSON) to publish to friend or chat room @$pb.TagNumber(2) $core.String get superIdentityJson => $_getSZ(1); @$pb.TagNumber(2) @@ -1072,6 +1635,7 @@ class Conversation extends $pb.GeneratedMessage { @$pb.TagNumber(2) void clearSuperIdentityJson() => clearField(2); + /// Messages DHTLog @$pb.TagNumber(3) $0.TypedKey get messages => $_getN(2); @$pb.TagNumber(3) @@ -1084,16 +1648,28 @@ class Conversation extends $pb.GeneratedMessage { $0.TypedKey ensureMessages() => $_ensure(2); } -class Chat extends $pb.GeneratedMessage { - factory Chat() => create(); - Chat._() : super(); - factory Chat.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); - factory Chat.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); +/// A member of chat which may or may not be associated with a contact +class ChatMember extends $pb.GeneratedMessage { + factory ChatMember({ + $0.TypedKey? remoteIdentityPublicKey, + $0.TypedKey? remoteConversationRecordKey, + }) { + final $result = create(); + if (remoteIdentityPublicKey != null) { + $result.remoteIdentityPublicKey = remoteIdentityPublicKey; + } + if (remoteConversationRecordKey != null) { + $result.remoteConversationRecordKey = remoteConversationRecordKey; + } + return $result; + } + ChatMember._() : super(); + factory ChatMember.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory ChatMember.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); - static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Chat', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) - ..aOM(1, _omitFieldNames ? '' : 'settings', subBuilder: ChatSettings.create) - ..aOM<$0.TypedKey>(2, _omitFieldNames ? '' : 'localConversationRecordKey', subBuilder: $0.TypedKey.create) - ..aOM<$0.TypedKey>(3, _omitFieldNames ? '' : 'remoteConversationRecordKey', subBuilder: $0.TypedKey.create) + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ChatMember', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..aOM<$0.TypedKey>(1, _omitFieldNames ? '' : 'remoteIdentityPublicKey', subBuilder: $0.TypedKey.create) + ..aOM<$0.TypedKey>(2, _omitFieldNames ? '' : 'remoteConversationRecordKey', subBuilder: $0.TypedKey.create) ..hasRequiredFields = false ; @@ -1101,23 +1677,101 @@ class Chat extends $pb.GeneratedMessage { 'Using this can add significant overhead to your binary. ' 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' 'Will be removed in next major version') - Chat clone() => Chat()..mergeFromMessage(this); + ChatMember clone() => ChatMember()..mergeFromMessage(this); @$core.Deprecated( 'Using this can add significant overhead to your binary. ' 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' 'Will be removed in next major version') - Chat copyWith(void Function(Chat) updates) => super.copyWith((message) => updates(message as Chat)) as Chat; + ChatMember copyWith(void Function(ChatMember) updates) => super.copyWith((message) => updates(message as ChatMember)) as ChatMember; $pb.BuilderInfo get info_ => _i; @$core.pragma('dart2js:noInline') - static Chat create() => Chat._(); - Chat createEmptyInstance() => create(); - static $pb.PbList createRepeated() => $pb.PbList(); + static ChatMember create() => ChatMember._(); + ChatMember createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); @$core.pragma('dart2js:noInline') - static Chat getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); - static Chat? _defaultInstance; + static ChatMember getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static ChatMember? _defaultInstance; + /// The identity public key most recently associated with the chat member + @$pb.TagNumber(1) + $0.TypedKey get remoteIdentityPublicKey => $_getN(0); + @$pb.TagNumber(1) + set remoteIdentityPublicKey($0.TypedKey v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasRemoteIdentityPublicKey() => $_has(0); + @$pb.TagNumber(1) + void clearRemoteIdentityPublicKey() => clearField(1); + @$pb.TagNumber(1) + $0.TypedKey ensureRemoteIdentityPublicKey() => $_ensure(0); + + /// Conversation key for the other party + @$pb.TagNumber(2) + $0.TypedKey get remoteConversationRecordKey => $_getN(1); + @$pb.TagNumber(2) + set remoteConversationRecordKey($0.TypedKey v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasRemoteConversationRecordKey() => $_has(1); + @$pb.TagNumber(2) + void clearRemoteConversationRecordKey() => clearField(2); + @$pb.TagNumber(2) + $0.TypedKey ensureRemoteConversationRecordKey() => $_ensure(1); +} + +/// A 1-1 chat +/// Privately encrypted, this is the local user's copy of the chat +class DirectChat extends $pb.GeneratedMessage { + factory DirectChat({ + ChatSettings? settings, + $0.TypedKey? localConversationRecordKey, + ChatMember? remoteMember, + }) { + final $result = create(); + if (settings != null) { + $result.settings = settings; + } + if (localConversationRecordKey != null) { + $result.localConversationRecordKey = localConversationRecordKey; + } + if (remoteMember != null) { + $result.remoteMember = remoteMember; + } + return $result; + } + DirectChat._() : super(); + factory DirectChat.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory DirectChat.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DirectChat', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..aOM(1, _omitFieldNames ? '' : 'settings', subBuilder: ChatSettings.create) + ..aOM<$0.TypedKey>(2, _omitFieldNames ? '' : 'localConversationRecordKey', subBuilder: $0.TypedKey.create) + ..aOM(3, _omitFieldNames ? '' : 'remoteMember', subBuilder: ChatMember.create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + DirectChat clone() => DirectChat()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + DirectChat copyWith(void Function(DirectChat) updates) => super.copyWith((message) => updates(message as DirectChat)) as DirectChat; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static DirectChat create() => DirectChat._(); + DirectChat createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static DirectChat getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static DirectChat? _defaultInstance; + + /// Settings @$pb.TagNumber(1) ChatSettings get settings => $_getN(0); @$pb.TagNumber(1) @@ -1129,6 +1783,7 @@ class Chat extends $pb.GeneratedMessage { @$pb.TagNumber(1) ChatSettings ensureSettings() => $_ensure(0); + /// Conversation key for this user @$pb.TagNumber(2) $0.TypedKey get localConversationRecordKey => $_getN(1); @$pb.TagNumber(2) @@ -1140,28 +1795,57 @@ class Chat extends $pb.GeneratedMessage { @$pb.TagNumber(2) $0.TypedKey ensureLocalConversationRecordKey() => $_ensure(1); + /// Conversation key for the other party @$pb.TagNumber(3) - $0.TypedKey get remoteConversationRecordKey => $_getN(2); + ChatMember get remoteMember => $_getN(2); @$pb.TagNumber(3) - set remoteConversationRecordKey($0.TypedKey v) { setField(3, v); } + set remoteMember(ChatMember v) { setField(3, v); } @$pb.TagNumber(3) - $core.bool hasRemoteConversationRecordKey() => $_has(2); + $core.bool hasRemoteMember() => $_has(2); @$pb.TagNumber(3) - void clearRemoteConversationRecordKey() => clearField(3); + void clearRemoteMember() => clearField(3); @$pb.TagNumber(3) - $0.TypedKey ensureRemoteConversationRecordKey() => $_ensure(2); + ChatMember ensureRemoteMember() => $_ensure(2); } +/// A group chat +/// Privately encrypted, this is the local user's copy of the chat class GroupChat extends $pb.GeneratedMessage { - factory GroupChat() => create(); + factory GroupChat({ + ChatSettings? settings, + Membership? membership, + Permissions? permissions, + $0.TypedKey? localConversationRecordKey, + $core.Iterable? remoteMembers, + }) { + final $result = create(); + if (settings != null) { + $result.settings = settings; + } + if (membership != null) { + $result.membership = membership; + } + if (permissions != null) { + $result.permissions = permissions; + } + if (localConversationRecordKey != null) { + $result.localConversationRecordKey = localConversationRecordKey; + } + if (remoteMembers != null) { + $result.remoteMembers.addAll(remoteMembers); + } + return $result; + } GroupChat._() : super(); factory GroupChat.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory GroupChat.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'GroupChat', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) ..aOM(1, _omitFieldNames ? '' : 'settings', subBuilder: ChatSettings.create) - ..aOM<$0.TypedKey>(2, _omitFieldNames ? '' : 'localConversationRecordKey', subBuilder: $0.TypedKey.create) - ..pc<$0.TypedKey>(3, _omitFieldNames ? '' : 'remoteConversationRecordKeys', $pb.PbFieldType.PM, subBuilder: $0.TypedKey.create) + ..aOM(2, _omitFieldNames ? '' : 'membership', subBuilder: Membership.create) + ..aOM(3, _omitFieldNames ? '' : 'permissions', subBuilder: Permissions.create) + ..aOM<$0.TypedKey>(4, _omitFieldNames ? '' : 'localConversationRecordKey', subBuilder: $0.TypedKey.create) + ..pc(5, _omitFieldNames ? '' : 'remoteMembers', $pb.PbFieldType.PM, subBuilder: ChatMember.create) ..hasRequiredFields = false ; @@ -1186,6 +1870,7 @@ class GroupChat extends $pb.GeneratedMessage { static GroupChat getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static GroupChat? _defaultInstance; + /// Settings @$pb.TagNumber(1) ChatSettings get settings => $_getN(0); @$pb.TagNumber(1) @@ -1197,23 +1882,170 @@ class GroupChat extends $pb.GeneratedMessage { @$pb.TagNumber(1) ChatSettings ensureSettings() => $_ensure(0); + /// Membership @$pb.TagNumber(2) - $0.TypedKey get localConversationRecordKey => $_getN(1); + Membership get membership => $_getN(1); @$pb.TagNumber(2) - set localConversationRecordKey($0.TypedKey v) { setField(2, v); } + set membership(Membership v) { setField(2, v); } @$pb.TagNumber(2) - $core.bool hasLocalConversationRecordKey() => $_has(1); + $core.bool hasMembership() => $_has(1); @$pb.TagNumber(2) - void clearLocalConversationRecordKey() => clearField(2); + void clearMembership() => clearField(2); @$pb.TagNumber(2) - $0.TypedKey ensureLocalConversationRecordKey() => $_ensure(1); + Membership ensureMembership() => $_ensure(1); + /// Permissions @$pb.TagNumber(3) - $core.List<$0.TypedKey> get remoteConversationRecordKeys => $_getList(2); + Permissions get permissions => $_getN(2); + @$pb.TagNumber(3) + set permissions(Permissions v) { setField(3, v); } + @$pb.TagNumber(3) + $core.bool hasPermissions() => $_has(2); + @$pb.TagNumber(3) + void clearPermissions() => clearField(3); + @$pb.TagNumber(3) + Permissions ensurePermissions() => $_ensure(2); + + /// Conversation key for this user + @$pb.TagNumber(4) + $0.TypedKey get localConversationRecordKey => $_getN(3); + @$pb.TagNumber(4) + set localConversationRecordKey($0.TypedKey v) { setField(4, v); } + @$pb.TagNumber(4) + $core.bool hasLocalConversationRecordKey() => $_has(3); + @$pb.TagNumber(4) + void clearLocalConversationRecordKey() => clearField(4); + @$pb.TagNumber(4) + $0.TypedKey ensureLocalConversationRecordKey() => $_ensure(3); + + /// Conversation keys for the other parties + @$pb.TagNumber(5) + $core.List get remoteMembers => $_getList(4); } +enum Chat_Kind { + direct, + group, + notSet +} + +/// Some kind of chat +class Chat extends $pb.GeneratedMessage { + factory Chat({ + DirectChat? direct, + GroupChat? group, + }) { + final $result = create(); + if (direct != null) { + $result.direct = direct; + } + if (group != null) { + $result.group = group; + } + return $result; + } + Chat._() : super(); + factory Chat.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory Chat.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, Chat_Kind> _Chat_KindByTag = { + 1 : Chat_Kind.direct, + 2 : Chat_Kind.group, + 0 : Chat_Kind.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Chat', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..oo(0, [1, 2]) + ..aOM(1, _omitFieldNames ? '' : 'direct', subBuilder: DirectChat.create) + ..aOM(2, _omitFieldNames ? '' : 'group', subBuilder: GroupChat.create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + Chat clone() => Chat()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + Chat copyWith(void Function(Chat) updates) => super.copyWith((message) => updates(message as Chat)) as Chat; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Chat create() => Chat._(); + Chat createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static Chat getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static Chat? _defaultInstance; + + Chat_Kind whichKind() => _Chat_KindByTag[$_whichOneof(0)]!; + void clearKind() => clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + DirectChat get direct => $_getN(0); + @$pb.TagNumber(1) + set direct(DirectChat v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasDirect() => $_has(0); + @$pb.TagNumber(1) + void clearDirect() => clearField(1); + @$pb.TagNumber(1) + DirectChat ensureDirect() => $_ensure(0); + + @$pb.TagNumber(2) + GroupChat get group => $_getN(1); + @$pb.TagNumber(2) + set group(GroupChat v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasGroup() => $_has(1); + @$pb.TagNumber(2) + void clearGroup() => clearField(2); + @$pb.TagNumber(2) + GroupChat ensureGroup() => $_ensure(1); +} + +/// Publicly shared profile information for both contacts and accounts +/// Contains: +/// Name - Friendly name +/// Pronouns - Pronouns of user +/// Icon - Little picture to represent user in contact list class Profile extends $pb.GeneratedMessage { - factory Profile() => create(); + factory Profile({ + $core.String? name, + $core.String? pronouns, + $core.String? about, + $core.String? status, + Availability? availability, + DataReference? avatar, + $fixnum.Int64? timestamp, + }) { + final $result = create(); + if (name != null) { + $result.name = name; + } + if (pronouns != null) { + $result.pronouns = pronouns; + } + if (about != null) { + $result.about = about; + } + if (status != null) { + $result.status = status; + } + if (availability != null) { + $result.availability = availability; + } + if (avatar != null) { + $result.avatar = avatar; + } + if (timestamp != null) { + $result.timestamp = timestamp; + } + return $result; + } Profile._() : super(); factory Profile.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Profile.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -1224,7 +2056,8 @@ class Profile extends $pb.GeneratedMessage { ..aOS(3, _omitFieldNames ? '' : 'about') ..aOS(4, _omitFieldNames ? '' : 'status') ..e(5, _omitFieldNames ? '' : 'availability', $pb.PbFieldType.OE, defaultOrMaker: Availability.AVAILABILITY_UNSPECIFIED, valueOf: Availability.valueOf, enumValues: Availability.values) - ..aOM<$0.TypedKey>(6, _omitFieldNames ? '' : 'avatar', subBuilder: $0.TypedKey.create) + ..aOM(6, _omitFieldNames ? '' : 'avatar', subBuilder: DataReference.create) + ..a<$fixnum.Int64>(7, _omitFieldNames ? '' : 'timestamp', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO) ..hasRequiredFields = false ; @@ -1249,6 +2082,7 @@ class Profile extends $pb.GeneratedMessage { static Profile getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static Profile? _defaultInstance; + /// Friendy name (max length 64) @$pb.TagNumber(1) $core.String get name => $_getSZ(0); @$pb.TagNumber(1) @@ -1258,6 +2092,7 @@ class Profile extends $pb.GeneratedMessage { @$pb.TagNumber(1) void clearName() => clearField(1); + /// Pronouns of user (max length 64) @$pb.TagNumber(2) $core.String get pronouns => $_getSZ(1); @$pb.TagNumber(2) @@ -1267,6 +2102,7 @@ class Profile extends $pb.GeneratedMessage { @$pb.TagNumber(2) void clearPronouns() => clearField(2); + /// Description of the user (max length 1024) @$pb.TagNumber(3) $core.String get about => $_getSZ(2); @$pb.TagNumber(3) @@ -1276,6 +2112,7 @@ class Profile extends $pb.GeneratedMessage { @$pb.TagNumber(3) void clearAbout() => clearField(3); + /// Status/away message (max length 128) @$pb.TagNumber(4) $core.String get status => $_getSZ(3); @$pb.TagNumber(4) @@ -1285,6 +2122,7 @@ class Profile extends $pb.GeneratedMessage { @$pb.TagNumber(4) void clearStatus() => clearField(4); + /// Availability @$pb.TagNumber(5) Availability get availability => $_getN(4); @$pb.TagNumber(5) @@ -1294,20 +2132,84 @@ class Profile extends $pb.GeneratedMessage { @$pb.TagNumber(5) void clearAvailability() => clearField(5); + /// Avatar @$pb.TagNumber(6) - $0.TypedKey get avatar => $_getN(5); + DataReference get avatar => $_getN(5); @$pb.TagNumber(6) - set avatar($0.TypedKey v) { setField(6, v); } + set avatar(DataReference v) { setField(6, v); } @$pb.TagNumber(6) $core.bool hasAvatar() => $_has(5); @$pb.TagNumber(6) void clearAvatar() => clearField(6); @$pb.TagNumber(6) - $0.TypedKey ensureAvatar() => $_ensure(5); + DataReference ensureAvatar() => $_ensure(5); + + /// Timestamp of last change + @$pb.TagNumber(7) + $fixnum.Int64 get timestamp => $_getI64(6); + @$pb.TagNumber(7) + set timestamp($fixnum.Int64 v) { $_setInt64(6, v); } + @$pb.TagNumber(7) + $core.bool hasTimestamp() => $_has(6); + @$pb.TagNumber(7) + void clearTimestamp() => clearField(7); } +/// A record of an individual account +/// Pointed to by the identity account map in the identity key +/// +/// DHT Schema: DFLT(1) +/// DHT Private: accountSecretKey class Account extends $pb.GeneratedMessage { - factory Account() => create(); + factory Account({ + Profile? profile, + $core.bool? invisible, + $core.int? autoAwayTimeoutMin, + $1.OwnedDHTRecordPointer? contactList, + $1.OwnedDHTRecordPointer? contactInvitationRecords, + $1.OwnedDHTRecordPointer? chatList, + $1.OwnedDHTRecordPointer? groupChatList, + $core.String? freeMessage, + $core.String? busyMessage, + $core.String? awayMessage, + $core.bool? autodetectAway, + }) { + final $result = create(); + if (profile != null) { + $result.profile = profile; + } + if (invisible != null) { + $result.invisible = invisible; + } + if (autoAwayTimeoutMin != null) { + $result.autoAwayTimeoutMin = autoAwayTimeoutMin; + } + if (contactList != null) { + $result.contactList = contactList; + } + if (contactInvitationRecords != null) { + $result.contactInvitationRecords = contactInvitationRecords; + } + if (chatList != null) { + $result.chatList = chatList; + } + if (groupChatList != null) { + $result.groupChatList = groupChatList; + } + if (freeMessage != null) { + $result.freeMessage = freeMessage; + } + if (busyMessage != null) { + $result.busyMessage = busyMessage; + } + if (awayMessage != null) { + $result.awayMessage = awayMessage; + } + if (autodetectAway != null) { + $result.autodetectAway = autodetectAway; + } + return $result; + } Account._() : super(); factory Account.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Account.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -1315,11 +2217,15 @@ class Account extends $pb.GeneratedMessage { static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Account', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) ..aOM(1, _omitFieldNames ? '' : 'profile', subBuilder: Profile.create) ..aOB(2, _omitFieldNames ? '' : 'invisible') - ..a<$core.int>(3, _omitFieldNames ? '' : 'autoAwayTimeoutSec', $pb.PbFieldType.OU3) + ..a<$core.int>(3, _omitFieldNames ? '' : 'autoAwayTimeoutMin', $pb.PbFieldType.OU3) ..aOM<$1.OwnedDHTRecordPointer>(4, _omitFieldNames ? '' : 'contactList', subBuilder: $1.OwnedDHTRecordPointer.create) ..aOM<$1.OwnedDHTRecordPointer>(5, _omitFieldNames ? '' : 'contactInvitationRecords', subBuilder: $1.OwnedDHTRecordPointer.create) ..aOM<$1.OwnedDHTRecordPointer>(6, _omitFieldNames ? '' : 'chatList', subBuilder: $1.OwnedDHTRecordPointer.create) ..aOM<$1.OwnedDHTRecordPointer>(7, _omitFieldNames ? '' : 'groupChatList', subBuilder: $1.OwnedDHTRecordPointer.create) + ..aOS(8, _omitFieldNames ? '' : 'freeMessage') + ..aOS(9, _omitFieldNames ? '' : 'busyMessage') + ..aOS(10, _omitFieldNames ? '' : 'awayMessage') + ..aOB(11, _omitFieldNames ? '' : 'autodetectAway') ..hasRequiredFields = false ; @@ -1344,6 +2250,7 @@ class Account extends $pb.GeneratedMessage { static Account getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static Account? _defaultInstance; + /// The user's profile that gets shared with contacts @$pb.TagNumber(1) Profile get profile => $_getN(0); @$pb.TagNumber(1) @@ -1355,6 +2262,7 @@ class Account extends $pb.GeneratedMessage { @$pb.TagNumber(1) Profile ensureProfile() => $_ensure(0); + /// Invisibility makes you always look 'Offline' @$pb.TagNumber(2) $core.bool get invisible => $_getBF(1); @$pb.TagNumber(2) @@ -1364,15 +2272,18 @@ class Account extends $pb.GeneratedMessage { @$pb.TagNumber(2) void clearInvisible() => clearField(2); + /// Auto-away sets 'away' mode after an inactivity time (only if autodetect_away is set) @$pb.TagNumber(3) - $core.int get autoAwayTimeoutSec => $_getIZ(2); + $core.int get autoAwayTimeoutMin => $_getIZ(2); @$pb.TagNumber(3) - set autoAwayTimeoutSec($core.int v) { $_setUnsignedInt32(2, v); } + set autoAwayTimeoutMin($core.int v) { $_setUnsignedInt32(2, v); } @$pb.TagNumber(3) - $core.bool hasAutoAwayTimeoutSec() => $_has(2); + $core.bool hasAutoAwayTimeoutMin() => $_has(2); @$pb.TagNumber(3) - void clearAutoAwayTimeoutSec() => clearField(3); + void clearAutoAwayTimeoutMin() => clearField(3); + /// The contacts DHTList for this account + /// DHT Private @$pb.TagNumber(4) $1.OwnedDHTRecordPointer get contactList => $_getN(3); @$pb.TagNumber(4) @@ -1384,6 +2295,8 @@ class Account extends $pb.GeneratedMessage { @$pb.TagNumber(4) $1.OwnedDHTRecordPointer ensureContactList() => $_ensure(3); + /// The ContactInvitationRecord DHTShortArray for this account + /// DHT Private @$pb.TagNumber(5) $1.OwnedDHTRecordPointer get contactInvitationRecords => $_getN(4); @$pb.TagNumber(5) @@ -1395,6 +2308,8 @@ class Account extends $pb.GeneratedMessage { @$pb.TagNumber(5) $1.OwnedDHTRecordPointer ensureContactInvitationRecords() => $_ensure(4); + /// The Chats DHTList for this account + /// DHT Private @$pb.TagNumber(6) $1.OwnedDHTRecordPointer get chatList => $_getN(5); @$pb.TagNumber(6) @@ -1406,6 +2321,8 @@ class Account extends $pb.GeneratedMessage { @$pb.TagNumber(6) $1.OwnedDHTRecordPointer ensureChatList() => $_ensure(5); + /// The GroupChats DHTList for this account + /// DHT Private @$pb.TagNumber(7) $1.OwnedDHTRecordPointer get groupChatList => $_getN(6); @$pb.TagNumber(7) @@ -1416,22 +2333,106 @@ class Account extends $pb.GeneratedMessage { void clearGroupChatList() => clearField(7); @$pb.TagNumber(7) $1.OwnedDHTRecordPointer ensureGroupChatList() => $_ensure(6); + + /// Free message (max length 128) + @$pb.TagNumber(8) + $core.String get freeMessage => $_getSZ(7); + @$pb.TagNumber(8) + set freeMessage($core.String v) { $_setString(7, v); } + @$pb.TagNumber(8) + $core.bool hasFreeMessage() => $_has(7); + @$pb.TagNumber(8) + void clearFreeMessage() => clearField(8); + + /// Busy message (max length 128) + @$pb.TagNumber(9) + $core.String get busyMessage => $_getSZ(8); + @$pb.TagNumber(9) + set busyMessage($core.String v) { $_setString(8, v); } + @$pb.TagNumber(9) + $core.bool hasBusyMessage() => $_has(8); + @$pb.TagNumber(9) + void clearBusyMessage() => clearField(9); + + /// Away message (max length 128) + @$pb.TagNumber(10) + $core.String get awayMessage => $_getSZ(9); + @$pb.TagNumber(10) + set awayMessage($core.String v) { $_setString(9, v); } + @$pb.TagNumber(10) + $core.bool hasAwayMessage() => $_has(9); + @$pb.TagNumber(10) + void clearAwayMessage() => clearField(10); + + /// Auto-detect away + @$pb.TagNumber(11) + $core.bool get autodetectAway => $_getBF(10); + @$pb.TagNumber(11) + set autodetectAway($core.bool v) { $_setBool(10, v); } + @$pb.TagNumber(11) + $core.bool hasAutodetectAway() => $_has(10); + @$pb.TagNumber(11) + void clearAutodetectAway() => clearField(11); } +/// A record of a contact that has accepted a contact invitation +/// Contains a copy of the most recent remote profile as well as +/// a locally edited profile. +/// Contains a copy of the most recent identity from the contact's +/// Master identity dht key +/// +/// Stored in ContactList DHTList class Contact extends $pb.GeneratedMessage { - factory Contact() => create(); + factory Contact({ + $core.String? nickname, + Profile? profile, + $core.String? superIdentityJson, + $0.TypedKey? identityPublicKey, + $0.TypedKey? remoteConversationRecordKey, + $0.TypedKey? localConversationRecordKey, + $core.bool? showAvailability, + $core.String? notes, + }) { + final $result = create(); + if (nickname != null) { + $result.nickname = nickname; + } + if (profile != null) { + $result.profile = profile; + } + if (superIdentityJson != null) { + $result.superIdentityJson = superIdentityJson; + } + if (identityPublicKey != null) { + $result.identityPublicKey = identityPublicKey; + } + if (remoteConversationRecordKey != null) { + $result.remoteConversationRecordKey = remoteConversationRecordKey; + } + if (localConversationRecordKey != null) { + $result.localConversationRecordKey = localConversationRecordKey; + } + if (showAvailability != null) { + $result.showAvailability = showAvailability; + } + if (notes != null) { + $result.notes = notes; + } + return $result; + } Contact._() : super(); factory Contact.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Contact.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Contact', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) - ..aOM(1, _omitFieldNames ? '' : 'editedProfile', subBuilder: Profile.create) - ..aOM(2, _omitFieldNames ? '' : 'remoteProfile', subBuilder: Profile.create) + ..aOS(1, _omitFieldNames ? '' : 'nickname') + ..aOM(2, _omitFieldNames ? '' : 'profile', subBuilder: Profile.create) ..aOS(3, _omitFieldNames ? '' : 'superIdentityJson') ..aOM<$0.TypedKey>(4, _omitFieldNames ? '' : 'identityPublicKey', subBuilder: $0.TypedKey.create) ..aOM<$0.TypedKey>(5, _omitFieldNames ? '' : 'remoteConversationRecordKey', subBuilder: $0.TypedKey.create) ..aOM<$0.TypedKey>(6, _omitFieldNames ? '' : 'localConversationRecordKey', subBuilder: $0.TypedKey.create) ..aOB(7, _omitFieldNames ? '' : 'showAvailability') + ..aOS(8, _omitFieldNames ? '' : 'notes') ..hasRequiredFields = false ; @@ -1456,28 +2457,29 @@ class Contact extends $pb.GeneratedMessage { static Contact getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static Contact? _defaultInstance; + /// Friend's nickname @$pb.TagNumber(1) - Profile get editedProfile => $_getN(0); + $core.String get nickname => $_getSZ(0); @$pb.TagNumber(1) - set editedProfile(Profile v) { setField(1, v); } + set nickname($core.String v) { $_setString(0, v); } @$pb.TagNumber(1) - $core.bool hasEditedProfile() => $_has(0); + $core.bool hasNickname() => $_has(0); @$pb.TagNumber(1) - void clearEditedProfile() => clearField(1); - @$pb.TagNumber(1) - Profile ensureEditedProfile() => $_ensure(0); + void clearNickname() => clearField(1); + /// Copy of friend's profile from remote conversation @$pb.TagNumber(2) - Profile get remoteProfile => $_getN(1); + Profile get profile => $_getN(1); @$pb.TagNumber(2) - set remoteProfile(Profile v) { setField(2, v); } + set profile(Profile v) { setField(2, v); } @$pb.TagNumber(2) - $core.bool hasRemoteProfile() => $_has(1); + $core.bool hasProfile() => $_has(1); @$pb.TagNumber(2) - void clearRemoteProfile() => clearField(2); + void clearProfile() => clearField(2); @$pb.TagNumber(2) - Profile ensureRemoteProfile() => $_ensure(1); + Profile ensureProfile() => $_ensure(1); + /// Copy of friend's SuperIdentity in JSON from remote conversation @$pb.TagNumber(3) $core.String get superIdentityJson => $_getSZ(2); @$pb.TagNumber(3) @@ -1487,6 +2489,7 @@ class Contact extends $pb.GeneratedMessage { @$pb.TagNumber(3) void clearSuperIdentityJson() => clearField(3); + /// Copy of friend's most recent identity public key from their identityMaster @$pb.TagNumber(4) $0.TypedKey get identityPublicKey => $_getN(3); @$pb.TagNumber(4) @@ -1498,6 +2501,7 @@ class Contact extends $pb.GeneratedMessage { @$pb.TagNumber(4) $0.TypedKey ensureIdentityPublicKey() => $_ensure(3); + /// Remote conversation key to sync from friend @$pb.TagNumber(5) $0.TypedKey get remoteConversationRecordKey => $_getN(4); @$pb.TagNumber(5) @@ -1509,6 +2513,7 @@ class Contact extends $pb.GeneratedMessage { @$pb.TagNumber(5) $0.TypedKey ensureRemoteConversationRecordKey() => $_ensure(4); + /// Our conversation key for friend to sync @$pb.TagNumber(6) $0.TypedKey get localConversationRecordKey => $_getN(5); @$pb.TagNumber(6) @@ -1520,6 +2525,7 @@ class Contact extends $pb.GeneratedMessage { @$pb.TagNumber(6) $0.TypedKey ensureLocalConversationRecordKey() => $_ensure(5); + /// Show availability to this contact @$pb.TagNumber(7) $core.bool get showAvailability => $_getBF(6); @$pb.TagNumber(7) @@ -1528,10 +2534,36 @@ class Contact extends $pb.GeneratedMessage { $core.bool hasShowAvailability() => $_has(6); @$pb.TagNumber(7) void clearShowAvailability() => clearField(7); + + /// Notes about this friend + @$pb.TagNumber(8) + $core.String get notes => $_getSZ(7); + @$pb.TagNumber(8) + set notes($core.String v) { $_setString(7, v); } + @$pb.TagNumber(8) + $core.bool hasNotes() => $_has(7); + @$pb.TagNumber(8) + void clearNotes() => clearField(8); } +/// Invitation that is shared for VeilidChat contact connections +/// serialized to QR code or data blob, not send over DHT, out of band. +/// Writer secret is unique to this invitation. Writer public key is in the ContactRequestPrivate +/// in the ContactRequestInbox subkey 0 DHT key class ContactInvitation extends $pb.GeneratedMessage { - factory ContactInvitation() => create(); + factory ContactInvitation({ + $0.TypedKey? contactRequestInboxKey, + $core.List<$core.int>? writerSecret, + }) { + final $result = create(); + if (contactRequestInboxKey != null) { + $result.contactRequestInboxKey = contactRequestInboxKey; + } + if (writerSecret != null) { + $result.writerSecret = writerSecret; + } + return $result; + } ContactInvitation._() : super(); factory ContactInvitation.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory ContactInvitation.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -1563,6 +2595,7 @@ class ContactInvitation extends $pb.GeneratedMessage { static ContactInvitation getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static ContactInvitation? _defaultInstance; + /// Contact request DHT record key @$pb.TagNumber(1) $0.TypedKey get contactRequestInboxKey => $_getN(0); @$pb.TagNumber(1) @@ -1574,6 +2607,7 @@ class ContactInvitation extends $pb.GeneratedMessage { @$pb.TagNumber(1) $0.TypedKey ensureContactRequestInboxKey() => $_ensure(0); + /// Writer secret key bytes possibly encrypted with nonce appended @$pb.TagNumber(2) $core.List<$core.int> get writerSecret => $_getN(1); @$pb.TagNumber(2) @@ -1584,8 +2618,21 @@ class ContactInvitation extends $pb.GeneratedMessage { void clearWriterSecret() => clearField(2); } +/// Signature of invitation with identity class SignedContactInvitation extends $pb.GeneratedMessage { - factory SignedContactInvitation() => create(); + factory SignedContactInvitation({ + $core.List<$core.int>? contactInvitation, + $0.Signature? identitySignature, + }) { + final $result = create(); + if (contactInvitation != null) { + $result.contactInvitation = contactInvitation; + } + if (identitySignature != null) { + $result.identitySignature = identitySignature; + } + return $result; + } SignedContactInvitation._() : super(); factory SignedContactInvitation.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory SignedContactInvitation.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -1617,6 +2664,7 @@ class SignedContactInvitation extends $pb.GeneratedMessage { static SignedContactInvitation getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static SignedContactInvitation? _defaultInstance; + /// The serialized bytes for the contact invitation @$pb.TagNumber(1) $core.List<$core.int> get contactInvitation => $_getN(0); @$pb.TagNumber(1) @@ -1626,6 +2674,7 @@ class SignedContactInvitation extends $pb.GeneratedMessage { @$pb.TagNumber(1) void clearContactInvitation() => clearField(1); + /// The signature of the contact_invitation bytes with the identity @$pb.TagNumber(2) $0.Signature get identitySignature => $_getN(1); @$pb.TagNumber(2) @@ -1638,8 +2687,22 @@ class SignedContactInvitation extends $pb.GeneratedMessage { $0.Signature ensureIdentitySignature() => $_ensure(1); } +/// Contact request unicastinbox on the DHT +/// DHTSchema: SMPL 1 owner key, 1 writer key symmetrically encrypted with writer secret class ContactRequest extends $pb.GeneratedMessage { - factory ContactRequest() => create(); + factory ContactRequest({ + EncryptionKeyType? encryptionKeyType, + $core.List<$core.int>? private, + }) { + final $result = create(); + if (encryptionKeyType != null) { + $result.encryptionKeyType = encryptionKeyType; + } + if (private != null) { + $result.private = private; + } + return $result; + } ContactRequest._() : super(); factory ContactRequest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory ContactRequest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -1671,6 +2734,7 @@ class ContactRequest extends $pb.GeneratedMessage { static ContactRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static ContactRequest? _defaultInstance; + /// The kind of encryption used on the unicastinbox writer key @$pb.TagNumber(1) EncryptionKeyType get encryptionKeyType => $_getN(0); @$pb.TagNumber(1) @@ -1680,6 +2744,7 @@ class ContactRequest extends $pb.GeneratedMessage { @$pb.TagNumber(1) void clearEncryptionKeyType() => clearField(1); + /// The private part encoded and symmetrically encrypted with the unicastinbox writer secret @$pb.TagNumber(2) $core.List<$core.int> get private => $_getN(1); @$pb.TagNumber(2) @@ -1690,8 +2755,34 @@ class ContactRequest extends $pb.GeneratedMessage { void clearPrivate() => clearField(2); } +/// The private part of a possibly encrypted contact request +/// Symmetrically encrypted with writer secret class ContactRequestPrivate extends $pb.GeneratedMessage { - factory ContactRequestPrivate() => create(); + factory ContactRequestPrivate({ + $0.CryptoKey? writerKey, + Profile? profile, + $0.TypedKey? superIdentityRecordKey, + $0.TypedKey? chatRecordKey, + $fixnum.Int64? expiration, + }) { + final $result = create(); + if (writerKey != null) { + $result.writerKey = writerKey; + } + if (profile != null) { + $result.profile = profile; + } + if (superIdentityRecordKey != null) { + $result.superIdentityRecordKey = superIdentityRecordKey; + } + if (chatRecordKey != null) { + $result.chatRecordKey = chatRecordKey; + } + if (expiration != null) { + $result.expiration = expiration; + } + return $result; + } ContactRequestPrivate._() : super(); factory ContactRequestPrivate.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory ContactRequestPrivate.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -1726,6 +2817,7 @@ class ContactRequestPrivate extends $pb.GeneratedMessage { static ContactRequestPrivate getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static ContactRequestPrivate? _defaultInstance; + /// Writer public key for signing writes to contact request unicastinbox @$pb.TagNumber(1) $0.CryptoKey get writerKey => $_getN(0); @$pb.TagNumber(1) @@ -1737,6 +2829,7 @@ class ContactRequestPrivate extends $pb.GeneratedMessage { @$pb.TagNumber(1) $0.CryptoKey ensureWriterKey() => $_ensure(0); + /// Snapshot of profile @$pb.TagNumber(2) Profile get profile => $_getN(1); @$pb.TagNumber(2) @@ -1748,6 +2841,7 @@ class ContactRequestPrivate extends $pb.GeneratedMessage { @$pb.TagNumber(2) Profile ensureProfile() => $_ensure(1); + /// SuperIdentity DHT record key @$pb.TagNumber(3) $0.TypedKey get superIdentityRecordKey => $_getN(2); @$pb.TagNumber(3) @@ -1759,6 +2853,7 @@ class ContactRequestPrivate extends $pb.GeneratedMessage { @$pb.TagNumber(3) $0.TypedKey ensureSuperIdentityRecordKey() => $_ensure(2); + /// Local chat DHT record key @$pb.TagNumber(4) $0.TypedKey get chatRecordKey => $_getN(3); @$pb.TagNumber(4) @@ -1770,6 +2865,7 @@ class ContactRequestPrivate extends $pb.GeneratedMessage { @$pb.TagNumber(4) $0.TypedKey ensureChatRecordKey() => $_ensure(3); + /// Expiration timestamp @$pb.TagNumber(5) $fixnum.Int64 get expiration => $_getI64(4); @$pb.TagNumber(5) @@ -1780,8 +2876,25 @@ class ContactRequestPrivate extends $pb.GeneratedMessage { void clearExpiration() => clearField(5); } +/// To accept or reject a contact request, fill this out and send to the ContactRequest unicastinbox class ContactResponse extends $pb.GeneratedMessage { - factory ContactResponse() => create(); + factory ContactResponse({ + $core.bool? accept, + $0.TypedKey? superIdentityRecordKey, + $0.TypedKey? remoteConversationRecordKey, + }) { + final $result = create(); + if (accept != null) { + $result.accept = accept; + } + if (superIdentityRecordKey != null) { + $result.superIdentityRecordKey = superIdentityRecordKey; + } + if (remoteConversationRecordKey != null) { + $result.remoteConversationRecordKey = remoteConversationRecordKey; + } + return $result; + } ContactResponse._() : super(); factory ContactResponse.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory ContactResponse.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -1814,6 +2927,7 @@ class ContactResponse extends $pb.GeneratedMessage { static ContactResponse getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static ContactResponse? _defaultInstance; + /// Accept or reject @$pb.TagNumber(1) $core.bool get accept => $_getBF(0); @$pb.TagNumber(1) @@ -1823,6 +2937,7 @@ class ContactResponse extends $pb.GeneratedMessage { @$pb.TagNumber(1) void clearAccept() => clearField(1); + /// Remote SuperIdentity DHT record key @$pb.TagNumber(2) $0.TypedKey get superIdentityRecordKey => $_getN(1); @$pb.TagNumber(2) @@ -1834,6 +2949,7 @@ class ContactResponse extends $pb.GeneratedMessage { @$pb.TagNumber(2) $0.TypedKey ensureSuperIdentityRecordKey() => $_ensure(1); + /// Remote chat DHT record key if accepted @$pb.TagNumber(3) $0.TypedKey get remoteConversationRecordKey => $_getN(2); @$pb.TagNumber(3) @@ -1846,8 +2962,22 @@ class ContactResponse extends $pb.GeneratedMessage { $0.TypedKey ensureRemoteConversationRecordKey() => $_ensure(2); } +/// Signature of response with identity +/// Symmetrically encrypted with writer secret class SignedContactResponse extends $pb.GeneratedMessage { - factory SignedContactResponse() => create(); + factory SignedContactResponse({ + $core.List<$core.int>? contactResponse, + $0.Signature? identitySignature, + }) { + final $result = create(); + if (contactResponse != null) { + $result.contactResponse = contactResponse; + } + if (identitySignature != null) { + $result.identitySignature = identitySignature; + } + return $result; + } SignedContactResponse._() : super(); factory SignedContactResponse.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory SignedContactResponse.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -1879,6 +3009,7 @@ class SignedContactResponse extends $pb.GeneratedMessage { static SignedContactResponse getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static SignedContactResponse? _defaultInstance; + /// Serialized bytes for ContactResponse @$pb.TagNumber(1) $core.List<$core.int> get contactResponse => $_getN(0); @$pb.TagNumber(1) @@ -1888,6 +3019,7 @@ class SignedContactResponse extends $pb.GeneratedMessage { @$pb.TagNumber(1) void clearContactResponse() => clearField(1); + /// Signature of the contact_accept bytes with the identity @$pb.TagNumber(2) $0.Signature get identitySignature => $_getN(1); @$pb.TagNumber(2) @@ -1900,8 +3032,45 @@ class SignedContactResponse extends $pb.GeneratedMessage { $0.Signature ensureIdentitySignature() => $_ensure(1); } +/// Contact request record kept in Account DHTList to keep track of extant contact invitations class ContactInvitationRecord extends $pb.GeneratedMessage { - factory ContactInvitationRecord() => create(); + factory ContactInvitationRecord({ + $1.OwnedDHTRecordPointer? contactRequestInbox, + $0.CryptoKey? writerKey, + $0.CryptoKey? writerSecret, + $0.TypedKey? localConversationRecordKey, + $fixnum.Int64? expiration, + $core.List<$core.int>? invitation, + $core.String? message, + $core.String? recipient, + }) { + final $result = create(); + if (contactRequestInbox != null) { + $result.contactRequestInbox = contactRequestInbox; + } + if (writerKey != null) { + $result.writerKey = writerKey; + } + if (writerSecret != null) { + $result.writerSecret = writerSecret; + } + if (localConversationRecordKey != null) { + $result.localConversationRecordKey = localConversationRecordKey; + } + if (expiration != null) { + $result.expiration = expiration; + } + if (invitation != null) { + $result.invitation = invitation; + } + if (message != null) { + $result.message = message; + } + if (recipient != null) { + $result.recipient = recipient; + } + return $result; + } ContactInvitationRecord._() : super(); factory ContactInvitationRecord.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory ContactInvitationRecord.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -1914,6 +3083,7 @@ class ContactInvitationRecord extends $pb.GeneratedMessage { ..a<$fixnum.Int64>(5, _omitFieldNames ? '' : 'expiration', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO) ..a<$core.List<$core.int>>(6, _omitFieldNames ? '' : 'invitation', $pb.PbFieldType.OY) ..aOS(7, _omitFieldNames ? '' : 'message') + ..aOS(8, _omitFieldNames ? '' : 'recipient') ..hasRequiredFields = false ; @@ -1938,6 +3108,7 @@ class ContactInvitationRecord extends $pb.GeneratedMessage { static ContactInvitationRecord getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static ContactInvitationRecord? _defaultInstance; + /// Contact request unicastinbox DHT record key (parent is accountkey) @$pb.TagNumber(1) $1.OwnedDHTRecordPointer get contactRequestInbox => $_getN(0); @$pb.TagNumber(1) @@ -1949,6 +3120,7 @@ class ContactInvitationRecord extends $pb.GeneratedMessage { @$pb.TagNumber(1) $1.OwnedDHTRecordPointer ensureContactRequestInbox() => $_ensure(0); + /// Writer key sent to contact for the contact_request_inbox smpl inbox subkey @$pb.TagNumber(2) $0.CryptoKey get writerKey => $_getN(1); @$pb.TagNumber(2) @@ -1960,6 +3132,7 @@ class ContactInvitationRecord extends $pb.GeneratedMessage { @$pb.TagNumber(2) $0.CryptoKey ensureWriterKey() => $_ensure(1); + /// Writer secret sent encrypted in the invitation @$pb.TagNumber(3) $0.CryptoKey get writerSecret => $_getN(2); @$pb.TagNumber(3) @@ -1971,6 +3144,7 @@ class ContactInvitationRecord extends $pb.GeneratedMessage { @$pb.TagNumber(3) $0.CryptoKey ensureWriterSecret() => $_ensure(2); + /// Local chat DHT record key (parent is accountkey, will be moved to Contact if accepted) @$pb.TagNumber(4) $0.TypedKey get localConversationRecordKey => $_getN(3); @$pb.TagNumber(4) @@ -1982,6 +3156,7 @@ class ContactInvitationRecord extends $pb.GeneratedMessage { @$pb.TagNumber(4) $0.TypedKey ensureLocalConversationRecordKey() => $_ensure(3); + /// Expiration timestamp @$pb.TagNumber(5) $fixnum.Int64 get expiration => $_getI64(4); @$pb.TagNumber(5) @@ -1991,6 +3166,7 @@ class ContactInvitationRecord extends $pb.GeneratedMessage { @$pb.TagNumber(5) void clearExpiration() => clearField(5); + /// A copy of the raw SignedContactInvitation invitation bytes post-encryption and signing @$pb.TagNumber(6) $core.List<$core.int> get invitation => $_getN(5); @$pb.TagNumber(6) @@ -2000,6 +3176,7 @@ class ContactInvitationRecord extends $pb.GeneratedMessage { @$pb.TagNumber(6) void clearInvitation() => clearField(6); + /// The message sent along with the invitation @$pb.TagNumber(7) $core.String get message => $_getSZ(6); @$pb.TagNumber(7) @@ -2008,6 +3185,16 @@ class ContactInvitationRecord extends $pb.GeneratedMessage { $core.bool hasMessage() => $_has(6); @$pb.TagNumber(7) void clearMessage() => clearField(7); + + /// The recipient sent along with the invitation + @$pb.TagNumber(8) + $core.String get recipient => $_getSZ(7); + @$pb.TagNumber(8) + set recipient($core.String v) { $_setString(7, v); } + @$pb.TagNumber(8) + $core.bool hasRecipient() => $_has(7); + @$pb.TagNumber(8) + void clearRecipient() => clearField(8); } diff --git a/lib/proto/veilidchat.pbenum.dart b/lib/proto/veilidchat.pbenum.dart index 9133788..42009e8 100644 --- a/lib/proto/veilidchat.pbenum.dart +++ b/lib/proto/veilidchat.pbenum.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields // ignore_for_file: unnecessary_import, unnecessary_this, unused_import @@ -13,6 +13,7 @@ import 'dart:core' as $core; import 'package:protobuf/protobuf.dart' as $pb; +/// Contact availability class Availability extends $pb.ProtobufEnum { static const Availability AVAILABILITY_UNSPECIFIED = Availability._(0, _omitEnumNames ? '' : 'AVAILABILITY_UNSPECIFIED'); static const Availability AVAILABILITY_OFFLINE = Availability._(1, _omitEnumNames ? '' : 'AVAILABILITY_OFFLINE'); @@ -34,6 +35,7 @@ class Availability extends $pb.ProtobufEnum { const Availability._($core.int v, $core.String n) : super(v, n); } +/// Encryption used on secret keys class EncryptionKeyType extends $pb.ProtobufEnum { static const EncryptionKeyType ENCRYPTION_KEY_TYPE_UNSPECIFIED = EncryptionKeyType._(0, _omitEnumNames ? '' : 'ENCRYPTION_KEY_TYPE_UNSPECIFIED'); static const EncryptionKeyType ENCRYPTION_KEY_TYPE_NONE = EncryptionKeyType._(1, _omitEnumNames ? '' : 'ENCRYPTION_KEY_TYPE_NONE'); @@ -53,6 +55,7 @@ class EncryptionKeyType extends $pb.ProtobufEnum { const EncryptionKeyType._($core.int v, $core.String n) : super(v, n); } +/// Scope of a chat class Scope extends $pb.ProtobufEnum { static const Scope WATCHERS = Scope._(0, _omitEnumNames ? '' : 'WATCHERS'); static const Scope MODERATED = Scope._(1, _omitEnumNames ? '' : 'MODERATED'); diff --git a/lib/proto/veilidchat.pbjson.dart b/lib/proto/veilidchat.pbjson.dart index ed0bda4..0958343 100644 --- a/lib/proto/veilidchat.pbjson.dart +++ b/lib/proto/veilidchat.pbjson.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields // ignore_for_file: unnecessary_import, unnecessary_this, unused_import @@ -65,6 +65,51 @@ final $typed_data.Uint8List scopeDescriptor = $convert.base64Decode( 'CgVTY29wZRIMCghXQVRDSEVSUxAAEg0KCU1PREVSQVRFRBABEgsKB1RBTEtFUlMQAhIOCgpNT0' 'RFUkFUT1JTEAMSCgoGQURNSU5TEAQ='); +@$core.Deprecated('Use dHTDataReferenceDescriptor instead') +const DHTDataReference$json = { + '1': 'DHTDataReference', + '2': [ + {'1': 'dht_data', '3': 1, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'dhtData'}, + {'1': 'hash', '3': 2, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'hash'}, + ], +}; + +/// Descriptor for `DHTDataReference`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List dHTDataReferenceDescriptor = $convert.base64Decode( + 'ChBESFREYXRhUmVmZXJlbmNlEisKCGRodF9kYXRhGAEgASgLMhAudmVpbGlkLlR5cGVkS2V5Ug' + 'dkaHREYXRhEiQKBGhhc2gYAiABKAsyEC52ZWlsaWQuVHlwZWRLZXlSBGhhc2g='); + +@$core.Deprecated('Use blockStoreDataReferenceDescriptor instead') +const BlockStoreDataReference$json = { + '1': 'BlockStoreDataReference', + '2': [ + {'1': 'block', '3': 1, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'block'}, + ], +}; + +/// Descriptor for `BlockStoreDataReference`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List blockStoreDataReferenceDescriptor = $convert.base64Decode( + 'ChdCbG9ja1N0b3JlRGF0YVJlZmVyZW5jZRImCgVibG9jaxgBIAEoCzIQLnZlaWxpZC5UeXBlZE' + 'tleVIFYmxvY2s='); + +@$core.Deprecated('Use dataReferenceDescriptor instead') +const DataReference$json = { + '1': 'DataReference', + '2': [ + {'1': 'dht_data', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.DHTDataReference', '9': 0, '10': 'dhtData'}, + {'1': 'block_store_data', '3': 2, '4': 1, '5': 11, '6': '.veilidchat.BlockStoreDataReference', '9': 0, '10': 'blockStoreData'}, + ], + '8': [ + {'1': 'kind'}, + ], +}; + +/// Descriptor for `DataReference`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List dataReferenceDescriptor = $convert.base64Decode( + 'Cg1EYXRhUmVmZXJlbmNlEjkKCGRodF9kYXRhGAEgASgLMhwudmVpbGlkY2hhdC5ESFREYXRhUm' + 'VmZXJlbmNlSABSB2RodERhdGESTwoQYmxvY2tfc3RvcmVfZGF0YRgCIAEoCzIjLnZlaWxpZGNo' + 'YXQuQmxvY2tTdG9yZURhdGFSZWZlcmVuY2VIAFIOYmxvY2tTdG9yZURhdGFCBgoEa2luZA=='); + @$core.Deprecated('Use attachmentDescriptor instead') const Attachment$json = { '1': 'Attachment', @@ -89,14 +134,14 @@ const AttachmentMedia$json = { '2': [ {'1': 'mime', '3': 1, '4': 1, '5': 9, '10': 'mime'}, {'1': 'name', '3': 2, '4': 1, '5': 9, '10': 'name'}, - {'1': 'content', '3': 3, '4': 1, '5': 11, '6': '.dht.DataReference', '10': 'content'}, + {'1': 'content', '3': 3, '4': 1, '5': 11, '6': '.veilidchat.DataReference', '10': 'content'}, ], }; /// Descriptor for `AttachmentMedia`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List attachmentMediaDescriptor = $convert.base64Decode( 'Cg9BdHRhY2htZW50TWVkaWESEgoEbWltZRgBIAEoCVIEbWltZRISCgRuYW1lGAIgASgJUgRuYW' - '1lEiwKB2NvbnRlbnQYAyABKAsyEi5kaHQuRGF0YVJlZmVyZW5jZVIHY29udGVudA=='); + '1lEjMKB2NvbnRlbnQYAyABKAsyGS52ZWlsaWRjaGF0LkRhdGFSZWZlcmVuY2VSB2NvbnRlbnQ='); @$core.Deprecated('Use permissionsDescriptor instead') const Permissions$json = { @@ -140,7 +185,7 @@ const ChatSettings$json = { '2': [ {'1': 'title', '3': 1, '4': 1, '5': 9, '10': 'title'}, {'1': 'description', '3': 2, '4': 1, '5': 9, '10': 'description'}, - {'1': 'icon', '3': 3, '4': 1, '5': 11, '6': '.dht.DataReference', '9': 0, '10': 'icon', '17': true}, + {'1': 'icon', '3': 3, '4': 1, '5': 11, '6': '.veilidchat.DataReference', '9': 0, '10': 'icon', '17': true}, {'1': 'default_expiration', '3': 4, '4': 1, '5': 4, '10': 'defaultExpiration'}, ], '8': [ @@ -151,9 +196,9 @@ const ChatSettings$json = { /// Descriptor for `ChatSettings`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List chatSettingsDescriptor = $convert.base64Decode( 'CgxDaGF0U2V0dGluZ3MSFAoFdGl0bGUYASABKAlSBXRpdGxlEiAKC2Rlc2NyaXB0aW9uGAIgAS' - 'gJUgtkZXNjcmlwdGlvbhIrCgRpY29uGAMgASgLMhIuZGh0LkRhdGFSZWZlcmVuY2VIAFIEaWNv' - 'bogBARItChJkZWZhdWx0X2V4cGlyYXRpb24YBCABKARSEWRlZmF1bHRFeHBpcmF0aW9uQgcKBV' - '9pY29u'); + 'gJUgtkZXNjcmlwdGlvbhIyCgRpY29uGAMgASgLMhkudmVpbGlkY2hhdC5EYXRhUmVmZXJlbmNl' + 'SABSBGljb26IAQESLQoSZGVmYXVsdF9leHBpcmF0aW9uGAQgASgEUhFkZWZhdWx0RXhwaXJhdG' + 'lvbkIHCgVfaWNvbg=='); @$core.Deprecated('Use messageDescriptor instead') const Message$json = { @@ -170,6 +215,7 @@ const Message$json = { {'1': 'permissions', '3': 9, '4': 1, '5': 11, '6': '.veilidchat.Message.ControlPermissions', '9': 0, '10': 'permissions'}, {'1': 'membership', '3': 10, '4': 1, '5': 11, '6': '.veilidchat.Message.ControlMembership', '9': 0, '10': 'membership'}, {'1': 'moderation', '3': 11, '4': 1, '5': 11, '6': '.veilidchat.Message.ControlModeration', '9': 0, '10': 'moderation'}, + {'1': 'readReceipt', '3': 13, '4': 1, '5': 11, '6': '.veilidchat.Message.ControlReadReceipt', '9': 0, '10': 'readReceipt'}, {'1': 'signature', '3': 12, '4': 1, '5': 11, '6': '.veilid.Signature', '10': 'signature'}, ], '3': [Message_Text$json, Message_Secret$json, Message_ControlDelete$json, Message_ControlErase$json, Message_ControlSettings$json, Message_ControlPermissions$json, Message_ControlMembership$json, Message_ControlModeration$json, Message_ControlReadReceipt$json], @@ -273,22 +319,24 @@ final $typed_data.Uint8List messageDescriptor = $convert.base64Decode( 'twZXJtaXNzaW9ucxgJIAEoCzImLnZlaWxpZGNoYXQuTWVzc2FnZS5Db250cm9sUGVybWlzc2lv' 'bnNIAFILcGVybWlzc2lvbnMSRwoKbWVtYmVyc2hpcBgKIAEoCzIlLnZlaWxpZGNoYXQuTWVzc2' 'FnZS5Db250cm9sTWVtYmVyc2hpcEgAUgptZW1iZXJzaGlwEkcKCm1vZGVyYXRpb24YCyABKAsy' - 'JS52ZWlsaWRjaGF0Lk1lc3NhZ2UuQ29udHJvbE1vZGVyYXRpb25IAFIKbW9kZXJhdGlvbhIvCg' - 'lzaWduYXR1cmUYDCABKAsyES52ZWlsaWQuU2lnbmF0dXJlUglzaWduYXR1cmUa5QEKBFRleHQS' - 'EgoEdGV4dBgBIAEoCVIEdGV4dBIZCgV0b3BpYxgCIAEoCUgAUgV0b3BpY4gBARIeCghyZXBseV' - '9pZBgDIAEoDEgBUgdyZXBseUlkiAEBEh4KCmV4cGlyYXRpb24YBCABKARSCmV4cGlyYXRpb24S' - 'HQoKdmlld19saW1pdBgFIAEoDVIJdmlld0xpbWl0EjgKC2F0dGFjaG1lbnRzGAYgAygLMhYudm' - 'VpbGlkY2hhdC5BdHRhY2htZW50UgthdHRhY2htZW50c0IICgZfdG9waWNCCwoJX3JlcGx5X2lk' - 'GkgKBlNlY3JldBIeCgpjaXBoZXJ0ZXh0GAEgASgMUgpjaXBoZXJ0ZXh0Eh4KCmV4cGlyYXRpb2' - '4YAiABKARSCmV4cGlyYXRpb24aIQoNQ29udHJvbERlbGV0ZRIQCgNpZHMYASADKAxSA2lkcxos' - 'CgxDb250cm9sRXJhc2USHAoJdGltZXN0YW1wGAEgASgEUgl0aW1lc3RhbXAaRwoPQ29udHJvbF' - 'NldHRpbmdzEjQKCHNldHRpbmdzGAEgASgLMhgudmVpbGlkY2hhdC5DaGF0U2V0dGluZ3NSCHNl' - 'dHRpbmdzGk8KEkNvbnRyb2xQZXJtaXNzaW9ucxI5CgtwZXJtaXNzaW9ucxgBIAEoCzIXLnZlaW' - 'xpZGNoYXQuUGVybWlzc2lvbnNSC3Blcm1pc3Npb25zGksKEUNvbnRyb2xNZW1iZXJzaGlwEjYK' - 'Cm1lbWJlcnNoaXAYASABKAsyFi52ZWlsaWRjaGF0Lk1lbWJlcnNoaXBSCm1lbWJlcnNoaXAaWQ' - 'oRQ29udHJvbE1vZGVyYXRpb24SIQoMYWNjZXB0ZWRfaWRzGAEgAygMUgthY2NlcHRlZElkcxIh' - 'CgxyZWplY3RlZF9pZHMYAiADKAxSC3JlamVjdGVkSWRzGi8KEkNvbnRyb2xSZWFkUmVjZWlwdB' - 'IZCghyZWFkX2lkcxgBIAMoDFIHcmVhZElkc0IGCgRraW5k'); + 'JS52ZWlsaWRjaGF0Lk1lc3NhZ2UuQ29udHJvbE1vZGVyYXRpb25IAFIKbW9kZXJhdGlvbhJKCg' + 'tyZWFkUmVjZWlwdBgNIAEoCzImLnZlaWxpZGNoYXQuTWVzc2FnZS5Db250cm9sUmVhZFJlY2Vp' + 'cHRIAFILcmVhZFJlY2VpcHQSLwoJc2lnbmF0dXJlGAwgASgLMhEudmVpbGlkLlNpZ25hdHVyZV' + 'IJc2lnbmF0dXJlGuUBCgRUZXh0EhIKBHRleHQYASABKAlSBHRleHQSGQoFdG9waWMYAiABKAlI' + 'AFIFdG9waWOIAQESHgoIcmVwbHlfaWQYAyABKAxIAVIHcmVwbHlJZIgBARIeCgpleHBpcmF0aW' + '9uGAQgASgEUgpleHBpcmF0aW9uEh0KCnZpZXdfbGltaXQYBSABKA1SCXZpZXdMaW1pdBI4Cgth' + 'dHRhY2htZW50cxgGIAMoCzIWLnZlaWxpZGNoYXQuQXR0YWNobWVudFILYXR0YWNobWVudHNCCA' + 'oGX3RvcGljQgsKCV9yZXBseV9pZBpICgZTZWNyZXQSHgoKY2lwaGVydGV4dBgBIAEoDFIKY2lw' + 'aGVydGV4dBIeCgpleHBpcmF0aW9uGAIgASgEUgpleHBpcmF0aW9uGiEKDUNvbnRyb2xEZWxldG' + 'USEAoDaWRzGAEgAygMUgNpZHMaLAoMQ29udHJvbEVyYXNlEhwKCXRpbWVzdGFtcBgBIAEoBFIJ' + 'dGltZXN0YW1wGkcKD0NvbnRyb2xTZXR0aW5ncxI0CghzZXR0aW5ncxgBIAEoCzIYLnZlaWxpZG' + 'NoYXQuQ2hhdFNldHRpbmdzUghzZXR0aW5ncxpPChJDb250cm9sUGVybWlzc2lvbnMSOQoLcGVy' + 'bWlzc2lvbnMYASABKAsyFy52ZWlsaWRjaGF0LlBlcm1pc3Npb25zUgtwZXJtaXNzaW9ucxpLCh' + 'FDb250cm9sTWVtYmVyc2hpcBI2CgptZW1iZXJzaGlwGAEgASgLMhYudmVpbGlkY2hhdC5NZW1i' + 'ZXJzaGlwUgptZW1iZXJzaGlwGlkKEUNvbnRyb2xNb2RlcmF0aW9uEiEKDGFjY2VwdGVkX2lkcx' + 'gBIAMoDFILYWNjZXB0ZWRJZHMSIQoMcmVqZWN0ZWRfaWRzGAIgAygMUgtyZWplY3RlZElkcxov' + 'ChJDb250cm9sUmVhZFJlY2VpcHQSGQoIcmVhZF9pZHMYASADKAxSB3JlYWRJZHNCBgoEa2luZA' + '=='); @$core.Deprecated('Use reconciledMessageDescriptor instead') const ReconciledMessage$json = { @@ -320,41 +368,76 @@ final $typed_data.Uint8List conversationDescriptor = $convert.base64Decode( 'JvZmlsZRIuChNzdXBlcl9pZGVudGl0eV9qc29uGAIgASgJUhFzdXBlcklkZW50aXR5SnNvbhIs' 'CghtZXNzYWdlcxgDIAEoCzIQLnZlaWxpZC5UeXBlZEtleVIIbWVzc2FnZXM='); -@$core.Deprecated('Use chatDescriptor instead') -const Chat$json = { - '1': 'Chat', +@$core.Deprecated('Use chatMemberDescriptor instead') +const ChatMember$json = { + '1': 'ChatMember', '2': [ - {'1': 'settings', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.ChatSettings', '10': 'settings'}, - {'1': 'local_conversation_record_key', '3': 2, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'localConversationRecordKey'}, - {'1': 'remote_conversation_record_key', '3': 3, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'remoteConversationRecordKey'}, + {'1': 'remote_identity_public_key', '3': 1, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'remoteIdentityPublicKey'}, + {'1': 'remote_conversation_record_key', '3': 2, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'remoteConversationRecordKey'}, ], }; -/// Descriptor for `Chat`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List chatDescriptor = $convert.base64Decode( - 'CgRDaGF0EjQKCHNldHRpbmdzGAEgASgLMhgudmVpbGlkY2hhdC5DaGF0U2V0dGluZ3NSCHNldH' - 'RpbmdzElMKHWxvY2FsX2NvbnZlcnNhdGlvbl9yZWNvcmRfa2V5GAIgASgLMhAudmVpbGlkLlR5' - 'cGVkS2V5Uhpsb2NhbENvbnZlcnNhdGlvblJlY29yZEtleRJVCh5yZW1vdGVfY29udmVyc2F0aW' - '9uX3JlY29yZF9rZXkYAyABKAsyEC52ZWlsaWQuVHlwZWRLZXlSG3JlbW90ZUNvbnZlcnNhdGlv' - 'blJlY29yZEtleQ=='); +/// Descriptor for `ChatMember`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List chatMemberDescriptor = $convert.base64Decode( + 'CgpDaGF0TWVtYmVyEk0KGnJlbW90ZV9pZGVudGl0eV9wdWJsaWNfa2V5GAEgASgLMhAudmVpbG' + 'lkLlR5cGVkS2V5UhdyZW1vdGVJZGVudGl0eVB1YmxpY0tleRJVCh5yZW1vdGVfY29udmVyc2F0' + 'aW9uX3JlY29yZF9rZXkYAiABKAsyEC52ZWlsaWQuVHlwZWRLZXlSG3JlbW90ZUNvbnZlcnNhdG' + 'lvblJlY29yZEtleQ=='); + +@$core.Deprecated('Use directChatDescriptor instead') +const DirectChat$json = { + '1': 'DirectChat', + '2': [ + {'1': 'settings', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.ChatSettings', '10': 'settings'}, + {'1': 'local_conversation_record_key', '3': 2, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'localConversationRecordKey'}, + {'1': 'remote_member', '3': 3, '4': 1, '5': 11, '6': '.veilidchat.ChatMember', '10': 'remoteMember'}, + ], +}; + +/// Descriptor for `DirectChat`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List directChatDescriptor = $convert.base64Decode( + 'CgpEaXJlY3RDaGF0EjQKCHNldHRpbmdzGAEgASgLMhgudmVpbGlkY2hhdC5DaGF0U2V0dGluZ3' + 'NSCHNldHRpbmdzElMKHWxvY2FsX2NvbnZlcnNhdGlvbl9yZWNvcmRfa2V5GAIgASgLMhAudmVp' + 'bGlkLlR5cGVkS2V5Uhpsb2NhbENvbnZlcnNhdGlvblJlY29yZEtleRI7Cg1yZW1vdGVfbWVtYm' + 'VyGAMgASgLMhYudmVpbGlkY2hhdC5DaGF0TWVtYmVyUgxyZW1vdGVNZW1iZXI='); @$core.Deprecated('Use groupChatDescriptor instead') const GroupChat$json = { '1': 'GroupChat', '2': [ {'1': 'settings', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.ChatSettings', '10': 'settings'}, - {'1': 'local_conversation_record_key', '3': 2, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'localConversationRecordKey'}, - {'1': 'remote_conversation_record_keys', '3': 3, '4': 3, '5': 11, '6': '.veilid.TypedKey', '10': 'remoteConversationRecordKeys'}, + {'1': 'membership', '3': 2, '4': 1, '5': 11, '6': '.veilidchat.Membership', '10': 'membership'}, + {'1': 'permissions', '3': 3, '4': 1, '5': 11, '6': '.veilidchat.Permissions', '10': 'permissions'}, + {'1': 'local_conversation_record_key', '3': 4, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'localConversationRecordKey'}, + {'1': 'remote_members', '3': 5, '4': 3, '5': 11, '6': '.veilidchat.ChatMember', '10': 'remoteMembers'}, ], }; /// Descriptor for `GroupChat`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List groupChatDescriptor = $convert.base64Decode( 'CglHcm91cENoYXQSNAoIc2V0dGluZ3MYASABKAsyGC52ZWlsaWRjaGF0LkNoYXRTZXR0aW5nc1' - 'IIc2V0dGluZ3MSUwodbG9jYWxfY29udmVyc2F0aW9uX3JlY29yZF9rZXkYAiABKAsyEC52ZWls' - 'aWQuVHlwZWRLZXlSGmxvY2FsQ29udmVyc2F0aW9uUmVjb3JkS2V5ElcKH3JlbW90ZV9jb252ZX' - 'JzYXRpb25fcmVjb3JkX2tleXMYAyADKAsyEC52ZWlsaWQuVHlwZWRLZXlSHHJlbW90ZUNvbnZl' - 'cnNhdGlvblJlY29yZEtleXM='); + 'IIc2V0dGluZ3MSNgoKbWVtYmVyc2hpcBgCIAEoCzIWLnZlaWxpZGNoYXQuTWVtYmVyc2hpcFIK' + 'bWVtYmVyc2hpcBI5CgtwZXJtaXNzaW9ucxgDIAEoCzIXLnZlaWxpZGNoYXQuUGVybWlzc2lvbn' + 'NSC3Blcm1pc3Npb25zElMKHWxvY2FsX2NvbnZlcnNhdGlvbl9yZWNvcmRfa2V5GAQgASgLMhAu' + 'dmVpbGlkLlR5cGVkS2V5Uhpsb2NhbENvbnZlcnNhdGlvblJlY29yZEtleRI9Cg5yZW1vdGVfbW' + 'VtYmVycxgFIAMoCzIWLnZlaWxpZGNoYXQuQ2hhdE1lbWJlclINcmVtb3RlTWVtYmVycw=='); + +@$core.Deprecated('Use chatDescriptor instead') +const Chat$json = { + '1': 'Chat', + '2': [ + {'1': 'direct', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.DirectChat', '9': 0, '10': 'direct'}, + {'1': 'group', '3': 2, '4': 1, '5': 11, '6': '.veilidchat.GroupChat', '9': 0, '10': 'group'}, + ], + '8': [ + {'1': 'kind'}, + ], +}; + +/// Descriptor for `Chat`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List chatDescriptor = $convert.base64Decode( + 'CgRDaGF0EjAKBmRpcmVjdBgBIAEoCzIWLnZlaWxpZGNoYXQuRGlyZWN0Q2hhdEgAUgZkaXJlY3' + 'QSLQoFZ3JvdXAYAiABKAsyFS52ZWlsaWRjaGF0Lkdyb3VwQ2hhdEgAUgVncm91cEIGCgRraW5k'); @$core.Deprecated('Use profileDescriptor instead') const Profile$json = { @@ -365,7 +448,8 @@ const Profile$json = { {'1': 'about', '3': 3, '4': 1, '5': 9, '10': 'about'}, {'1': 'status', '3': 4, '4': 1, '5': 9, '10': 'status'}, {'1': 'availability', '3': 5, '4': 1, '5': 14, '6': '.veilidchat.Availability', '10': 'availability'}, - {'1': 'avatar', '3': 6, '4': 1, '5': 11, '6': '.veilid.TypedKey', '9': 0, '10': 'avatar', '17': true}, + {'1': 'avatar', '3': 6, '4': 1, '5': 11, '6': '.veilidchat.DataReference', '9': 0, '10': 'avatar', '17': true}, + {'1': 'timestamp', '3': 7, '4': 1, '5': 4, '10': 'timestamp'}, ], '8': [ {'1': '_avatar'}, @@ -376,9 +460,9 @@ const Profile$json = { final $typed_data.Uint8List profileDescriptor = $convert.base64Decode( 'CgdQcm9maWxlEhIKBG5hbWUYASABKAlSBG5hbWUSGgoIcHJvbm91bnMYAiABKAlSCHByb25vdW' '5zEhQKBWFib3V0GAMgASgJUgVhYm91dBIWCgZzdGF0dXMYBCABKAlSBnN0YXR1cxI8CgxhdmFp' - 'bGFiaWxpdHkYBSABKA4yGC52ZWlsaWRjaGF0LkF2YWlsYWJpbGl0eVIMYXZhaWxhYmlsaXR5Ei' - '0KBmF2YXRhchgGIAEoCzIQLnZlaWxpZC5UeXBlZEtleUgAUgZhdmF0YXKIAQFCCQoHX2F2YXRh' - 'cg=='); + 'bGFiaWxpdHkYBSABKA4yGC52ZWlsaWRjaGF0LkF2YWlsYWJpbGl0eVIMYXZhaWxhYmlsaXR5Ej' + 'YKBmF2YXRhchgGIAEoCzIZLnZlaWxpZGNoYXQuRGF0YVJlZmVyZW5jZUgAUgZhdmF0YXKIAQES' + 'HAoJdGltZXN0YW1wGAcgASgEUgl0aW1lc3RhbXBCCQoHX2F2YXRhcg=='); @$core.Deprecated('Use accountDescriptor instead') const Account$json = { @@ -386,50 +470,57 @@ const Account$json = { '2': [ {'1': 'profile', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.Profile', '10': 'profile'}, {'1': 'invisible', '3': 2, '4': 1, '5': 8, '10': 'invisible'}, - {'1': 'auto_away_timeout_sec', '3': 3, '4': 1, '5': 13, '10': 'autoAwayTimeoutSec'}, + {'1': 'auto_away_timeout_min', '3': 3, '4': 1, '5': 13, '10': 'autoAwayTimeoutMin'}, {'1': 'contact_list', '3': 4, '4': 1, '5': 11, '6': '.dht.OwnedDHTRecordPointer', '10': 'contactList'}, {'1': 'contact_invitation_records', '3': 5, '4': 1, '5': 11, '6': '.dht.OwnedDHTRecordPointer', '10': 'contactInvitationRecords'}, {'1': 'chat_list', '3': 6, '4': 1, '5': 11, '6': '.dht.OwnedDHTRecordPointer', '10': 'chatList'}, {'1': 'group_chat_list', '3': 7, '4': 1, '5': 11, '6': '.dht.OwnedDHTRecordPointer', '10': 'groupChatList'}, + {'1': 'free_message', '3': 8, '4': 1, '5': 9, '10': 'freeMessage'}, + {'1': 'busy_message', '3': 9, '4': 1, '5': 9, '10': 'busyMessage'}, + {'1': 'away_message', '3': 10, '4': 1, '5': 9, '10': 'awayMessage'}, + {'1': 'autodetect_away', '3': 11, '4': 1, '5': 8, '10': 'autodetectAway'}, ], }; /// Descriptor for `Account`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List accountDescriptor = $convert.base64Decode( 'CgdBY2NvdW50Ei0KB3Byb2ZpbGUYASABKAsyEy52ZWlsaWRjaGF0LlByb2ZpbGVSB3Byb2ZpbG' - 'USHAoJaW52aXNpYmxlGAIgASgIUglpbnZpc2libGUSMQoVYXV0b19hd2F5X3RpbWVvdXRfc2Vj' - 'GAMgASgNUhJhdXRvQXdheVRpbWVvdXRTZWMSPQoMY29udGFjdF9saXN0GAQgASgLMhouZGh0Lk' + 'USHAoJaW52aXNpYmxlGAIgASgIUglpbnZpc2libGUSMQoVYXV0b19hd2F5X3RpbWVvdXRfbWlu' + 'GAMgASgNUhJhdXRvQXdheVRpbWVvdXRNaW4SPQoMY29udGFjdF9saXN0GAQgASgLMhouZGh0Lk' '93bmVkREhUUmVjb3JkUG9pbnRlclILY29udGFjdExpc3QSWAoaY29udGFjdF9pbnZpdGF0aW9u' 'X3JlY29yZHMYBSABKAsyGi5kaHQuT3duZWRESFRSZWNvcmRQb2ludGVyUhhjb250YWN0SW52aX' 'RhdGlvblJlY29yZHMSNwoJY2hhdF9saXN0GAYgASgLMhouZGh0Lk93bmVkREhUUmVjb3JkUG9p' 'bnRlclIIY2hhdExpc3QSQgoPZ3JvdXBfY2hhdF9saXN0GAcgASgLMhouZGh0Lk93bmVkREhUUm' - 'Vjb3JkUG9pbnRlclINZ3JvdXBDaGF0TGlzdA=='); + 'Vjb3JkUG9pbnRlclINZ3JvdXBDaGF0TGlzdBIhCgxmcmVlX21lc3NhZ2UYCCABKAlSC2ZyZWVN' + 'ZXNzYWdlEiEKDGJ1c3lfbWVzc2FnZRgJIAEoCVILYnVzeU1lc3NhZ2USIQoMYXdheV9tZXNzYW' + 'dlGAogASgJUgthd2F5TWVzc2FnZRInCg9hdXRvZGV0ZWN0X2F3YXkYCyABKAhSDmF1dG9kZXRl' + 'Y3RBd2F5'); @$core.Deprecated('Use contactDescriptor instead') const Contact$json = { '1': 'Contact', '2': [ - {'1': 'edited_profile', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.Profile', '10': 'editedProfile'}, - {'1': 'remote_profile', '3': 2, '4': 1, '5': 11, '6': '.veilidchat.Profile', '10': 'remoteProfile'}, + {'1': 'nickname', '3': 1, '4': 1, '5': 9, '10': 'nickname'}, + {'1': 'profile', '3': 2, '4': 1, '5': 11, '6': '.veilidchat.Profile', '10': 'profile'}, {'1': 'super_identity_json', '3': 3, '4': 1, '5': 9, '10': 'superIdentityJson'}, {'1': 'identity_public_key', '3': 4, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'identityPublicKey'}, {'1': 'remote_conversation_record_key', '3': 5, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'remoteConversationRecordKey'}, {'1': 'local_conversation_record_key', '3': 6, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'localConversationRecordKey'}, {'1': 'show_availability', '3': 7, '4': 1, '5': 8, '10': 'showAvailability'}, + {'1': 'notes', '3': 8, '4': 1, '5': 9, '10': 'notes'}, ], }; /// Descriptor for `Contact`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List contactDescriptor = $convert.base64Decode( - 'CgdDb250YWN0EjoKDmVkaXRlZF9wcm9maWxlGAEgASgLMhMudmVpbGlkY2hhdC5Qcm9maWxlUg' - '1lZGl0ZWRQcm9maWxlEjoKDnJlbW90ZV9wcm9maWxlGAIgASgLMhMudmVpbGlkY2hhdC5Qcm9m' - 'aWxlUg1yZW1vdGVQcm9maWxlEi4KE3N1cGVyX2lkZW50aXR5X2pzb24YAyABKAlSEXN1cGVySW' - 'RlbnRpdHlKc29uEkAKE2lkZW50aXR5X3B1YmxpY19rZXkYBCABKAsyEC52ZWlsaWQuVHlwZWRL' - 'ZXlSEWlkZW50aXR5UHVibGljS2V5ElUKHnJlbW90ZV9jb252ZXJzYXRpb25fcmVjb3JkX2tleR' - 'gFIAEoCzIQLnZlaWxpZC5UeXBlZEtleVIbcmVtb3RlQ29udmVyc2F0aW9uUmVjb3JkS2V5ElMK' - 'HWxvY2FsX2NvbnZlcnNhdGlvbl9yZWNvcmRfa2V5GAYgASgLMhAudmVpbGlkLlR5cGVkS2V5Uh' - 'psb2NhbENvbnZlcnNhdGlvblJlY29yZEtleRIrChFzaG93X2F2YWlsYWJpbGl0eRgHIAEoCFIQ' - 'c2hvd0F2YWlsYWJpbGl0eQ=='); + 'CgdDb250YWN0EhoKCG5pY2tuYW1lGAEgASgJUghuaWNrbmFtZRItCgdwcm9maWxlGAIgASgLMh' + 'MudmVpbGlkY2hhdC5Qcm9maWxlUgdwcm9maWxlEi4KE3N1cGVyX2lkZW50aXR5X2pzb24YAyAB' + 'KAlSEXN1cGVySWRlbnRpdHlKc29uEkAKE2lkZW50aXR5X3B1YmxpY19rZXkYBCABKAsyEC52ZW' + 'lsaWQuVHlwZWRLZXlSEWlkZW50aXR5UHVibGljS2V5ElUKHnJlbW90ZV9jb252ZXJzYXRpb25f' + 'cmVjb3JkX2tleRgFIAEoCzIQLnZlaWxpZC5UeXBlZEtleVIbcmVtb3RlQ29udmVyc2F0aW9uUm' + 'Vjb3JkS2V5ElMKHWxvY2FsX2NvbnZlcnNhdGlvbl9yZWNvcmRfa2V5GAYgASgLMhAudmVpbGlk' + 'LlR5cGVkS2V5Uhpsb2NhbENvbnZlcnNhdGlvblJlY29yZEtleRIrChFzaG93X2F2YWlsYWJpbG' + 'l0eRgHIAEoCFIQc2hvd0F2YWlsYWJpbGl0eRIUCgVub3RlcxgIIAEoCVIFbm90ZXM='); @$core.Deprecated('Use contactInvitationDescriptor instead') const ContactInvitation$json = { @@ -540,6 +631,7 @@ const ContactInvitationRecord$json = { {'1': 'expiration', '3': 5, '4': 1, '5': 4, '10': 'expiration'}, {'1': 'invitation', '3': 6, '4': 1, '5': 12, '10': 'invitation'}, {'1': 'message', '3': 7, '4': 1, '5': 9, '10': 'message'}, + {'1': 'recipient', '3': 8, '4': 1, '5': 9, '10': 'recipient'}, ], }; @@ -551,5 +643,6 @@ final $typed_data.Uint8List contactInvitationRecordDescriptor = $convert.base64D 'NlY3JldBgDIAEoCzIRLnZlaWxpZC5DcnlwdG9LZXlSDHdyaXRlclNlY3JldBJTCh1sb2NhbF9j' 'b252ZXJzYXRpb25fcmVjb3JkX2tleRgEIAEoCzIQLnZlaWxpZC5UeXBlZEtleVIabG9jYWxDb2' '52ZXJzYXRpb25SZWNvcmRLZXkSHgoKZXhwaXJhdGlvbhgFIAEoBFIKZXhwaXJhdGlvbhIeCgpp' - 'bnZpdGF0aW9uGAYgASgMUgppbnZpdGF0aW9uEhgKB21lc3NhZ2UYByABKAlSB21lc3NhZ2U='); + 'bnZpdGF0aW9uGAYgASgMUgppbnZpdGF0aW9uEhgKB21lc3NhZ2UYByABKAlSB21lc3NhZ2USHA' + 'oJcmVjaXBpZW50GAggASgJUglyZWNpcGllbnQ='); diff --git a/lib/proto/veilidchat.pbserver.dart b/lib/proto/veilidchat.pbserver.dart index 02a9ae4..047feed 100644 --- a/lib/proto/veilidchat.pbserver.dart +++ b/lib/proto/veilidchat.pbserver.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names // ignore_for_file: deprecated_member_use_from_same_package, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields diff --git a/lib/proto/veilidchat.proto b/lib/proto/veilidchat.proto index dd2de0b..5bff89c 100644 --- a/lib/proto/veilidchat.proto +++ b/lib/proto/veilidchat.proto @@ -47,6 +47,31 @@ enum Scope { ADMINS = 4; } +//////////////////////////////////////////////////////////////////////////////////// +// Data +//////////////////////////////////////////////////////////////////////////////////// + +// Reference to data on the DHT +message DHTDataReference { + veilid.TypedKey dht_data = 1; + veilid.TypedKey hash = 2; +} + +// Reference to data on the BlockStore +message BlockStoreDataReference { + veilid.TypedKey block = 1; +} + +// DataReference +// Pointer to data somewhere in Veilid +// Abstraction over DHTData and BlockStore +message DataReference { + oneof kind { + DHTDataReference dht_data = 1; + BlockStoreDataReference block_store_data = 2; + } +} + //////////////////////////////////////////////////////////////////////////////////// // Attachments //////////////////////////////////////////////////////////////////////////////////// @@ -67,10 +92,9 @@ message AttachmentMedia { // Title or filename string name = 2; // Pointer to the data content - dht.DataReference content = 3; + DataReference content = 3; } - //////////////////////////////////////////////////////////////////////////////////// // Chat room controls //////////////////////////////////////////////////////////////////////////////////// @@ -106,7 +130,7 @@ message ChatSettings { // Description for the chat string description = 2; // Icon for the chat - optional dht.DataReference icon = 3; + optional DataReference icon = 3; // Default message expiration duration (in us) uint64 default_expiration = 4; } @@ -204,6 +228,7 @@ message Message { ControlPermissions permissions = 9; ControlMembership membership = 10; ControlModeration moderation = 11; + ControlReadReceipt readReceipt = 13; } // Author signature over all of the fields and attachment signatures @@ -243,15 +268,23 @@ message Conversation { veilid.TypedKey messages = 3; } -// Either a 1-1 conversation or a group chat +// A member of chat which may or may not be associated with a contact +message ChatMember { + // The identity public key most recently associated with the chat member + veilid.TypedKey remote_identity_public_key = 1; + // Conversation key for the other party + veilid.TypedKey remote_conversation_record_key = 2; +} + +// A 1-1 chat // Privately encrypted, this is the local user's copy of the chat -message Chat { +message DirectChat { // Settings ChatSettings settings = 1; // Conversation key for this user veilid.TypedKey local_conversation_record_key = 2; // Conversation key for the other party - veilid.TypedKey remote_conversation_record_key = 3; + ChatMember remote_member = 3; } // A group chat @@ -259,10 +292,22 @@ message Chat { message GroupChat { // Settings ChatSettings settings = 1; + // Membership + Membership membership = 2; + // Permissions + Permissions permissions = 3; // Conversation key for this user - veilid.TypedKey local_conversation_record_key = 2; + veilid.TypedKey local_conversation_record_key = 4; // Conversation keys for the other parties - repeated veilid.TypedKey remote_conversation_record_keys = 3; + repeated ChatMember remote_members = 5; +} + +// Some kind of chat +message Chat { + oneof kind { + DirectChat direct = 1; + GroupChat group = 2; + } } //////////////////////////////////////////////////////////////////////////////////// @@ -275,18 +320,20 @@ message GroupChat { // Pronouns - Pronouns of user // Icon - Little picture to represent user in contact list message Profile { - // Friendy name + // Friendy name (max length 64) string name = 1; - // Pronouns of user + // Pronouns of user (max length 64) string pronouns = 2; - // Description of the user + // Description of the user (max length 1024) string about = 3; - // Status/away message + // Status/away message (max length 128) string status = 4; // Availability Availability availability = 5; - // Avatar DHTData - optional veilid.TypedKey avatar = 6; + // Avatar + optional DataReference avatar = 6; + // Timestamp of last change + uint64 timestamp = 7; } // A record of an individual account @@ -299,8 +346,8 @@ message Account { Profile profile = 1; // Invisibility makes you always look 'Offline' bool invisible = 2; - // Auto-away sets 'away' mode after an inactivity time - uint32 auto_away_timeout_sec = 3; + // Auto-away sets 'away' mode after an inactivity time (only if autodetect_away is set) + uint32 auto_away_timeout_min = 3; // The contacts DHTList for this account // DHT Private dht.OwnedDHTRecordPointer contact_list = 4; @@ -313,6 +360,15 @@ message Account { // The GroupChats DHTList for this account // DHT Private dht.OwnedDHTRecordPointer group_chat_list = 7; + // Free message (max length 128) + string free_message = 8; + // Busy message (max length 128) + string busy_message = 9; + // Away message (max length 128) + string away_message = 10; + // Auto-detect away + bool autodetect_away = 11; + } // A record of a contact that has accepted a contact invitation @@ -323,10 +379,10 @@ message Account { // // Stored in ContactList DHTList message Contact { - // Friend's profile as locally edited - Profile edited_profile = 1; + // Friend's nickname + string nickname = 1; // Copy of friend's profile from remote conversation - Profile remote_profile = 2; + Profile profile = 2; // Copy of friend's SuperIdentity in JSON from remote conversation string super_identity_json = 3; // Copy of friend's most recent identity public key from their identityMaster @@ -337,6 +393,8 @@ message Contact { veilid.TypedKey local_conversation_record_key = 6; // Show availability to this contact bool show_availability = 7; + // Notes about this friend + string notes = 8; } //////////////////////////////////////////////////////////////////////////////////// @@ -421,4 +479,6 @@ message ContactInvitationRecord { bytes invitation = 6; // The message sent along with the invitation string message = 7; + // The recipient sent along with the invitation + string recipient = 8; } \ No newline at end of file diff --git a/lib/router/cubit/router_cubit.dart b/lib/router/cubit/router_cubit.dart deleted file mode 100644 index f30a617..0000000 --- a/lib/router/cubit/router_cubit.dart +++ /dev/null @@ -1,163 +0,0 @@ -import 'dart:async'; - -import 'package:bloc/bloc.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:go_router/go_router.dart'; -import 'package:stream_transform/stream_transform.dart'; - -import '../../../account_manager/account_manager.dart'; -import '../../layout/layout.dart'; -import '../../settings/settings.dart'; -import '../../tools/tools.dart'; -import '../../veilid_processor/views/developer.dart'; - -part 'router_cubit.freezed.dart'; -part 'router_cubit.g.dart'; - -final _rootNavKey = GlobalKey(debugLabel: 'rootNavKey'); -final _homeNavKey = GlobalKey(debugLabel: 'homeNavKey'); - -@freezed -class RouterState with _$RouterState { - const factory RouterState( - {required bool hasAnyAccount, - required bool hasActiveChat}) = _RouterState; - - factory RouterState.fromJson(dynamic json) => - _$RouterStateFromJson(json as Map); -} - -class RouterCubit extends Cubit { - RouterCubit(AccountRepository accountRepository) - : super(RouterState( - hasAnyAccount: accountRepository.getLocalAccounts().isNotEmpty, - hasActiveChat: false, - )) { - // Subscribe to repository streams - _accountRepositorySubscription = accountRepository.stream.listen((event) { - switch (event) { - case AccountRepositoryChange.localAccounts: - emit(state.copyWith( - hasAnyAccount: accountRepository.getLocalAccounts().isNotEmpty)); - break; - case AccountRepositoryChange.userLogins: - case AccountRepositoryChange.activeLocalAccount: - break; - } - }); - } - - void setHasActiveChat(bool active) { - emit(state.copyWith(hasActiveChat: active)); - } - - @override - Future close() async { - await _accountRepositorySubscription.cancel(); - await super.close(); - } - - /// Our application routes - List get routes => [ - ShellRoute( - navigatorKey: _homeNavKey, - builder: (context, state, child) => HomeShell( - accountReadyBuilder: Builder( - builder: (context) => - HomeAccountReadyShell(context: context, child: child))), - routes: [ - GoRoute( - path: '/', - builder: (context, state) => const HomeAccountReadyMain(), - ), - GoRoute( - path: '/chat', - builder: (context, state) => const HomeAccountReadyChat(), - ), - ], - ), - GoRoute( - path: '/new_account', - builder: (context, state) => const NewAccountPage(), - ), - GoRoute( - path: '/settings', - builder: (context, state) => const SettingsPage(), - ), - GoRoute( - path: '/developer', - builder: (context, state) => const DeveloperPage(), - ) - ]; - - /// Redirects when our state changes - String? redirect(BuildContext context, GoRouterState goRouterState) { - // No matter where we are, if there's not - - switch (goRouterState.matchedLocation) { - case '/new_account': - return state.hasAnyAccount ? '/' : null; - case '/': - if (!state.hasAnyAccount) { - return '/new_account'; - } - if (responsiveVisibility( - context: context, - tablet: false, - tabletLandscape: false, - desktop: false)) { - if (state.hasActiveChat) { - return '/chat'; - } - } - return null; - case '/chat': - if (!state.hasAnyAccount) { - return '/new_account'; - } - if (responsiveVisibility( - context: context, - tablet: false, - tabletLandscape: false, - desktop: false)) { - if (!state.hasActiveChat) { - return '/'; - } - } else { - return '/'; - } - return null; - case '/settings': - return null; - case '/developer': - return null; - default: - return state.hasAnyAccount ? null : '/new_account'; - } - } - - /// Make a GoRouter instance that uses this cubit - GoRouter router() { - final r = _router; - if (r != null) { - return r; - } - return _router = GoRouter( - navigatorKey: _rootNavKey, - refreshListenable: StreamListenable(stream.startWith(state).distinct()), - debugLogDiagnostics: kDebugMode, - initialLocation: '/', - routes: routes, - redirect: redirect, - ); - } - - //////////////// - - late final StreamSubscription - _accountRepositorySubscription; - GoRouter? _router; -} diff --git a/lib/router/cubit/router_cubit.freezed.dart b/lib/router/cubit/router_cubit.freezed.dart deleted file mode 100644 index c36db7d..0000000 --- a/lib/router/cubit/router_cubit.freezed.dart +++ /dev/null @@ -1,181 +0,0 @@ -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'router_cubit.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -T _$identity(T value) => value; - -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -RouterState _$RouterStateFromJson(Map json) { - return _RouterState.fromJson(json); -} - -/// @nodoc -mixin _$RouterState { - bool get hasAnyAccount => throw _privateConstructorUsedError; - bool get hasActiveChat => throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $RouterStateCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $RouterStateCopyWith<$Res> { - factory $RouterStateCopyWith( - RouterState value, $Res Function(RouterState) then) = - _$RouterStateCopyWithImpl<$Res, RouterState>; - @useResult - $Res call({bool hasAnyAccount, bool hasActiveChat}); -} - -/// @nodoc -class _$RouterStateCopyWithImpl<$Res, $Val extends RouterState> - implements $RouterStateCopyWith<$Res> { - _$RouterStateCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? hasAnyAccount = null, - Object? hasActiveChat = null, - }) { - return _then(_value.copyWith( - hasAnyAccount: null == hasAnyAccount - ? _value.hasAnyAccount - : hasAnyAccount // ignore: cast_nullable_to_non_nullable - as bool, - hasActiveChat: null == hasActiveChat - ? _value.hasActiveChat - : hasActiveChat // ignore: cast_nullable_to_non_nullable - as bool, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$RouterStateImplCopyWith<$Res> - implements $RouterStateCopyWith<$Res> { - factory _$$RouterStateImplCopyWith( - _$RouterStateImpl value, $Res Function(_$RouterStateImpl) then) = - __$$RouterStateImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({bool hasAnyAccount, bool hasActiveChat}); -} - -/// @nodoc -class __$$RouterStateImplCopyWithImpl<$Res> - extends _$RouterStateCopyWithImpl<$Res, _$RouterStateImpl> - implements _$$RouterStateImplCopyWith<$Res> { - __$$RouterStateImplCopyWithImpl( - _$RouterStateImpl _value, $Res Function(_$RouterStateImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? hasAnyAccount = null, - Object? hasActiveChat = null, - }) { - return _then(_$RouterStateImpl( - hasAnyAccount: null == hasAnyAccount - ? _value.hasAnyAccount - : hasAnyAccount // ignore: cast_nullable_to_non_nullable - as bool, - hasActiveChat: null == hasActiveChat - ? _value.hasActiveChat - : hasActiveChat // ignore: cast_nullable_to_non_nullable - as bool, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$RouterStateImpl with DiagnosticableTreeMixin implements _RouterState { - const _$RouterStateImpl( - {required this.hasAnyAccount, required this.hasActiveChat}); - - factory _$RouterStateImpl.fromJson(Map json) => - _$$RouterStateImplFromJson(json); - - @override - final bool hasAnyAccount; - @override - final bool hasActiveChat; - - @override - String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'RouterState(hasAnyAccount: $hasAnyAccount, hasActiveChat: $hasActiveChat)'; - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty('type', 'RouterState')) - ..add(DiagnosticsProperty('hasAnyAccount', hasAnyAccount)) - ..add(DiagnosticsProperty('hasActiveChat', hasActiveChat)); - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$RouterStateImpl && - (identical(other.hasAnyAccount, hasAnyAccount) || - other.hasAnyAccount == hasAnyAccount) && - (identical(other.hasActiveChat, hasActiveChat) || - other.hasActiveChat == hasActiveChat)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash(runtimeType, hasAnyAccount, hasActiveChat); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$RouterStateImplCopyWith<_$RouterStateImpl> get copyWith => - __$$RouterStateImplCopyWithImpl<_$RouterStateImpl>(this, _$identity); - - @override - Map toJson() { - return _$$RouterStateImplToJson( - this, - ); - } -} - -abstract class _RouterState implements RouterState { - const factory _RouterState( - {required final bool hasAnyAccount, - required final bool hasActiveChat}) = _$RouterStateImpl; - - factory _RouterState.fromJson(Map json) = - _$RouterStateImpl.fromJson; - - @override - bool get hasAnyAccount; - @override - bool get hasActiveChat; - @override - @JsonKey(ignore: true) - _$$RouterStateImplCopyWith<_$RouterStateImpl> get copyWith => - throw _privateConstructorUsedError; -} diff --git a/lib/router/cubits/router_cubit.dart b/lib/router/cubits/router_cubit.dart new file mode 100644 index 0000000..d651611 --- /dev/null +++ b/lib/router/cubits/router_cubit.dart @@ -0,0 +1,180 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:go_router/go_router.dart'; +import 'package:stream_transform/stream_transform.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../../account_manager/account_manager.dart'; +import '../../layout/layout.dart'; +import '../../settings/settings.dart'; +import '../../theme/theme.dart'; +import '../../tools/tools.dart'; +import '../../veilid_processor/views/developer.dart'; +import '../views/router_shell.dart'; + +export 'package:go_router/go_router.dart'; + +part 'router_cubit.freezed.dart'; +part 'router_cubit.g.dart'; + +final _rootNavKey = GlobalKey(debugLabel: 'rootNavKey'); + +@freezed +sealed class RouterState with _$RouterState { + const factory RouterState({ + required bool hasAnyAccount, + }) = _RouterState; + + factory RouterState.fromJson(dynamic json) => + _$RouterStateFromJson(json as Map); +} + +class RouterCubit extends Cubit { + RouterCubit(AccountRepository accountRepository) + : super(RouterState( + hasAnyAccount: accountRepository.getLocalAccounts().isNotEmpty, + )) { + // Subscribe to repository streams + _accountRepositorySubscription = accountRepository.stream.listen((event) { + switch (event) { + case AccountRepositoryChange.localAccounts: + emit(state.copyWith( + hasAnyAccount: accountRepository.getLocalAccounts().isNotEmpty)); + case AccountRepositoryChange.userLogins: + case AccountRepositoryChange.activeLocalAccount: + } + }); + } + + @override + Future close() async { + await _accountRepositorySubscription.cancel(); + await super.close(); + } + + /// Our application routes + List get routes => [ + ShellRoute( + builder: (context, state, child) => RouterShell(child: child), + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const HomeScreen(), + ), + GoRoute( + path: '/edit_account', + redirect: (_, state) { + final extra = state.extra; + if (extra == null || + extra is! List || + extra[0] is! TypedKey) { + return '/'; + } + return null; + }, + builder: (context, state) { + final extra = state.extra! as List; + return EditAccountPage( + superIdentityRecordKey: extra[0] as TypedKey, + initialValue: extra[1] as AccountSpec, + accountRecord: extra[2] as OwnedDHTRecordPointer, + ); + }, + ), + GoRoute( + path: '/new_account', + builder: (context, state) => const NewAccountPage(), + routes: [ + GoRoute( + path: 'recovery_key', + redirect: (_, state) { + final extra = state.extra; + if (extra == null || + extra is! List || + extra[0] is! WritableSuperIdentity || + extra[1] is! String || + extra[2] is! bool) { + return '/'; + } + return null; + }, + builder: (context, state) { + final extra = state.extra! as List; + + return PopControl( + dismissible: false, + child: ShowRecoveryKeyPage( + writableSuperIdentity: + extra[0] as WritableSuperIdentity, + name: extra[1] as String, + isFirstAccount: extra[2] as bool)); + }), + ]), + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsPage(), + ), + GoRoute( + path: '/developer', + builder: (context, state) => const DeveloperPage(), + ) + ]) + ]; + + /// Redirects when our state changes + String? redirect(BuildContext context, GoRouterState goRouterState) { + switch (goRouterState.matchedLocation) { + // We can go to any of these routes without an account. + case '/new_account': + return null; + case '/new_account/recovery_key': + return null; + case '/settings': + return null; + case '/developer': + return null; + // Otherwise, if there's no account, + // we need to go to the new account page. + default: + return state.hasAnyAccount ? null : '/new_account'; + } + } + + /// Make a GoRouter instance that uses this cubit + GoRouter router() { + final r = _router; + if (r != null) { + return r; + } + return _router = GoRouter( + navigatorKey: _rootNavKey, + refreshListenable: StreamListenable(stream.startWith(state).distinct()), + debugLogDiagnostics: kIsDebugMode, + initialLocation: '/', + routes: routes, + redirect: redirect, + ); + } + + //////////////// + + late final StreamSubscription + _accountRepositorySubscription; + GoRouter? _router; +} + +extension GoRouterExtension on GoRouter { + String location() { + final lastMatch = routerDelegate.currentConfiguration.last; + final matchList = lastMatch is ImperativeRouteMatch + ? lastMatch.matches + : routerDelegate.currentConfiguration; + final location = matchList.uri.toString(); + return location; + } +} diff --git a/lib/router/cubits/router_cubit.freezed.dart b/lib/router/cubits/router_cubit.freezed.dart new file mode 100644 index 0000000..0f5b285 --- /dev/null +++ b/lib/router/cubits/router_cubit.freezed.dart @@ -0,0 +1,173 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'router_cubit.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$RouterState implements DiagnosticableTreeMixin { + bool get hasAnyAccount; + + /// Create a copy of RouterState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $RouterStateCopyWith get copyWith => + _$RouterStateCopyWithImpl(this as RouterState, _$identity); + + /// Serializes this RouterState to a JSON map. + Map toJson(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'RouterState')) + ..add(DiagnosticsProperty('hasAnyAccount', hasAnyAccount)); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is RouterState && + (identical(other.hasAnyAccount, hasAnyAccount) || + other.hasAnyAccount == hasAnyAccount)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, hasAnyAccount); + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'RouterState(hasAnyAccount: $hasAnyAccount)'; + } +} + +/// @nodoc +abstract mixin class $RouterStateCopyWith<$Res> { + factory $RouterStateCopyWith( + RouterState value, $Res Function(RouterState) _then) = + _$RouterStateCopyWithImpl; + @useResult + $Res call({bool hasAnyAccount}); +} + +/// @nodoc +class _$RouterStateCopyWithImpl<$Res> implements $RouterStateCopyWith<$Res> { + _$RouterStateCopyWithImpl(this._self, this._then); + + final RouterState _self; + final $Res Function(RouterState) _then; + + /// Create a copy of RouterState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? hasAnyAccount = null, + }) { + return _then(_self.copyWith( + hasAnyAccount: null == hasAnyAccount + ? _self.hasAnyAccount + : hasAnyAccount // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _RouterState with DiagnosticableTreeMixin implements RouterState { + const _RouterState({required this.hasAnyAccount}); + factory _RouterState.fromJson(Map json) => + _$RouterStateFromJson(json); + + @override + final bool hasAnyAccount; + + /// Create a copy of RouterState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$RouterStateCopyWith<_RouterState> get copyWith => + __$RouterStateCopyWithImpl<_RouterState>(this, _$identity); + + @override + Map toJson() { + return _$RouterStateToJson( + this, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'RouterState')) + ..add(DiagnosticsProperty('hasAnyAccount', hasAnyAccount)); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _RouterState && + (identical(other.hasAnyAccount, hasAnyAccount) || + other.hasAnyAccount == hasAnyAccount)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, hasAnyAccount); + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'RouterState(hasAnyAccount: $hasAnyAccount)'; + } +} + +/// @nodoc +abstract mixin class _$RouterStateCopyWith<$Res> + implements $RouterStateCopyWith<$Res> { + factory _$RouterStateCopyWith( + _RouterState value, $Res Function(_RouterState) _then) = + __$RouterStateCopyWithImpl; + @override + @useResult + $Res call({bool hasAnyAccount}); +} + +/// @nodoc +class __$RouterStateCopyWithImpl<$Res> implements _$RouterStateCopyWith<$Res> { + __$RouterStateCopyWithImpl(this._self, this._then); + + final _RouterState _self; + final $Res Function(_RouterState) _then; + + /// Create a copy of RouterState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? hasAnyAccount = null, + }) { + return _then(_RouterState( + hasAnyAccount: null == hasAnyAccount + ? _self.hasAnyAccount + : hasAnyAccount // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +// dart format on diff --git a/lib/router/cubit/router_cubit.g.dart b/lib/router/cubits/router_cubit.g.dart similarity index 58% rename from lib/router/cubit/router_cubit.g.dart rename to lib/router/cubits/router_cubit.g.dart index 31ca24a..3623d0e 100644 --- a/lib/router/cubit/router_cubit.g.dart +++ b/lib/router/cubits/router_cubit.g.dart @@ -6,14 +6,11 @@ part of 'router_cubit.dart'; // JsonSerializableGenerator // ************************************************************************** -_$RouterStateImpl _$$RouterStateImplFromJson(Map json) => - _$RouterStateImpl( +_RouterState _$RouterStateFromJson(Map json) => _RouterState( hasAnyAccount: json['has_any_account'] as bool, - hasActiveChat: json['has_active_chat'] as bool, ); -Map _$$RouterStateImplToJson(_$RouterStateImpl instance) => +Map _$RouterStateToJson(_RouterState instance) => { 'has_any_account': instance.hasAnyAccount, - 'has_active_chat': instance.hasActiveChat, }; diff --git a/lib/router/router.dart b/lib/router/router.dart index 1867a19..4fd9dc5 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -1 +1,2 @@ -export 'cubit/router_cubit.dart'; +export 'cubits/router_cubit.dart'; +export 'views/router_shell.dart'; diff --git a/lib/router/views/router_shell.dart b/lib/router/views/router_shell.dart new file mode 100644 index 0000000..d22129f --- /dev/null +++ b/lib/router/views/router_shell.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +import '../../keyboard_shortcuts.dart'; +import '../../notifications/notifications.dart'; +import '../../settings/settings.dart'; +import '../../theme/theme.dart'; + +class RouterShell extends StatelessWidget { + const RouterShell({required Widget child, super.key}) : _child = child; + + @override + Widget build(BuildContext context) => PopControl( + dismissible: false, + child: AsyncBlocBuilder( + builder: (context, state) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: + TextScaler.linear(state.themePreference.displayScale)), + child: NotificationsWidget( + child: KeyboardShortcuts(child: _child))))); + + final Widget _child; +} diff --git a/lib/settings/models/preferences.dart b/lib/settings/models/preferences.dart index 8dfcb73..a7432d6 100644 --- a/lib/settings/models/preferences.dart +++ b/lib/settings/models/preferences.dart @@ -1,6 +1,7 @@ import 'package:change_case/change_case.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import '../../notifications/notifications.dart'; import '../../theme/theme.dart'; part 'preferences.freezed.dart'; @@ -9,21 +10,17 @@ part 'preferences.g.dart'; // Lock preference changes how frequently the messenger locks its // interface and requires the identitySecretKey to be entered (pin/password/etc) @freezed -class LockPreference with _$LockPreference { +sealed class LockPreference with _$LockPreference { const factory LockPreference({ - required int inactivityLockSecs, - required bool lockWhenSwitching, - required bool lockWithSystemLock, + @Default(0) int inactivityLockSecs, + @Default(false) bool lockWhenSwitching, + @Default(false) bool lockWithSystemLock, }) = _LockPreference; factory LockPreference.fromJson(dynamic json) => _$LockPreferenceFromJson(json as Map); - static const LockPreference defaults = LockPreference( - inactivityLockSecs: 0, - lockWhenSwitching: false, - lockWithSystemLock: false, - ); + static const defaults = LockPreference(); } // Theme supports multiple translations @@ -40,18 +37,17 @@ enum LanguagePreference { // Preferences are stored in a table locally and globally affect all // accounts imported/added and the app in general @freezed -class Preferences with _$Preferences { +sealed class Preferences with _$Preferences { const factory Preferences({ - required ThemePreferences themePreferences, - required LanguagePreference language, - required LockPreference locking, + @Default(ThemePreferences.defaults) ThemePreferences themePreference, + @Default(LanguagePreference.defaults) LanguagePreference languagePreference, + @Default(LockPreference.defaults) LockPreference lockPreference, + @Default(NotificationsPreference.defaults) + NotificationsPreference notificationsPreference, }) = _Preferences; factory Preferences.fromJson(dynamic json) => _$PreferencesFromJson(json as Map); - static const Preferences defaults = Preferences( - themePreferences: ThemePreferences.defaults, - language: LanguagePreference.defaults, - locking: LockPreference.defaults); + static const defaults = Preferences(); } diff --git a/lib/settings/models/preferences.freezed.dart b/lib/settings/models/preferences.freezed.dart index e9667c8..9e090f5 100644 --- a/lib/settings/models/preferences.freezed.dart +++ b/lib/settings/models/preferences.freezed.dart @@ -1,3 +1,4 @@ +// dart format width=80 // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint @@ -9,147 +10,31 @@ part of 'preferences.dart'; // FreezedGenerator // ************************************************************************** +// dart format off T _$identity(T value) => value; -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -LockPreference _$LockPreferenceFromJson(Map json) { - return _LockPreference.fromJson(json); -} - /// @nodoc mixin _$LockPreference { - int get inactivityLockSecs => throw _privateConstructorUsedError; - bool get lockWhenSwitching => throw _privateConstructorUsedError; - bool get lockWithSystemLock => throw _privateConstructorUsedError; + int get inactivityLockSecs; + bool get lockWhenSwitching; + bool get lockWithSystemLock; - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) + /// Create a copy of LockPreference + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') $LockPreferenceCopyWith get copyWith => - throw _privateConstructorUsedError; -} + _$LockPreferenceCopyWithImpl( + this as LockPreference, _$identity); -/// @nodoc -abstract class $LockPreferenceCopyWith<$Res> { - factory $LockPreferenceCopyWith( - LockPreference value, $Res Function(LockPreference) then) = - _$LockPreferenceCopyWithImpl<$Res, LockPreference>; - @useResult - $Res call( - {int inactivityLockSecs, - bool lockWhenSwitching, - bool lockWithSystemLock}); -} - -/// @nodoc -class _$LockPreferenceCopyWithImpl<$Res, $Val extends LockPreference> - implements $LockPreferenceCopyWith<$Res> { - _$LockPreferenceCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? inactivityLockSecs = null, - Object? lockWhenSwitching = null, - Object? lockWithSystemLock = null, - }) { - return _then(_value.copyWith( - inactivityLockSecs: null == inactivityLockSecs - ? _value.inactivityLockSecs - : inactivityLockSecs // ignore: cast_nullable_to_non_nullable - as int, - lockWhenSwitching: null == lockWhenSwitching - ? _value.lockWhenSwitching - : lockWhenSwitching // ignore: cast_nullable_to_non_nullable - as bool, - lockWithSystemLock: null == lockWithSystemLock - ? _value.lockWithSystemLock - : lockWithSystemLock // ignore: cast_nullable_to_non_nullable - as bool, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$LockPreferenceImplCopyWith<$Res> - implements $LockPreferenceCopyWith<$Res> { - factory _$$LockPreferenceImplCopyWith(_$LockPreferenceImpl value, - $Res Function(_$LockPreferenceImpl) then) = - __$$LockPreferenceImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {int inactivityLockSecs, - bool lockWhenSwitching, - bool lockWithSystemLock}); -} - -/// @nodoc -class __$$LockPreferenceImplCopyWithImpl<$Res> - extends _$LockPreferenceCopyWithImpl<$Res, _$LockPreferenceImpl> - implements _$$LockPreferenceImplCopyWith<$Res> { - __$$LockPreferenceImplCopyWithImpl( - _$LockPreferenceImpl _value, $Res Function(_$LockPreferenceImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? inactivityLockSecs = null, - Object? lockWhenSwitching = null, - Object? lockWithSystemLock = null, - }) { - return _then(_$LockPreferenceImpl( - inactivityLockSecs: null == inactivityLockSecs - ? _value.inactivityLockSecs - : inactivityLockSecs // ignore: cast_nullable_to_non_nullable - as int, - lockWhenSwitching: null == lockWhenSwitching - ? _value.lockWhenSwitching - : lockWhenSwitching // ignore: cast_nullable_to_non_nullable - as bool, - lockWithSystemLock: null == lockWithSystemLock - ? _value.lockWithSystemLock - : lockWithSystemLock // ignore: cast_nullable_to_non_nullable - as bool, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$LockPreferenceImpl implements _LockPreference { - const _$LockPreferenceImpl( - {required this.inactivityLockSecs, - required this.lockWhenSwitching, - required this.lockWithSystemLock}); - - factory _$LockPreferenceImpl.fromJson(Map json) => - _$$LockPreferenceImplFromJson(json); - - @override - final int inactivityLockSecs; - @override - final bool lockWhenSwitching; - @override - final bool lockWithSystemLock; - - @override - String toString() { - return 'LockPreference(inactivityLockSecs: $inactivityLockSecs, lockWhenSwitching: $lockWhenSwitching, lockWithSystemLock: $lockWithSystemLock)'; - } + /// Serializes this LockPreference to a JSON map. + Map toJson(); @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$LockPreferenceImpl && + other is LockPreference && (identical(other.inactivityLockSecs, inactivityLockSecs) || other.inactivityLockSecs == inactivityLockSecs) && (identical(other.lockWhenSwitching, lockWhenSwitching) || @@ -158,250 +43,455 @@ class _$LockPreferenceImpl implements _LockPreference { other.lockWithSystemLock == lockWithSystemLock)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash( runtimeType, inactivityLockSecs, lockWhenSwitching, lockWithSystemLock); - @JsonKey(ignore: true) @override - @pragma('vm:prefer-inline') - _$$LockPreferenceImplCopyWith<_$LockPreferenceImpl> get copyWith => - __$$LockPreferenceImplCopyWithImpl<_$LockPreferenceImpl>( - this, _$identity); - - @override - Map toJson() { - return _$$LockPreferenceImplToJson( - this, - ); + String toString() { + return 'LockPreference(inactivityLockSecs: $inactivityLockSecs, lockWhenSwitching: $lockWhenSwitching, lockWithSystemLock: $lockWithSystemLock)'; } } -abstract class _LockPreference implements LockPreference { - const factory _LockPreference( - {required final int inactivityLockSecs, - required final bool lockWhenSwitching, - required final bool lockWithSystemLock}) = _$LockPreferenceImpl; - - factory _LockPreference.fromJson(Map json) = - _$LockPreferenceImpl.fromJson; - - @override - int get inactivityLockSecs; - @override - bool get lockWhenSwitching; - @override - bool get lockWithSystemLock; - @override - @JsonKey(ignore: true) - _$$LockPreferenceImplCopyWith<_$LockPreferenceImpl> get copyWith => - throw _privateConstructorUsedError; -} - -Preferences _$PreferencesFromJson(Map json) { - return _Preferences.fromJson(json); -} - /// @nodoc -mixin _$Preferences { - ThemePreferences get themePreferences => throw _privateConstructorUsedError; - LanguagePreference get language => throw _privateConstructorUsedError; - LockPreference get locking => throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $PreferencesCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $PreferencesCopyWith<$Res> { - factory $PreferencesCopyWith( - Preferences value, $Res Function(Preferences) then) = - _$PreferencesCopyWithImpl<$Res, Preferences>; +abstract mixin class $LockPreferenceCopyWith<$Res> { + factory $LockPreferenceCopyWith( + LockPreference value, $Res Function(LockPreference) _then) = + _$LockPreferenceCopyWithImpl; @useResult $Res call( - {ThemePreferences themePreferences, - LanguagePreference language, - LockPreference locking}); - - $ThemePreferencesCopyWith<$Res> get themePreferences; - $LockPreferenceCopyWith<$Res> get locking; + {int inactivityLockSecs, + bool lockWhenSwitching, + bool lockWithSystemLock}); } /// @nodoc -class _$PreferencesCopyWithImpl<$Res, $Val extends Preferences> - implements $PreferencesCopyWith<$Res> { - _$PreferencesCopyWithImpl(this._value, this._then); +class _$LockPreferenceCopyWithImpl<$Res> + implements $LockPreferenceCopyWith<$Res> { + _$LockPreferenceCopyWithImpl(this._self, this._then); - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; + final LockPreference _self; + final $Res Function(LockPreference) _then; + /// Create a copy of LockPreference + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ - Object? themePreferences = null, - Object? language = null, - Object? locking = null, + Object? inactivityLockSecs = null, + Object? lockWhenSwitching = null, + Object? lockWithSystemLock = null, }) { - return _then(_value.copyWith( - themePreferences: null == themePreferences - ? _value.themePreferences - : themePreferences // ignore: cast_nullable_to_non_nullable - as ThemePreferences, - language: null == language - ? _value.language - : language // ignore: cast_nullable_to_non_nullable - as LanguagePreference, - locking: null == locking - ? _value.locking - : locking // ignore: cast_nullable_to_non_nullable - as LockPreference, - ) as $Val); - } - - @override - @pragma('vm:prefer-inline') - $ThemePreferencesCopyWith<$Res> get themePreferences { - return $ThemePreferencesCopyWith<$Res>(_value.themePreferences, (value) { - return _then(_value.copyWith(themePreferences: value) as $Val); - }); - } - - @override - @pragma('vm:prefer-inline') - $LockPreferenceCopyWith<$Res> get locking { - return $LockPreferenceCopyWith<$Res>(_value.locking, (value) { - return _then(_value.copyWith(locking: value) as $Val); - }); - } -} - -/// @nodoc -abstract class _$$PreferencesImplCopyWith<$Res> - implements $PreferencesCopyWith<$Res> { - factory _$$PreferencesImplCopyWith( - _$PreferencesImpl value, $Res Function(_$PreferencesImpl) then) = - __$$PreferencesImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {ThemePreferences themePreferences, - LanguagePreference language, - LockPreference locking}); - - @override - $ThemePreferencesCopyWith<$Res> get themePreferences; - @override - $LockPreferenceCopyWith<$Res> get locking; -} - -/// @nodoc -class __$$PreferencesImplCopyWithImpl<$Res> - extends _$PreferencesCopyWithImpl<$Res, _$PreferencesImpl> - implements _$$PreferencesImplCopyWith<$Res> { - __$$PreferencesImplCopyWithImpl( - _$PreferencesImpl _value, $Res Function(_$PreferencesImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? themePreferences = null, - Object? language = null, - Object? locking = null, - }) { - return _then(_$PreferencesImpl( - themePreferences: null == themePreferences - ? _value.themePreferences - : themePreferences // ignore: cast_nullable_to_non_nullable - as ThemePreferences, - language: null == language - ? _value.language - : language // ignore: cast_nullable_to_non_nullable - as LanguagePreference, - locking: null == locking - ? _value.locking - : locking // ignore: cast_nullable_to_non_nullable - as LockPreference, + return _then(_self.copyWith( + inactivityLockSecs: null == inactivityLockSecs + ? _self.inactivityLockSecs + : inactivityLockSecs // ignore: cast_nullable_to_non_nullable + as int, + lockWhenSwitching: null == lockWhenSwitching + ? _self.lockWhenSwitching + : lockWhenSwitching // ignore: cast_nullable_to_non_nullable + as bool, + lockWithSystemLock: null == lockWithSystemLock + ? _self.lockWithSystemLock + : lockWithSystemLock // ignore: cast_nullable_to_non_nullable + as bool, )); } } /// @nodoc @JsonSerializable() -class _$PreferencesImpl implements _Preferences { - const _$PreferencesImpl( - {required this.themePreferences, - required this.language, - required this.locking}); - - factory _$PreferencesImpl.fromJson(Map json) => - _$$PreferencesImplFromJson(json); +class _LockPreference implements LockPreference { + const _LockPreference( + {this.inactivityLockSecs = 0, + this.lockWhenSwitching = false, + this.lockWithSystemLock = false}); + factory _LockPreference.fromJson(Map json) => + _$LockPreferenceFromJson(json); @override - final ThemePreferences themePreferences; + @JsonKey() + final int inactivityLockSecs; @override - final LanguagePreference language; + @JsonKey() + final bool lockWhenSwitching; @override - final LockPreference locking; + @JsonKey() + final bool lockWithSystemLock; + + /// Create a copy of LockPreference + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$LockPreferenceCopyWith<_LockPreference> get copyWith => + __$LockPreferenceCopyWithImpl<_LockPreference>(this, _$identity); @override - String toString() { - return 'Preferences(themePreferences: $themePreferences, language: $language, locking: $locking)'; + Map toJson() { + return _$LockPreferenceToJson( + this, + ); } @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$PreferencesImpl && - (identical(other.themePreferences, themePreferences) || - other.themePreferences == themePreferences) && - (identical(other.language, language) || - other.language == language) && - (identical(other.locking, locking) || other.locking == locking)); + other is _LockPreference && + (identical(other.inactivityLockSecs, inactivityLockSecs) || + other.inactivityLockSecs == inactivityLockSecs) && + (identical(other.lockWhenSwitching, lockWhenSwitching) || + other.lockWhenSwitching == lockWhenSwitching) && + (identical(other.lockWithSystemLock, lockWithSystemLock) || + other.lockWithSystemLock == lockWithSystemLock)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => - Object.hash(runtimeType, themePreferences, language, locking); + int get hashCode => Object.hash( + runtimeType, inactivityLockSecs, lockWhenSwitching, lockWithSystemLock); - @JsonKey(ignore: true) + @override + String toString() { + return 'LockPreference(inactivityLockSecs: $inactivityLockSecs, lockWhenSwitching: $lockWhenSwitching, lockWithSystemLock: $lockWithSystemLock)'; + } +} + +/// @nodoc +abstract mixin class _$LockPreferenceCopyWith<$Res> + implements $LockPreferenceCopyWith<$Res> { + factory _$LockPreferenceCopyWith( + _LockPreference value, $Res Function(_LockPreference) _then) = + __$LockPreferenceCopyWithImpl; + @override + @useResult + $Res call( + {int inactivityLockSecs, + bool lockWhenSwitching, + bool lockWithSystemLock}); +} + +/// @nodoc +class __$LockPreferenceCopyWithImpl<$Res> + implements _$LockPreferenceCopyWith<$Res> { + __$LockPreferenceCopyWithImpl(this._self, this._then); + + final _LockPreference _self; + final $Res Function(_LockPreference) _then; + + /// Create a copy of LockPreference + /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') - _$$PreferencesImplCopyWith<_$PreferencesImpl> get copyWith => - __$$PreferencesImplCopyWithImpl<_$PreferencesImpl>(this, _$identity); + $Res call({ + Object? inactivityLockSecs = null, + Object? lockWhenSwitching = null, + Object? lockWithSystemLock = null, + }) { + return _then(_LockPreference( + inactivityLockSecs: null == inactivityLockSecs + ? _self.inactivityLockSecs + : inactivityLockSecs // ignore: cast_nullable_to_non_nullable + as int, + lockWhenSwitching: null == lockWhenSwitching + ? _self.lockWhenSwitching + : lockWhenSwitching // ignore: cast_nullable_to_non_nullable + as bool, + lockWithSystemLock: null == lockWithSystemLock + ? _self.lockWithSystemLock + : lockWithSystemLock // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc +mixin _$Preferences { + ThemePreferences get themePreference; + LanguagePreference get languagePreference; + LockPreference get lockPreference; + NotificationsPreference get notificationsPreference; + + /// Create a copy of Preferences + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $PreferencesCopyWith get copyWith => + _$PreferencesCopyWithImpl(this as Preferences, _$identity); + + /// Serializes this Preferences to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is Preferences && + (identical(other.themePreference, themePreference) || + other.themePreference == themePreference) && + (identical(other.languagePreference, languagePreference) || + other.languagePreference == languagePreference) && + (identical(other.lockPreference, lockPreference) || + other.lockPreference == lockPreference) && + (identical( + other.notificationsPreference, notificationsPreference) || + other.notificationsPreference == notificationsPreference)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, themePreference, + languagePreference, lockPreference, notificationsPreference); + + @override + String toString() { + return 'Preferences(themePreference: $themePreference, languagePreference: $languagePreference, lockPreference: $lockPreference, notificationsPreference: $notificationsPreference)'; + } +} + +/// @nodoc +abstract mixin class $PreferencesCopyWith<$Res> { + factory $PreferencesCopyWith( + Preferences value, $Res Function(Preferences) _then) = + _$PreferencesCopyWithImpl; + @useResult + $Res call( + {ThemePreferences themePreference, + LanguagePreference languagePreference, + LockPreference lockPreference, + NotificationsPreference notificationsPreference}); + + $ThemePreferencesCopyWith<$Res> get themePreference; + $LockPreferenceCopyWith<$Res> get lockPreference; + $NotificationsPreferenceCopyWith<$Res> get notificationsPreference; +} + +/// @nodoc +class _$PreferencesCopyWithImpl<$Res> implements $PreferencesCopyWith<$Res> { + _$PreferencesCopyWithImpl(this._self, this._then); + + final Preferences _self; + final $Res Function(Preferences) _then; + + /// Create a copy of Preferences + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? themePreference = null, + Object? languagePreference = null, + Object? lockPreference = null, + Object? notificationsPreference = null, + }) { + return _then(_self.copyWith( + themePreference: null == themePreference + ? _self.themePreference + : themePreference // ignore: cast_nullable_to_non_nullable + as ThemePreferences, + languagePreference: null == languagePreference + ? _self.languagePreference + : languagePreference // ignore: cast_nullable_to_non_nullable + as LanguagePreference, + lockPreference: null == lockPreference + ? _self.lockPreference + : lockPreference // ignore: cast_nullable_to_non_nullable + as LockPreference, + notificationsPreference: null == notificationsPreference + ? _self.notificationsPreference + : notificationsPreference // ignore: cast_nullable_to_non_nullable + as NotificationsPreference, + )); + } + + /// Create a copy of Preferences + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $ThemePreferencesCopyWith<$Res> get themePreference { + return $ThemePreferencesCopyWith<$Res>(_self.themePreference, (value) { + return _then(_self.copyWith(themePreference: value)); + }); + } + + /// Create a copy of Preferences + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $LockPreferenceCopyWith<$Res> get lockPreference { + return $LockPreferenceCopyWith<$Res>(_self.lockPreference, (value) { + return _then(_self.copyWith(lockPreference: value)); + }); + } + + /// Create a copy of Preferences + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $NotificationsPreferenceCopyWith<$Res> get notificationsPreference { + return $NotificationsPreferenceCopyWith<$Res>(_self.notificationsPreference, + (value) { + return _then(_self.copyWith(notificationsPreference: value)); + }); + } +} + +/// @nodoc +@JsonSerializable() +class _Preferences implements Preferences { + const _Preferences( + {this.themePreference = ThemePreferences.defaults, + this.languagePreference = LanguagePreference.defaults, + this.lockPreference = LockPreference.defaults, + this.notificationsPreference = NotificationsPreference.defaults}); + factory _Preferences.fromJson(Map json) => + _$PreferencesFromJson(json); + + @override + @JsonKey() + final ThemePreferences themePreference; + @override + @JsonKey() + final LanguagePreference languagePreference; + @override + @JsonKey() + final LockPreference lockPreference; + @override + @JsonKey() + final NotificationsPreference notificationsPreference; + + /// Create a copy of Preferences + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$PreferencesCopyWith<_Preferences> get copyWith => + __$PreferencesCopyWithImpl<_Preferences>(this, _$identity); @override Map toJson() { - return _$$PreferencesImplToJson( + return _$PreferencesToJson( this, ); } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _Preferences && + (identical(other.themePreference, themePreference) || + other.themePreference == themePreference) && + (identical(other.languagePreference, languagePreference) || + other.languagePreference == languagePreference) && + (identical(other.lockPreference, lockPreference) || + other.lockPreference == lockPreference) && + (identical( + other.notificationsPreference, notificationsPreference) || + other.notificationsPreference == notificationsPreference)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, themePreference, + languagePreference, lockPreference, notificationsPreference); + + @override + String toString() { + return 'Preferences(themePreference: $themePreference, languagePreference: $languagePreference, lockPreference: $lockPreference, notificationsPreference: $notificationsPreference)'; + } } -abstract class _Preferences implements Preferences { - const factory _Preferences( - {required final ThemePreferences themePreferences, - required final LanguagePreference language, - required final LockPreference locking}) = _$PreferencesImpl; - - factory _Preferences.fromJson(Map json) = - _$PreferencesImpl.fromJson; +/// @nodoc +abstract mixin class _$PreferencesCopyWith<$Res> + implements $PreferencesCopyWith<$Res> { + factory _$PreferencesCopyWith( + _Preferences value, $Res Function(_Preferences) _then) = + __$PreferencesCopyWithImpl; + @override + @useResult + $Res call( + {ThemePreferences themePreference, + LanguagePreference languagePreference, + LockPreference lockPreference, + NotificationsPreference notificationsPreference}); @override - ThemePreferences get themePreferences; + $ThemePreferencesCopyWith<$Res> get themePreference; @override - LanguagePreference get language; + $LockPreferenceCopyWith<$Res> get lockPreference; @override - LockPreference get locking; - @override - @JsonKey(ignore: true) - _$$PreferencesImplCopyWith<_$PreferencesImpl> get copyWith => - throw _privateConstructorUsedError; + $NotificationsPreferenceCopyWith<$Res> get notificationsPreference; } + +/// @nodoc +class __$PreferencesCopyWithImpl<$Res> implements _$PreferencesCopyWith<$Res> { + __$PreferencesCopyWithImpl(this._self, this._then); + + final _Preferences _self; + final $Res Function(_Preferences) _then; + + /// Create a copy of Preferences + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? themePreference = null, + Object? languagePreference = null, + Object? lockPreference = null, + Object? notificationsPreference = null, + }) { + return _then(_Preferences( + themePreference: null == themePreference + ? _self.themePreference + : themePreference // ignore: cast_nullable_to_non_nullable + as ThemePreferences, + languagePreference: null == languagePreference + ? _self.languagePreference + : languagePreference // ignore: cast_nullable_to_non_nullable + as LanguagePreference, + lockPreference: null == lockPreference + ? _self.lockPreference + : lockPreference // ignore: cast_nullable_to_non_nullable + as LockPreference, + notificationsPreference: null == notificationsPreference + ? _self.notificationsPreference + : notificationsPreference // ignore: cast_nullable_to_non_nullable + as NotificationsPreference, + )); + } + + /// Create a copy of Preferences + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $ThemePreferencesCopyWith<$Res> get themePreference { + return $ThemePreferencesCopyWith<$Res>(_self.themePreference, (value) { + return _then(_self.copyWith(themePreference: value)); + }); + } + + /// Create a copy of Preferences + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $LockPreferenceCopyWith<$Res> get lockPreference { + return $LockPreferenceCopyWith<$Res>(_self.lockPreference, (value) { + return _then(_self.copyWith(lockPreference: value)); + }); + } + + /// Create a copy of Preferences + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $NotificationsPreferenceCopyWith<$Res> get notificationsPreference { + return $NotificationsPreferenceCopyWith<$Res>(_self.notificationsPreference, + (value) { + return _then(_self.copyWith(notificationsPreference: value)); + }); + } +} + +// dart format on diff --git a/lib/settings/models/preferences.g.dart b/lib/settings/models/preferences.g.dart index 23cd5bb..55f21a7 100644 --- a/lib/settings/models/preferences.g.dart +++ b/lib/settings/models/preferences.g.dart @@ -6,31 +6,39 @@ part of 'preferences.dart'; // JsonSerializableGenerator // ************************************************************************** -_$LockPreferenceImpl _$$LockPreferenceImplFromJson(Map json) => - _$LockPreferenceImpl( - inactivityLockSecs: (json['inactivity_lock_secs'] as num).toInt(), - lockWhenSwitching: json['lock_when_switching'] as bool, - lockWithSystemLock: json['lock_with_system_lock'] as bool, +_LockPreference _$LockPreferenceFromJson(Map json) => + _LockPreference( + inactivityLockSecs: (json['inactivity_lock_secs'] as num?)?.toInt() ?? 0, + lockWhenSwitching: json['lock_when_switching'] as bool? ?? false, + lockWithSystemLock: json['lock_with_system_lock'] as bool? ?? false, ); -Map _$$LockPreferenceImplToJson( - _$LockPreferenceImpl instance) => +Map _$LockPreferenceToJson(_LockPreference instance) => { 'inactivity_lock_secs': instance.inactivityLockSecs, 'lock_when_switching': instance.lockWhenSwitching, 'lock_with_system_lock': instance.lockWithSystemLock, }; -_$PreferencesImpl _$$PreferencesImplFromJson(Map json) => - _$PreferencesImpl( - themePreferences: ThemePreferences.fromJson(json['theme_preferences']), - language: LanguagePreference.fromJson(json['language']), - locking: LockPreference.fromJson(json['locking']), +_Preferences _$PreferencesFromJson(Map json) => _Preferences( + themePreference: json['theme_preference'] == null + ? ThemePreferences.defaults + : ThemePreferences.fromJson(json['theme_preference']), + languagePreference: json['language_preference'] == null + ? LanguagePreference.defaults + : LanguagePreference.fromJson(json['language_preference']), + lockPreference: json['lock_preference'] == null + ? LockPreference.defaults + : LockPreference.fromJson(json['lock_preference']), + notificationsPreference: json['notifications_preference'] == null + ? NotificationsPreference.defaults + : NotificationsPreference.fromJson(json['notifications_preference']), ); -Map _$$PreferencesImplToJson(_$PreferencesImpl instance) => +Map _$PreferencesToJson(_Preferences instance) => { - 'theme_preferences': instance.themePreferences.toJson(), - 'language': instance.language.toJson(), - 'locking': instance.locking.toJson(), + 'theme_preference': instance.themePreference.toJson(), + 'language_preference': instance.languagePreference.toJson(), + 'lock_preference': instance.lockPreference.toJson(), + 'notifications_preference': instance.notificationsPreference.toJson(), }; diff --git a/lib/settings/settings_page.dart b/lib/settings/settings_page.dart index 5eb89fb..bb6ee3e 100644 --- a/lib/settings/settings_page.dart +++ b/lib/settings/settings_page.dart @@ -1,63 +1,61 @@ import 'package:animated_theme_switcher/animated_theme_switcher.dart'; import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:go_router/go_router.dart'; import '../layout/default_app_bar.dart'; +import '../notifications/notifications.dart'; import '../theme/theme.dart'; -import '../tools/tools.dart'; import '../veilid_processor/veilid_processor.dart'; import 'settings.dart'; -class SettingsPage extends StatefulWidget { +class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); - @override - SettingsPageState createState() => SettingsPageState(); -} - -class SettingsPageState extends State { - final _formKey = GlobalKey(); - static const String formFieldTheme = 'theme'; - static const String formFieldBrightness = 'brightness'; - - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((_) async { - await changeWindowSetup( - TitleBarStyle.normal, OrientationCapability.normal); - }); - } - @override Widget build(BuildContext context) => AsyncBlocBuilder( - builder: (context, state) => ThemeSwitchingArea( - child: Scaffold( - appBar: DefaultAppBar( - title: Text(translate('settings_page.titlebar')), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => GoRouterHelper(context).pop(), - ), - actions: [ - const SignalStrengthMeterWidget() - .paddingLTRB(16, 0, 16, 0), - ]), - body: FormBuilder( - key: _formKey, - child: ListView( + builder: (context, state) => ThemeSwitcher.withTheme( + builder: (_, switcher, theme) => StyledScaffold( + appBar: DefaultAppBar( + context: context, + title: Text(translate('settings_page.titlebar')), + leading: IconButton( + iconSize: 24.scaled(context), + icon: const Icon(Icons.arrow_back), + onPressed: () => GoRouterHelper(context).pop(), + ), + actions: [ + const SignalStrengthMeterWidget() + .paddingLTRB(16, 0, 16, 0), + ]), + body: ListView( + padding: const EdgeInsets.all(8).scaled(context), children: [ buildSettingsPageColorPreferences( - context: context, onChanged: () => setState(() {})), + context: context, + switcher: switcher, + ), buildSettingsPageBrightnessPreferences( - context: context, onChanged: () => setState(() {})), - ].map((x) => x.paddingLTRB(0, 0, 0, 8)).toList(), - ), - ).paddingSymmetric(horizontal: 24, vertical: 16), - ))); + context: context, + switcher: switcher, + ), + buildSettingsPageDisplayScalePreferences( + context: context, + switcher: switcher, + ), + buildSettingsPageWallpaperPreferences( + context: context, + switcher: switcher, + ), + buildSettingsPageNotificationPreferences( + context: context, + ), + ] + .map((x) => x.paddingLTRB(0, 0, 0, 8.scaled(context))) + .toList(), + ).paddingSymmetric(vertical: 4.scaled(context)), + ), + )); } diff --git a/lib/theme/models/chat_theme.dart b/lib/theme/models/chat_theme.dart deleted file mode 100644 index b6ef7ba..0000000 --- a/lib/theme/models/chat_theme.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_chat_ui/flutter_chat_ui.dart'; - -import 'scale_scheme.dart'; - -ChatTheme makeChatTheme(ScaleScheme scale, TextTheme textTheme) => - DefaultChatTheme( - primaryColor: scale.primaryScale.calloutBackground, - secondaryColor: scale.secondaryScale.calloutBackground, - backgroundColor: scale.grayScale.appBackground, - sendButtonIcon: Image.asset( - 'assets/icon-send.png', - color: scale.primaryScale.borderText, - package: 'flutter_chat_ui', - ), - inputBackgroundColor: Colors.blue, - inputBorderRadius: BorderRadius.zero, - inputTextDecoration: InputDecoration( - filled: true, - fillColor: scale.primaryScale.elementBackground, - isDense: true, - contentPadding: const EdgeInsets.fromLTRB(8, 12, 8, 12), - border: const OutlineInputBorder( - borderSide: BorderSide.none, - borderRadius: BorderRadius.all(Radius.circular(8))), - focusedBorder: const OutlineInputBorder( - borderSide: BorderSide.none, - borderRadius: BorderRadius.all(Radius.circular(8))), - ), - inputContainerDecoration: - BoxDecoration(color: scale.primaryScale.border), - inputPadding: const EdgeInsets.all(9), - inputTextColor: scale.primaryScale.appText, - attachmentButtonIcon: const Icon(Icons.attach_file), - sentMessageBodyTextStyle: TextStyle( - color: scale.primaryScale.calloutText, - fontSize: 16, - fontWeight: FontWeight.w500, - height: 1.5, - ), - sentEmojiMessageTextStyle: const TextStyle( - color: Colors.white, - fontSize: 64, - ), - receivedMessageBodyTextStyle: TextStyle( - color: scale.secondaryScale.calloutText, - fontSize: 16, - fontWeight: FontWeight.w500, - height: 1.5, - ), - receivedEmojiMessageTextStyle: const TextStyle( - color: Colors.white, - fontSize: 64, - )); diff --git a/lib/theme/models/contrast_generator.dart b/lib/theme/models/contrast_generator.dart index 52b32c9..05c5f55 100644 --- a/lib/theme/models/contrast_generator.dart +++ b/lib/theme/models/contrast_generator.dart @@ -1,15 +1,16 @@ import 'package:flutter/material.dart'; import 'radix_generator.dart'; -import 'scale_color.dart'; -import 'scale_input_decorator_theme.dart'; -import 'scale_scheme.dart'; +import 'scale_theme/scale_theme.dart'; -ScaleScheme _contrastScale(Brightness brightness) { - final back = brightness == Brightness.light ? Colors.white : Colors.black; - final front = brightness == Brightness.light ? Colors.black : Colors.white; +ScaleColor _contrastScaleColor( + {required Brightness brightness, + required Color frontColor, + required Color backColor}) { + final back = brightness == Brightness.light ? backColor : frontColor; + final front = brightness == Brightness.light ? frontColor : backColor; - final primaryScale = ScaleColor( + return ScaleColor( appBackground: back, subtleBackground: back, elementBackground: back, @@ -25,59 +26,309 @@ ScaleScheme _contrastScale(Brightness brightness) { primaryText: front, borderText: back, dialogBorder: front, + dialogBorderText: back, calloutBackground: front, calloutText: back, ); - - return ScaleScheme( - primaryScale: primaryScale, - primaryAlphaScale: primaryScale, - secondaryScale: primaryScale, - tertiaryScale: primaryScale, - grayScale: primaryScale, - errorScale: primaryScale); } -ThemeData contrastGenerator(Brightness brightness) { - final textTheme = makeRadixTextTheme(brightness); - final scaleScheme = _contrastScale(brightness); - final colorScheme = scaleScheme.toColorScheme(brightness); - final scaleConfig = ScaleConfig(useVisualIndicators: true); +const kMonoSpaceFontDisplay = 'Source Code Pro'; +const kMonoSpaceFontText = 'Source Code Pro'; - final themeData = ThemeData.from( - colorScheme: colorScheme, textTheme: textTheme, useMaterial3: true); - return themeData.copyWith( - bottomSheetTheme: themeData.bottomSheetTheme.copyWith( - elevation: 0, - modalElevation: 0, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16)))), - canvasColor: scaleScheme.primaryScale.subtleBackground, - chipTheme: themeData.chipTheme.copyWith( - backgroundColor: scaleScheme.primaryScale.elementBackground, - selectedColor: scaleScheme.primaryScale.activeElementBackground, - surfaceTintColor: scaleScheme.primaryScale.hoverElementBackground, - checkmarkColor: scaleScheme.primaryScale.border, - side: BorderSide(color: scaleScheme.primaryScale.border)), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - backgroundColor: scaleScheme.primaryScale.elementBackground, - foregroundColor: scaleScheme.primaryScale.appText, - disabledBackgroundColor: scaleScheme.grayScale.elementBackground, - disabledForegroundColor: scaleScheme.grayScale.appText, - shape: RoundedRectangleBorder( - side: BorderSide(color: scaleScheme.primaryScale.border), - borderRadius: BorderRadius.circular(8))), - ), +TextTheme makeMonoSpaceTextTheme(Brightness brightness) => + (brightness == Brightness.light) + ? const TextTheme( + displayLarge: TextStyle( + debugLabel: 'blackMonoSpace displayLarge', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.black54, + decoration: TextDecoration.none), + displayMedium: TextStyle( + debugLabel: 'blackMonoSpace displayMedium', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.black54, + decoration: TextDecoration.none), + displaySmall: TextStyle( + debugLabel: 'blackMonoSpace displaySmall', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.black54, + decoration: TextDecoration.none), + headlineLarge: TextStyle( + debugLabel: 'blackMonoSpace headlineLarge', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.black54, + decoration: TextDecoration.none), + headlineMedium: TextStyle( + debugLabel: 'blackMonoSpace headlineMedium', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.black54, + decoration: TextDecoration.none), + headlineSmall: TextStyle( + debugLabel: 'blackMonoSpace headlineSmall', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.black87, + decoration: TextDecoration.none), + titleLarge: TextStyle( + debugLabel: 'blackMonoSpace titleLarge', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.black87, + decoration: TextDecoration.none), + titleMedium: TextStyle( + debugLabel: 'blackMonoSpace titleMedium', + fontFamily: kMonoSpaceFontText, + color: Colors.black87, + decoration: TextDecoration.none), + titleSmall: TextStyle( + debugLabel: 'blackMonoSpace titleSmall', + fontFamily: kMonoSpaceFontText, + color: Colors.black, + decoration: TextDecoration.none), + bodyLarge: TextStyle( + debugLabel: 'blackMonoSpace bodyLarge', + fontFamily: kMonoSpaceFontText, + color: Colors.black87, + decoration: TextDecoration.none), + bodyMedium: TextStyle( + debugLabel: 'blackMonoSpace bodyMedium', + fontFamily: kMonoSpaceFontText, + color: Colors.black87, + decoration: TextDecoration.none), + bodySmall: TextStyle( + debugLabel: 'blackMonoSpace bodySmall', + fontFamily: kMonoSpaceFontText, + color: Colors.black54, + decoration: TextDecoration.none), + labelLarge: TextStyle( + debugLabel: 'blackMonoSpace labelLarge', + fontFamily: kMonoSpaceFontText, + color: Colors.black87, + decoration: TextDecoration.none), + labelMedium: TextStyle( + debugLabel: 'blackMonoSpace labelMedium', + fontFamily: kMonoSpaceFontText, + color: Colors.black, + decoration: TextDecoration.none), + labelSmall: TextStyle( + debugLabel: 'blackMonoSpace labelSmall', + fontFamily: kMonoSpaceFontText, + color: Colors.black, + decoration: TextDecoration.none), + ) + : const TextTheme( + displayLarge: TextStyle( + debugLabel: 'whiteMonoSpace displayLarge', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.white70, + decoration: TextDecoration.none), + displayMedium: TextStyle( + debugLabel: 'whiteMonoSpace displayMedium', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.white70, + decoration: TextDecoration.none), + displaySmall: TextStyle( + debugLabel: 'whiteMonoSpace displaySmall', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.white70, + decoration: TextDecoration.none), + headlineLarge: TextStyle( + debugLabel: 'whiteMonoSpace headlineLarge', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.white70, + decoration: TextDecoration.none), + headlineMedium: TextStyle( + debugLabel: 'whiteMonoSpace headlineMedium', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.white70, + decoration: TextDecoration.none), + headlineSmall: TextStyle( + debugLabel: 'whiteMonoSpace headlineSmall', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.white, + decoration: TextDecoration.none), + titleLarge: TextStyle( + debugLabel: 'whiteMonoSpace titleLarge', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.white, + decoration: TextDecoration.none), + titleMedium: TextStyle( + debugLabel: 'whiteMonoSpace titleMedium', + fontFamily: kMonoSpaceFontText, + color: Colors.white, + decoration: TextDecoration.none), + titleSmall: TextStyle( + debugLabel: 'whiteMonoSpace titleSmall', + fontFamily: kMonoSpaceFontText, + color: Colors.white, + decoration: TextDecoration.none), + bodyLarge: TextStyle( + debugLabel: 'whiteMonoSpace bodyLarge', + fontFamily: kMonoSpaceFontText, + color: Colors.white, + decoration: TextDecoration.none), + bodyMedium: TextStyle( + debugLabel: 'whiteMonoSpace bodyMedium', + fontFamily: kMonoSpaceFontText, + color: Colors.white, + decoration: TextDecoration.none), + bodySmall: TextStyle( + debugLabel: 'whiteMonoSpace bodySmall', + fontFamily: kMonoSpaceFontText, + color: Colors.white70, + decoration: TextDecoration.none), + labelLarge: TextStyle( + debugLabel: 'whiteMonoSpace labelLarge', + fontFamily: kMonoSpaceFontText, + color: Colors.white, + decoration: TextDecoration.none), + labelMedium: TextStyle( + debugLabel: 'whiteMonoSpace labelMedium', + fontFamily: kMonoSpaceFontText, + color: Colors.white, + decoration: TextDecoration.none), + labelSmall: TextStyle( + debugLabel: 'whiteMonoSpace labelSmall', + fontFamily: kMonoSpaceFontText, + color: Colors.white, + decoration: TextDecoration.none), + ); + +ScaleScheme _contrastScaleScheme( + {required Brightness brightness, + required Color primaryFront, + required Color primaryBack, + required Color secondaryFront, + required Color secondaryBack, + required Color tertiaryFront, + required Color tertiaryBack, + required Color grayFront, + required Color grayBack, + required Color errorFront, + required Color errorBack}) => + ScaleScheme( + primaryScale: _contrastScaleColor( + brightness: brightness, + frontColor: primaryFront, + backColor: primaryBack), + primaryAlphaScale: _contrastScaleColor( + brightness: brightness, + frontColor: primaryFront, + backColor: primaryBack), + secondaryScale: _contrastScaleColor( + brightness: brightness, + frontColor: secondaryFront, + backColor: secondaryBack), + tertiaryScale: _contrastScaleColor( + brightness: brightness, + frontColor: tertiaryFront, + backColor: tertiaryBack), + grayScale: _contrastScaleColor( + brightness: brightness, frontColor: grayFront, backColor: grayBack), + errorScale: _contrastScaleColor( + brightness: brightness, + frontColor: errorFront, + backColor: errorBack)); + +ThemeData contrastGenerator({ + required Brightness brightness, + required ScaleConfig scaleConfig, + required Color primaryFront, + required Color primaryBack, + required Color secondaryFront, + required Color secondaryBack, + required Color tertiaryFront, + required Color tertiaryBack, + required Color grayFront, + required Color grayBack, + required Color errorFront, + required Color errorBack, + TextTheme? customTextTheme, +}) { + final textTheme = customTextTheme ?? makeRadixTextTheme(brightness); + final scheme = _contrastScaleScheme( + brightness: brightness, + primaryFront: primaryFront, + primaryBack: primaryBack, + secondaryFront: secondaryFront, + secondaryBack: secondaryBack, + tertiaryFront: tertiaryFront, + tertiaryBack: tertiaryBack, + grayFront: grayFront, + grayBack: grayBack, + errorFront: errorFront, + errorBack: errorBack, + ); + + final scaleTheme = + ScaleTheme(textTheme: textTheme, scheme: scheme, config: scaleConfig); + + final baseThemeData = scaleTheme.toThemeData(brightness); + + WidgetStateProperty elementBorderWidgetStateProperty() => + WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return BorderSide(color: scheme.grayScale.border.withAlpha(0x7F)); + } else if (states.contains(WidgetState.pressed)) { + return BorderSide( + color: scheme.primaryScale.border, + strokeAlign: BorderSide.strokeAlignOutside); + } else if (states.contains(WidgetState.hovered)) { + return BorderSide(color: scheme.primaryScale.hoverBorder, width: 2); + } else if (states.contains(WidgetState.focused)) { + return BorderSide(color: scheme.primaryScale.hoverBorder, width: 2); + } + return BorderSide(color: scheme.primaryScale.border); + }); + + WidgetStateProperty elementBackgroundWidgetStateProperty() => + WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return scheme.grayScale.elementBackground; + } else if (states.contains(WidgetState.pressed)) { + return scheme.primaryScale.activeElementBackground; + } else if (states.contains(WidgetState.hovered)) { + return scheme.primaryScale.hoverElementBackground; + } else if (states.contains(WidgetState.focused)) { + return scheme.primaryScale.activeElementBackground; + } + return scheme.primaryScale.elementBackground; + }); + + final elevatedButtonTheme = ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: scheme.primaryScale.elementBackground, + foregroundColor: scheme.primaryScale.appText, + disabledBackgroundColor: + scheme.grayScale.elementBackground.withAlpha(0x7F), + disabledForegroundColor: scheme.grayScale.appText.withAlpha(0x7F), + shape: RoundedRectangleBorder( + side: BorderSide(color: scheme.primaryScale.border), + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale))) + .copyWith( + side: elementBorderWidgetStateProperty(), + backgroundColor: elementBackgroundWidgetStateProperty())); + + final sliderTheme = SliderThemeData.fromPrimaryColors( + primaryColor: scheme.primaryScale.borderText, + primaryColorDark: scheme.primaryScale.border, + primaryColorLight: scheme.primaryScale.border, + valueIndicatorTextStyle: textTheme.labelMedium! + .copyWith(color: scheme.primaryScale.borderText)); + + final themeData = baseThemeData.copyWith( + // chipTheme: baseThemeData.chipTheme.copyWith( + // backgroundColor: scaleScheme.primaryScale.elementBackground, + // selectedColor: scaleScheme.primaryScale.activeElementBackground, + // surfaceTintColor: scaleScheme.primaryScale.hoverElementBackground, + // checkmarkColor: scaleScheme.primaryScale.border, + // side: BorderSide(color: scaleScheme.primaryScale.border)), + elevatedButtonTheme: elevatedButtonTheme, + sliderTheme: sliderTheme, textSelectionTheme: TextSelectionThemeData( - cursorColor: scaleScheme.primaryScale.appText, - selectionColor: scaleScheme.primaryScale.appText.withAlpha(0x7F), - selectionHandleColor: scaleScheme.primaryScale.appText), - inputDecorationTheme: ScaleInputDecoratorTheme(scaleScheme, textTheme), - extensions: >[ - scaleScheme, - scaleConfig, - ]); + cursorColor: scheme.primaryScale.appText, + selectionColor: scheme.primaryScale.appText.withAlpha(0x7F), + selectionHandleColor: scheme.primaryScale.appText), + extensions: >[scheme, scaleConfig, scaleTheme]); + + return themeData; } diff --git a/lib/theme/models/models.dart b/lib/theme/models/models.dart index e0ba490..7806ede 100644 --- a/lib/theme/models/models.dart +++ b/lib/theme/models/models.dart @@ -1,6 +1,3 @@ -export 'chat_theme.dart'; export 'radix_generator.dart'; -export 'scale_color.dart'; -export 'scale_scheme.dart'; -export 'slider_tile.dart'; +export 'scale_theme/scale_theme.dart'; export 'theme_preference.dart'; diff --git a/lib/theme/models/radix_generator.dart b/lib/theme/models/radix_generator.dart index 4bd593f..ce05769 100644 --- a/lib/theme/models/radix_generator.dart +++ b/lib/theme/models/radix_generator.dart @@ -1,12 +1,11 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:radix_colors/radix_colors.dart'; import '../../tools/tools.dart'; -import 'scale_color.dart'; -import 'scale_input_decorator_theme.dart'; -import 'scale_scheme.dart'; +import 'scale_theme/scale_theme.dart'; enum RadixThemeColor { scarlet, // red + violet + tomato @@ -289,6 +288,7 @@ extension ToScaleColor on RadixColor { primaryText: scaleExtra.foregroundText, borderText: step12, dialogBorder: step9, + dialogBorderText: scaleExtra.foregroundText, calloutBackground: step9, calloutText: scaleExtra.foregroundText, ); @@ -570,7 +570,11 @@ RadixScheme _radixScheme(Brightness brightness, RadixThemeColor themeColor) { TextTheme makeRadixTextTheme(Brightness brightness) { late final TextTheme textTheme; - if (Platform.isIOS) { + if (kIsWeb) { + textTheme = (brightness == Brightness.light) + ? Typography.blackHelsinki + : Typography.whiteHelsinki; + } else if (Platform.isIOS) { textTheme = (brightness == Brightness.light) ? Typography.blackCupertino : Typography.whiteCupertino; @@ -599,63 +603,48 @@ TextTheme makeRadixTextTheme(Brightness brightness) { return textTheme; } +double wallpaperAlpha(Brightness brightness, RadixThemeColor themeColor) { + switch (themeColor) { + case RadixThemeColor.scarlet: + return 64; + case RadixThemeColor.babydoll: + return 192; + case RadixThemeColor.vapor: + return 192; + case RadixThemeColor.gold: + return 192; + case RadixThemeColor.garden: + return brightness == Brightness.dark ? 192 : 128; + case RadixThemeColor.forest: + return 192; + case RadixThemeColor.arctic: + return brightness == Brightness.dark ? 208 : 180; + case RadixThemeColor.lapis: + return brightness == Brightness.dark ? 128 : 192; + case RadixThemeColor.eggplant: + return brightness == Brightness.dark ? 192 : 192; + case RadixThemeColor.lime: + return brightness == Brightness.dark ? 192 : 128; + case RadixThemeColor.grim: + return brightness == Brightness.dark ? 240 : 224; + } +} + ThemeData radixGenerator(Brightness brightness, RadixThemeColor themeColor) { final textTheme = makeRadixTextTheme(brightness); final radix = _radixScheme(brightness, themeColor); final scaleScheme = radix.toScale(); - final colorScheme = scaleScheme.toColorScheme(brightness); - final scaleConfig = ScaleConfig(useVisualIndicators: false); + final scaleConfig = ScaleConfig( + useVisualIndicators: false, + preferBorders: false, + borderRadiusScale: 1, + wallpaperOpacity: wallpaperAlpha(brightness, themeColor), + ); - final themeData = ThemeData.from( - colorScheme: colorScheme, textTheme: textTheme, useMaterial3: true); - return themeData.copyWith( - scrollbarTheme: themeData.scrollbarTheme.copyWith( - thumbColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.pressed)) { - return scaleScheme.primaryScale.border; - } else if (states.contains(WidgetState.hovered)) { - return scaleScheme.primaryScale.hoverBorder; - } - return scaleScheme.primaryScale.subtleBorder; - }), trackColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.pressed)) { - return scaleScheme.primaryScale.activeElementBackground; - } else if (states.contains(WidgetState.hovered)) { - return scaleScheme.primaryScale.hoverElementBackground; - } - return scaleScheme.primaryScale.elementBackground; - }), trackBorderColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.pressed)) { - return scaleScheme.primaryScale.subtleBorder; - } else if (states.contains(WidgetState.hovered)) { - return scaleScheme.primaryScale.subtleBorder; - } - return scaleScheme.primaryScale.subtleBorder; - })), - bottomSheetTheme: themeData.bottomSheetTheme.copyWith( - elevation: 0, - modalElevation: 0, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16)))), - canvasColor: scaleScheme.primaryScale.subtleBackground, - chipTheme: themeData.chipTheme.copyWith( - backgroundColor: scaleScheme.primaryScale.elementBackground, - selectedColor: scaleScheme.primaryScale.activeElementBackground, - surfaceTintColor: scaleScheme.primaryScale.hoverElementBackground, - checkmarkColor: scaleScheme.primaryScale.primary, - side: BorderSide(color: scaleScheme.primaryScale.border)), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - backgroundColor: scaleScheme.primaryScale.elementBackground, - foregroundColor: scaleScheme.primaryScale.primary, - disabledBackgroundColor: scaleScheme.grayScale.elementBackground, - disabledForegroundColor: scaleScheme.grayScale.primary, - shape: RoundedRectangleBorder( - side: BorderSide(color: scaleScheme.primaryScale.border), - borderRadius: BorderRadius.circular(8))), - ), - inputDecorationTheme: ScaleInputDecoratorTheme(scaleScheme, textTheme), - extensions: >[scaleScheme, scaleConfig]); + final scaleTheme = ScaleTheme( + textTheme: textTheme, scheme: scaleScheme, config: scaleConfig); + + final themeData = scaleTheme.toThemeData(brightness); + + return themeData; } diff --git a/lib/theme/models/scale_input_decorator_theme.dart b/lib/theme/models/scale_input_decorator_theme.dart deleted file mode 100644 index f6865cd..0000000 --- a/lib/theme/models/scale_input_decorator_theme.dart +++ /dev/null @@ -1,165 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'scale_scheme.dart'; - -class ScaleInputDecoratorTheme extends InputDecorationTheme { - ScaleInputDecoratorTheme(this._scaleScheme, this._textTheme) - : super( - border: OutlineInputBorder( - borderSide: BorderSide(color: _scaleScheme.primaryScale.border), - borderRadius: BorderRadius.circular(8)), - contentPadding: const EdgeInsets.all(8), - labelStyle: TextStyle( - color: _scaleScheme.primaryScale.subtleText.withAlpha(127)), - floatingLabelStyle: - TextStyle(color: _scaleScheme.primaryScale.subtleText), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: _scaleScheme.primaryScale.hoverBorder, width: 2), - borderRadius: BorderRadius.circular(8))); - - final ScaleScheme _scaleScheme; - final TextTheme _textTheme; - - @override - TextStyle? get hintStyle => MaterialStateTextStyle.resolveWith((states) { - if (states.contains(MaterialState.disabled)) { - return TextStyle(color: _scaleScheme.grayScale.border); - } - return TextStyle(color: _scaleScheme.primaryScale.border); - }); - - @override - Color? get fillColor => MaterialStateColor.resolveWith((states) { - if (states.contains(MaterialState.disabled)) { - return _scaleScheme.grayScale.primary.withOpacity(0.04); - } - return _scaleScheme.primaryScale.primary.withOpacity(0.04); - }); - - @override - BorderSide? get activeIndicatorBorder => - MaterialStateBorderSide.resolveWith((states) { - if (states.contains(MaterialState.disabled)) { - return BorderSide( - color: _scaleScheme.grayScale.border.withAlpha(0x7F)); - } - if (states.contains(MaterialState.error)) { - if (states.contains(MaterialState.hovered)) { - return BorderSide(color: _scaleScheme.errorScale.hoverBorder); - } - if (states.contains(MaterialState.focused)) { - return BorderSide(color: _scaleScheme.errorScale.border, width: 2); - } - return BorderSide(color: _scaleScheme.errorScale.subtleBorder); - } - if (states.contains(MaterialState.hovered)) { - return BorderSide(color: _scaleScheme.secondaryScale.hoverBorder); - } - if (states.contains(MaterialState.focused)) { - return BorderSide( - color: _scaleScheme.secondaryScale.border, width: 2); - } - return BorderSide(color: _scaleScheme.secondaryScale.subtleBorder); - }); - - @override - BorderSide? get outlineBorder => - MaterialStateBorderSide.resolveWith((states) { - if (states.contains(MaterialState.disabled)) { - return BorderSide( - color: _scaleScheme.grayScale.border.withAlpha(0x7F)); - } - if (states.contains(MaterialState.error)) { - if (states.contains(MaterialState.hovered)) { - return BorderSide(color: _scaleScheme.errorScale.hoverBorder); - } - if (states.contains(MaterialState.focused)) { - return BorderSide(color: _scaleScheme.errorScale.border, width: 2); - } - return BorderSide(color: _scaleScheme.errorScale.subtleBorder); - } - if (states.contains(MaterialState.hovered)) { - return BorderSide(color: _scaleScheme.primaryScale.hoverBorder); - } - if (states.contains(MaterialState.focused)) { - return BorderSide(color: _scaleScheme.primaryScale.border, width: 2); - } - return BorderSide(color: _scaleScheme.primaryScale.subtleBorder); - }); - - @override - Color? get iconColor => _scaleScheme.primaryScale.primary; - - @override - Color? get prefixIconColor => MaterialStateColor.resolveWith((states) { - if (states.contains(MaterialState.disabled)) { - return _scaleScheme.primaryScale.primary.withAlpha(0x3F); - } - if (states.contains(MaterialState.error)) { - return _scaleScheme.errorScale.primary; - } - return _scaleScheme.primaryScale.primary; - }); - - @override - Color? get suffixIconColor => MaterialStateColor.resolveWith((states) { - if (states.contains(MaterialState.disabled)) { - return _scaleScheme.primaryScale.primary.withAlpha(0x3F); - } - if (states.contains(MaterialState.error)) { - return _scaleScheme.errorScale.primary; - } - return _scaleScheme.primaryScale.primary; - }); - - @override - TextStyle? get labelStyle => MaterialStateTextStyle.resolveWith((states) { - final textStyle = _textTheme.bodyLarge ?? const TextStyle(); - if (states.contains(MaterialState.disabled)) { - return textStyle.copyWith( - color: _scaleScheme.grayScale.border.withAlpha(0x7F)); - } - if (states.contains(MaterialState.error)) { - if (states.contains(MaterialState.hovered)) { - return textStyle.copyWith( - color: _scaleScheme.errorScale.hoverBorder); - } - if (states.contains(MaterialState.focused)) { - return textStyle.copyWith( - color: _scaleScheme.errorScale.hoverBorder); - } - return textStyle.copyWith( - color: _scaleScheme.errorScale.subtleBorder); - } - if (states.contains(MaterialState.hovered)) { - return textStyle.copyWith( - color: _scaleScheme.primaryScale.hoverBorder); - } - if (states.contains(MaterialState.focused)) { - return textStyle.copyWith( - color: _scaleScheme.primaryScale.hoverBorder); - } - return textStyle.copyWith(color: _scaleScheme.primaryScale.border); - }); - - @override - TextStyle? get floatingLabelStyle => labelStyle; - - @override - TextStyle? get helperStyle => MaterialStateTextStyle.resolveWith((states) { - final textStyle = _textTheme.bodySmall ?? const TextStyle(); - if (states.contains(MaterialState.disabled)) { - return textStyle.copyWith( - color: _scaleScheme.grayScale.border.withAlpha(0x7F)); - } - return textStyle.copyWith( - color: _scaleScheme.secondaryScale.border.withAlpha(0x7F)); - }); - - @override - TextStyle? get errorStyle => MaterialStateTextStyle.resolveWith((states) { - final textStyle = _textTheme.bodySmall ?? const TextStyle(); - return textStyle.copyWith(color: _scaleScheme.errorScale.primary); - }); -} diff --git a/lib/theme/models/scale_theme/scale_app_bar_theme.dart b/lib/theme/models/scale_theme/scale_app_bar_theme.dart new file mode 100644 index 0000000..ea8e83e --- /dev/null +++ b/lib/theme/models/scale_theme/scale_app_bar_theme.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import 'scale_theme.dart'; + +class ScaleAppBarTheme { + ScaleAppBarTheme({ + required this.textStyle, + required this.iconColor, + required this.backgroundColor, + }); + + final TextStyle textStyle; + final Color iconColor; + final Color backgroundColor; +} + +extension ScaleAppBarThemeExt on ScaleTheme { + ScaleAppBarTheme appBarTheme({ScaleKind scaleKind = ScaleKind.primary}) { + final scale = scheme.scale(scaleKind); + + final textStyle = textTheme.titleLarge!.copyWith(color: scale.borderText); + final iconColor = scale.borderText; + final backgroundColor = scale.border; + + return ScaleAppBarTheme( + textStyle: textStyle, + iconColor: iconColor, + backgroundColor: backgroundColor, + ); + } +} diff --git a/lib/theme/models/scale_theme/scale_chat_theme.dart b/lib/theme/models/scale_theme/scale_chat_theme.dart new file mode 100644 index 0000000..d1145cf --- /dev/null +++ b/lib/theme/models/scale_theme/scale_chat_theme.dart @@ -0,0 +1,372 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; + +import 'scale_theme.dart'; + +class ScaleChatTheme { + ScaleChatTheme({ + // Default chat theme + required this.chatTheme, + + // Customization fields (from v1 of flutter chat ui) + required this.attachmentButtonIcon, + // required this.attachmentButtonMargin, + required this.backgroundColor, + required this.bubbleBorderSide, + // required this.dateDividerMargin, + // required this.chatContentMargin, + required this.dateDividerTextStyle, + // required this.deliveredIcon, + // required this.documentIcon, + // required this.emptyChatPlaceholderTextStyle, + // required this.errorColor, + // required this.errorIcon, + required this.inputBackgroundColor, + // required this.inputSurfaceTintColor, + // required this.inputElevation, + required this.inputBorderRadius, + // required this.inputMargin, + required this.inputPadding, + required this.inputTextColor, + required this.inputTextStyle, + required this.messageBorderRadius, + required this.messageInsetsHorizontal, + required this.messageInsetsVertical, + // required this.messageMaxWidth, + required this.primaryColor, + required this.receivedEmojiMessageTextStyle, + required this.receivedMessageBodyTextStyle, + // required this.receivedMessageCaptionTextStyle, + // required this.receivedMessageDocumentIconColor, + // required this.receivedMessageLinkDescriptionTextStyle, + // required this.receivedMessageLinkTitleTextStyle, + required this.secondaryColor, + // required this.seenIcon, + required this.sendButtonIcon, + // required this.sendButtonMargin, + // required this.sendingIcon, + required this.onlyEmojiFontSize, + required this.timeStyle, + required this.sentMessageBodyTextStyle, + // required this.sentMessageCaptionTextStyle, + // required this.sentMessageDocumentIconColor, + // required this.sentMessageLinkDescriptionTextStyle, + // required this.sentMessageLinkTitleTextStyle, + // required this.statusIconPadding, + // required this.userAvatarImageBackgroundColor, + // required this.userAvatarNameColors, + // required this.userAvatarTextStyle, + // required this.userNameTextStyle, + // required this.bubbleMargin, + required this.inputContainerDecoration, + // required this.inputTextCursorColor, + // required this.receivedMessageBodyBoldTextStyle, + // required this.receivedMessageBodyCodeTextStyle, + // required this.receivedMessageBodyLinkTextStyle, + // required this.sentMessageBodyBoldTextStyle, + // required this.sentMessageBodyCodeTextStyle, + // required this.sentMessageBodyLinkTextStyle, + // required this.highlightMessageColor, + }); + + final core.ChatTheme chatTheme; + + /// Icon for select attachment button. + final Widget? attachmentButtonIcon; + + /// Margin of attachment button. + // final EdgeInsets? attachmentButtonMargin; + + /// Used as a background color of a chat widget. + final Color backgroundColor; + + // Margin around the message bubble. + // final EdgeInsetsGeometry? bubbleMargin; + + /// Border for chat bubbles + final BorderSide bubbleBorderSide; + + /// Margin around date dividers. + // final EdgeInsets dateDividerMargin; + + /// Margin inside chat area. + // final EdgeInsets chatContentMargin; + + /// Text style of the date dividers. + final TextStyle dateDividerTextStyle; + + /// Icon for message's `delivered` status. For the best look use size of 16. + // final Widget? deliveredIcon; + + /// Icon inside file message. + // final Widget? documentIcon; + + /// Text style of the empty chat placeholder. + // final TextStyle emptyChatPlaceholderTextStyle; + + /// Color to indicate something bad happened (usually - shades of red). + // final Color errorColor; + + /// Icon for message's `error` status. For the best look use size of 16. + // final Widget? errorIcon; + + /// Color of the bottom bar where text field is. + final Color inputBackgroundColor; + + /// Surface Tint Color of the bottom bar where text field is. + // final Color inputSurfaceTintColor; + + /// Elevation to use for input material + // final double inputElevation; + + /// Top border radius of the bottom bar where text field is. + final BorderRadius inputBorderRadius; + + /// Decoration of the container wrapping the text field. + final Decoration? inputContainerDecoration; + + /// Outer insets of the bottom bar where text field is. + // final EdgeInsets inputMargin; + + /// Inner insets of the bottom bar where text field is. + final EdgeInsets inputPadding; + + /// Color of the text field's text and attachment/send buttons. + final Color inputTextColor; + + /// Color of the text field's cursor. + // final Color? inputTextCursorColor; + + /// Text style of the message input. To change the color use [inputTextColor]. + final TextStyle inputTextStyle; + + /// Border radius of message container. + final double messageBorderRadius; + + /// Horizontal message bubble insets. + final double messageInsetsHorizontal; + + /// Vertical message bubble insets. + final double messageInsetsVertical; + + /// Message bubble max width. set to [double.infinity] adaptive screen. + // final double messageMaxWidth; + + /// Primary color of the chat used as a background of sent messages + /// and statuses. + final Color primaryColor; + + /// Text style used for displaying emojis on text messages. + final TextStyle receivedEmojiMessageTextStyle; + + /// Body text style used for displaying bold text on received text messages. + // Default to a bold version of [receivedMessageBodyTextStyle]. + // final TextStyle? receivedMessageBodyBoldTextStyle; + + /// Body text style used for displaying code text on received text messages. + // Defaults to a mono version of [receivedMessageBodyTextStyle]. + // final TextStyle? receivedMessageBodyCodeTextStyle; + + /// Text style used for displaying link text on received text messages. + // Defaults to [receivedMessageBodyTextStyle]. + // final TextStyle? receivedMessageBodyLinkTextStyle; + + /// Body text style used for displaying text on different types + /// of received messages. + final TextStyle receivedMessageBodyTextStyle; + + /// Caption text style used for displaying secondary info (e.g. file size) on + /// different types of received messages. + // final TextStyle receivedMessageCaptionTextStyle; + + /// Color of the document icon on received messages. Has no effect when + // [documentIcon] is used. + // final Color receivedMessageDocumentIconColor; + + /// Text style used for displaying link description on received messages. + // final TextStyle receivedMessageLinkDescriptionTextStyle; + + /// Text style used for displaying link title on received messages. + // final TextStyle receivedMessageLinkTitleTextStyle; + + /// Secondary color, used as a background of received messages. + final Color secondaryColor; + + /// Icon for message's `seen` status. For the best look use size of 16. + // final Widget? seenIcon; + + /// Icon for send button. + final Widget? sendButtonIcon; + + /// Margin of send button. + // final EdgeInsets? sendButtonMargin; + + /// Icon for message's `sending` status. For the best look use size of 10. + // final Widget? sendingIcon; + + /// Text size for displaying emojis on text messages. + final double onlyEmojiFontSize; + + /// Text style used for time and status + final TextStyle timeStyle; + + /// Body text style used for displaying bold text on sent text messages. + /// Defaults to a bold version of [sentMessageBodyTextStyle]. + // final TextStyle? sentMessageBodyBoldTextStyle; + + /// Body text style used for displaying code text on sent text messages. + /// Defaults to a mono version of [sentMessageBodyTextStyle]. + // final TextStyle? sentMessageBodyCodeTextStyle; + + /// Text style used for displaying link text on sent text messages. + /// Defaults to [sentMessageBodyTextStyle]. + // final TextStyle? sentMessageBodyLinkTextStyle; + + /// Body text style used for displaying text on different types + /// of sent messages. + final TextStyle sentMessageBodyTextStyle; + + /// Caption text style used for displaying secondary info (e.g. file size) on + /// different types of sent messages. + // final TextStyle sentMessageCaptionTextStyle; + + /// Color of the document icon on sent messages. Has no effect when + // [documentIcon] is used. + // final Color sentMessageDocumentIconColor; + + /// Text style used for displaying link description on sent messages. + // final TextStyle sentMessageLinkDescriptionTextStyle; + + /// Text style used for displaying link title on sent messages. + // final TextStyle sentMessageLinkTitleTextStyle; + + /// Padding around status icons. + // final EdgeInsets statusIconPadding; + + /// Color used as a background for user avatar if an image is provided. + /// Visible if the image has some transparent parts. + // final Color userAvatarImageBackgroundColor; + + /// Colors used as backgrounds for user avatars with no image and so, + /// corresponding user names. + /// Calculated based on a user ID, so unique across the whole app. + // final List userAvatarNameColors; + + /// Text style used for displaying initials on user avatar if no + /// image is provided. + // final TextStyle userAvatarTextStyle; + + /// User names text style. Color will be overwritten with + // [userAvatarNameColors]. + // final TextStyle userNameTextStyle; + + /// Color used as background of message row on highlight. + // final Color? highlightMessageColor; +} + +extension ScaleChatThemeExt on ScaleTheme { + ScaleChatTheme chatTheme() { + // 'brightness' is not actually used by ChatColors.fromThemeData, + // or ChatTypography.fromThemeData so just say 'light' here + final themeData = toThemeData(Brightness.light); + final typography = core.ChatTypography.fromThemeData(themeData); + + final surfaceContainer = config.preferBorders + ? scheme.secondaryScale.calloutText + : scheme.secondaryScale.calloutBackground; + + final colors = core.ChatColors( + // Primary color, often used for sent messages and accents. + primary: config.preferBorders + ? scheme.primaryScale.calloutText + : scheme.primaryScale.calloutBackground, + // Color for text and icons displayed on top of [primary]. + onPrimary: scheme.primaryScale.primaryText, + // The main background color of the chat screen. + surface: + scheme.grayScale.appBackground.withAlpha(config.wallpaperAlpha), + + // Color for text and icons displayed on top of [surface]. + onSurface: scheme.primaryScale.appText, + + // Background color for elements like received messages. + surfaceContainer: surfaceContainer, + + // A slightly lighter/darker variant of [surfaceContainer]. + surfaceContainerLow: surfaceContainer.darken(25), + + // A slightly lighter/darker variant of [surfaceContainer]. + surfaceContainerHigh: surfaceContainer.lighten(25)); + + final chatTheme = core.ChatTheme( + colors: colors, + typography: typography, + shape: + BorderRadius.all(Radius.circular(config.borderRadiusScale * 12))); + + return ScaleChatTheme( + chatTheme: chatTheme, + primaryColor: config.preferBorders + ? scheme.primaryScale.calloutText + : scheme.primaryScale.calloutBackground, + secondaryColor: config.preferBorders + ? scheme.secondaryScale.calloutText + : scheme.secondaryScale.calloutBackground, + backgroundColor: + scheme.grayScale.appBackground.withAlpha(config.wallpaperAlpha), + messageBorderRadius: config.borderRadiusScale * 12, + bubbleBorderSide: config.preferBorders + ? BorderSide( + color: scheme.primaryScale.calloutBackground, + width: 2, + ) + : BorderSide(width: 2, color: Colors.black.withAlpha(96)), + sendButtonIcon: Image.asset( + 'assets/icon-send.png', + color: config.preferBorders + ? scheme.primaryScale.border + : scheme.primaryScale.borderText, + package: 'flutter_chat_ui', + ), + inputBackgroundColor: Colors.blue, + inputBorderRadius: BorderRadius.zero, + inputTextStyle: textTheme.bodyLarge!, + inputContainerDecoration: BoxDecoration( + border: config.preferBorders + ? Border( + top: + BorderSide(color: scheme.primaryScale.border, width: 2)) + : null, + color: config.preferBorders + ? scheme.primaryScale.elementBackground + : scheme.primaryScale.border), + inputPadding: const EdgeInsets.all(6), + inputTextColor: !config.preferBorders + ? scheme.primaryScale.appText + : scheme.primaryScale.border, + messageInsetsHorizontal: 12, + messageInsetsVertical: 8, + attachmentButtonIcon: const Icon(Icons.attach_file), + sentMessageBodyTextStyle: textTheme.bodyLarge!.copyWith( + color: config.preferBorders + ? scheme.primaryScale.calloutBackground + : scheme.primaryScale.calloutText, + ), + onlyEmojiFontSize: 64, + timeStyle: textTheme.bodySmall!.copyWith(fontSize: 9).copyWith( + color: config.preferBorders || config.useVisualIndicators + ? scheme.primaryScale.calloutBackground + : scheme.primaryScale.borderText), + receivedMessageBodyTextStyle: textTheme.bodyLarge!.copyWith( + color: config.preferBorders + ? scheme.secondaryScale.calloutBackground + : scheme.secondaryScale.calloutText, + ), + receivedEmojiMessageTextStyle: const TextStyle( + color: Colors.white, + fontSize: 64, + ), + dateDividerTextStyle: textTheme.labelSmall!); + } +} diff --git a/lib/theme/models/scale_color.dart b/lib/theme/models/scale_theme/scale_color.dart similarity index 88% rename from lib/theme/models/scale_color.dart rename to lib/theme/models/scale_theme/scale_color.dart index 244f6a3..f3c884f 100644 --- a/lib/theme/models/scale_color.dart +++ b/lib/theme/models/scale_theme/scale_color.dart @@ -17,6 +17,7 @@ class ScaleColor { required this.primaryText, required this.borderText, required this.dialogBorder, + required this.dialogBorderText, required this.calloutBackground, required this.calloutText, }); @@ -36,6 +37,7 @@ class ScaleColor { Color primaryText; Color borderText; Color dialogBorder; + Color dialogBorderText; Color calloutBackground; Color calloutText; @@ -48,13 +50,14 @@ class ScaleColor { Color? subtleBorder, Color? border, Color? hoverBorder, - Color? background, - Color? hoverBackground, + Color? primary, + Color? hoverPrimary, Color? subtleText, Color? appText, - Color? foregroundText, + Color? primaryText, Color? borderText, Color? dialogBorder, + Color? dialogBorderText, Color? calloutBackground, Color? calloutText, }) => @@ -69,16 +72,18 @@ class ScaleColor { subtleBorder: subtleBorder ?? this.subtleBorder, border: border ?? this.border, hoverBorder: hoverBorder ?? this.hoverBorder, - primary: background ?? this.primary, - hoverPrimary: hoverBackground ?? this.hoverPrimary, + primary: primary ?? this.primary, + hoverPrimary: hoverPrimary ?? this.hoverPrimary, subtleText: subtleText ?? this.subtleText, appText: appText ?? this.appText, - primaryText: foregroundText ?? this.primaryText, + primaryText: primaryText ?? this.primaryText, borderText: borderText ?? this.borderText, dialogBorder: dialogBorder ?? this.dialogBorder, + dialogBorderText: dialogBorderText ?? this.dialogBorderText, calloutBackground: calloutBackground ?? this.calloutBackground, calloutText: calloutText ?? this.calloutText); + // Use static method // ignore: prefer_constructors_over_static_methods static ScaleColor lerp(ScaleColor a, ScaleColor b, double t) => ScaleColor( appBackground: Color.lerp(a.appBackground, b.appBackground, t) ?? @@ -112,6 +117,9 @@ class ScaleColor { const Color(0x00000000), dialogBorder: Color.lerp(a.dialogBorder, b.dialogBorder, t) ?? const Color(0x00000000), + dialogBorderText: + Color.lerp(a.dialogBorderText, b.dialogBorderText, t) ?? + const Color(0x00000000), calloutBackground: Color.lerp(a.calloutBackground, b.calloutBackground, t) ?? const Color(0x00000000), diff --git a/lib/theme/models/scale_theme/scale_custom_dropdown_theme.dart b/lib/theme/models/scale_theme/scale_custom_dropdown_theme.dart new file mode 100644 index 0000000..2c5eb1c --- /dev/null +++ b/lib/theme/models/scale_theme/scale_custom_dropdown_theme.dart @@ -0,0 +1,92 @@ +import 'package:animated_custom_dropdown/custom_dropdown.dart'; +import 'package:flutter/material.dart'; + +import 'scale_theme.dart'; + +class ScaleCustomDropdownTheme { + ScaleCustomDropdownTheme({ + required this.decoration, + required this.closedHeaderPadding, + required this.expandedHeaderPadding, + required this.itemsListPadding, + required this.listItemPadding, + required this.disabledDecoration, + required this.textStyle, + }); + + final CustomDropdownDecoration decoration; + final EdgeInsets closedHeaderPadding; + final EdgeInsets expandedHeaderPadding; + final EdgeInsets itemsListPadding; + final EdgeInsets listItemPadding; + final CustomDropdownDisabledDecoration disabledDecoration; + final TextStyle textStyle; +} + +extension ScaleCustomDropdownThemeExt on ScaleTheme { + ScaleCustomDropdownTheme customDropdownTheme() { + final scale = scheme.primaryScale; + final borderColor = scale.borderText; + final fillColor = scale.subtleBorder; + + // final backgroundColor = config.useVisualIndicators && !selected + // ? tileColor.borderText + // : borderColor; + // final textColor = config.useVisualIndicators && !selected + // ? borderColor + // : tileColor.borderText; + + // final largeTextStyle = textTheme.labelMedium!.copyWith(color: textColor); + // final smallTextStyle = textTheme.labelSmall!.copyWith(color: textColor); + + final border = Border.fromBorderSide(config.useVisualIndicators + ? BorderSide(width: 2, color: borderColor, strokeAlign: 0) + : BorderSide.none); + final borderRadius = BorderRadius.circular(8 * config.borderRadiusScale); + + final decoration = CustomDropdownDecoration( + closedFillColor: fillColor, + expandedFillColor: fillColor, + closedShadow: [], + expandedShadow: [], + closedSuffixIcon: Icon(Icons.arrow_drop_down, color: borderColor), + expandedSuffixIcon: Icon(Icons.arrow_drop_up, color: borderColor), + prefixIcon: null, + closedBorder: border, + closedBorderRadius: borderRadius, + closedErrorBorder: null, + closedErrorBorderRadius: null, + expandedBorder: border, + expandedBorderRadius: borderRadius, + hintStyle: null, + headerStyle: null, + noResultFoundStyle: null, + errorStyle: null, + listItemStyle: null, + overlayScrollbarDecoration: null, + searchFieldDecoration: null, + listItemDecoration: null, + ); + + const disabledDecoration = CustomDropdownDisabledDecoration( + fillColor: null, + shadow: null, + suffixIcon: null, + prefixIcon: null, + border: null, + borderRadius: null, + headerStyle: null, + hintStyle: null, + ); + + return ScaleCustomDropdownTheme( + textStyle: textTheme.labelSmall!.copyWith(color: borderColor), + decoration: decoration, + closedHeaderPadding: const EdgeInsets.all(4), + expandedHeaderPadding: const EdgeInsets.all(4), + itemsListPadding: const EdgeInsets.all(4), + listItemPadding: const EdgeInsets.all(4), + disabledDecoration: disabledDecoration, + ); + } +} diff --git a/lib/theme/models/scale_theme/scale_input_decorator_theme.dart b/lib/theme/models/scale_theme/scale_input_decorator_theme.dart new file mode 100644 index 0000000..1fb26a4 --- /dev/null +++ b/lib/theme/models/scale_theme/scale_input_decorator_theme.dart @@ -0,0 +1,234 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'scale_theme.dart'; + +class ScaleInputDecoratorTheme extends InputDecorationTheme { + ScaleInputDecoratorTheme( + this._scaleScheme, ScaleConfig scaleConfig, this._textTheme) + : hintAlpha = scaleConfig.preferBorders ? 127 : 255, + super( + contentPadding: const EdgeInsets.all(8), + labelStyle: TextStyle(color: _scaleScheme.primaryScale.subtleText), + floatingLabelStyle: + TextStyle(color: _scaleScheme.primaryScale.subtleText), + border: OutlineInputBorder( + borderSide: BorderSide(color: _scaleScheme.primaryScale.border), + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale)), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: _scaleScheme.primaryScale.border), + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale)), + disabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: _scaleScheme.grayScale.border.withAlpha(0x7F)), + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale)), + errorBorder: OutlineInputBorder( + borderSide: BorderSide(color: _scaleScheme.errorScale.border), + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale)), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: _scaleScheme.primaryScale.hoverBorder, width: 2), + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale)), + hoverColor: + _scaleScheme.primaryScale.hoverElementBackground.withAlpha(0x7F), + filled: true, + focusedErrorBorder: OutlineInputBorder( + borderSide: + BorderSide(color: _scaleScheme.errorScale.border, width: 2), + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale)), + ); + + final ScaleScheme _scaleScheme; + final TextTheme _textTheme; + final int hintAlpha; + final int disabledAlpha = 127; + + @override + TextStyle? get hintStyle => WidgetStateTextStyle.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return TextStyle(color: _scaleScheme.grayScale.border); + } + return TextStyle( + color: _scaleScheme.primaryScale.border.withAlpha(hintAlpha)); + }); + + @override + Color? get fillColor => WidgetStateColor.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return _scaleScheme.grayScale.primary.withAlpha(10); + } + return _scaleScheme.primaryScale.primary.withAlpha(10); + }); + + @override + BorderSide? get activeIndicatorBorder => + WidgetStateBorderSide.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return BorderSide( + color: _scaleScheme.grayScale.border.withAlpha(disabledAlpha)); + } + if (states.contains(WidgetState.error)) { + if (states.contains(WidgetState.hovered)) { + return BorderSide(color: _scaleScheme.errorScale.hoverBorder); + } + if (states.contains(WidgetState.focused)) { + return BorderSide(color: _scaleScheme.errorScale.border, width: 2); + } + return BorderSide(color: _scaleScheme.errorScale.subtleBorder); + } + if (states.contains(WidgetState.hovered)) { + return BorderSide(color: _scaleScheme.secondaryScale.hoverBorder); + } + if (states.contains(WidgetState.focused)) { + return BorderSide( + color: _scaleScheme.secondaryScale.border, width: 2); + } + return BorderSide(color: _scaleScheme.secondaryScale.subtleBorder); + }); + + @override + BorderSide? get outlineBorder => WidgetStateBorderSide.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return BorderSide( + color: _scaleScheme.grayScale.border.withAlpha(disabledAlpha)); + } + if (states.contains(WidgetState.error)) { + if (states.contains(WidgetState.hovered)) { + return BorderSide(color: _scaleScheme.errorScale.hoverBorder); + } + if (states.contains(WidgetState.focused)) { + return BorderSide(color: _scaleScheme.errorScale.border, width: 2); + } + return BorderSide(color: _scaleScheme.errorScale.subtleBorder); + } + if (states.contains(WidgetState.hovered)) { + return BorderSide(color: _scaleScheme.primaryScale.hoverBorder); + } + if (states.contains(WidgetState.focused)) { + return BorderSide(color: _scaleScheme.primaryScale.border, width: 2); + } + return BorderSide(color: _scaleScheme.primaryScale.subtleBorder); + }); + + @override + Color? get iconColor => _scaleScheme.primaryScale.primary; + + @override + Color? get prefixIconColor => WidgetStateColor.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return _scaleScheme.primaryScale.primary.withAlpha(disabledAlpha); + } + if (states.contains(WidgetState.error)) { + return _scaleScheme.errorScale.primary; + } + return _scaleScheme.primaryScale.primary; + }); + + @override + Color? get suffixIconColor => WidgetStateColor.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return _scaleScheme.primaryScale.primary.withAlpha(disabledAlpha); + } + if (states.contains(WidgetState.error)) { + return _scaleScheme.errorScale.primary; + } + return _scaleScheme.primaryScale.primary; + }); + + @override + TextStyle? get labelStyle => WidgetStateTextStyle.resolveWith((states) { + final textStyle = _textTheme.bodyLarge ?? const TextStyle(); + if (states.contains(WidgetState.disabled)) { + return textStyle.copyWith( + color: _scaleScheme.grayScale.border.withAlpha(disabledAlpha)); + } + if (states.contains(WidgetState.error)) { + if (states.contains(WidgetState.hovered)) { + return textStyle.copyWith( + color: _scaleScheme.errorScale.hoverBorder); + } + if (states.contains(WidgetState.focused)) { + return textStyle.copyWith( + color: _scaleScheme.errorScale.hoverBorder); + } + return textStyle.copyWith( + color: _scaleScheme.errorScale.subtleBorder); + } + if (states.contains(WidgetState.hovered)) { + return textStyle.copyWith( + color: _scaleScheme.primaryScale.hoverBorder); + } + if (states.contains(WidgetState.focused)) { + return textStyle.copyWith( + color: _scaleScheme.primaryScale.hoverBorder); + } + return textStyle.copyWith( + color: _scaleScheme.primaryScale.border.withAlpha(hintAlpha)); + }); + + @override + TextStyle? get floatingLabelStyle => + WidgetStateTextStyle.resolveWith((states) { + final textStyle = _textTheme.bodyLarge ?? const TextStyle(); + if (states.contains(WidgetState.disabled)) { + return textStyle.copyWith( + color: _scaleScheme.grayScale.border.withAlpha(disabledAlpha)); + } + if (states.contains(WidgetState.error)) { + if (states.contains(WidgetState.hovered)) { + return textStyle.copyWith( + color: _scaleScheme.errorScale.hoverBorder); + } + if (states.contains(WidgetState.focused)) { + return textStyle.copyWith( + color: _scaleScheme.errorScale.hoverBorder); + } + return textStyle.copyWith( + color: _scaleScheme.errorScale.subtleBorder); + } + if (states.contains(WidgetState.hovered)) { + return textStyle.copyWith( + color: _scaleScheme.primaryScale.hoverBorder); + } + if (states.contains(WidgetState.focused)) { + return textStyle.copyWith( + color: _scaleScheme.primaryScale.hoverBorder); + } + return textStyle.copyWith(color: _scaleScheme.primaryScale.border); + }); + + @override + TextStyle? get helperStyle => WidgetStateTextStyle.resolveWith((states) { + final textStyle = _textTheme.bodySmall ?? const TextStyle(); + if (states.contains(WidgetState.disabled)) { + return textStyle.copyWith(color: _scaleScheme.grayScale.border); + } + return textStyle.copyWith( + color: _scaleScheme.primaryScale.border.withAlpha(hintAlpha)); + }); + + @override + TextStyle? get errorStyle => WidgetStateTextStyle.resolveWith((states) { + final textStyle = _textTheme.bodySmall ?? const TextStyle(); + return textStyle.copyWith(color: _scaleScheme.errorScale.primary); + }); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(IntProperty('disabledAlpha', disabledAlpha)) + ..add(IntProperty('hintAlpha', hintAlpha)); + } +} + +extension ScaleInputDecoratorThemeExt on ScaleTheme { + ScaleInputDecoratorTheme inputDecoratorTheme() => + ScaleInputDecoratorTheme(scheme, config, textTheme); +} diff --git a/lib/theme/models/scale_scheme.dart b/lib/theme/models/scale_theme/scale_scheme.dart similarity index 74% rename from lib/theme/models/scale_scheme.dart rename to lib/theme/models/scale_theme/scale_scheme.dart index 990fe1e..8363476 100644 --- a/lib/theme/models/scale_scheme.dart +++ b/lib/theme/models/scale_theme/scale_scheme.dart @@ -1,3 +1,6 @@ +import 'dart:ui'; + +import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:flutter/material.dart'; import 'scale_color.dart'; @@ -73,8 +76,8 @@ class ScaleScheme extends ThemeExtension { ColorScheme toColorScheme(Brightness brightness) => ColorScheme( brightness: brightness, - primary: primaryScale.primary, // reviewed - onPrimary: primaryScale.primaryText, // reviewed + primary: primaryScale.primary, + onPrimary: primaryScale.primaryText, // primaryContainer: primaryScale.hoverElementBackground, // onPrimaryContainer: primaryScale.subtleText, secondary: secondaryScale.primary, @@ -89,15 +92,12 @@ class ScaleScheme extends ThemeExtension { onError: errorScale.primaryText, // errorContainer: errorScale.hoverElementBackground, // onErrorContainer: errorScale.subtleText, - background: grayScale.appBackground, // reviewed - onBackground: grayScale.appText, // reviewed - surface: primaryScale.primary, // reviewed - onSurface: primaryScale.primaryText, // reviewed - surfaceVariant: secondaryScale.primary, - onSurfaceVariant: secondaryScale.primaryText, // ?? reviewed a little + surface: primaryScale.appBackground, + onSurface: primaryScale.appText, + onSurfaceVariant: secondaryScale.appText, outline: primaryScale.border, outlineVariant: secondaryScale.border, - shadow: const Color(0xFF000000), + shadow: primaryScale.primary.darken(60), //scrim: primaryScale.background, // inverseSurface: primaryScale.subtleText, // onInverseSurface: primaryScale.subtleBackground, @@ -109,16 +109,29 @@ class ScaleScheme extends ThemeExtension { class ScaleConfig extends ThemeExtension { ScaleConfig({ required this.useVisualIndicators, + required this.preferBorders, + required this.borderRadiusScale, + required this.wallpaperOpacity, }); final bool useVisualIndicators; + final bool preferBorders; + final double borderRadiusScale; + final double wallpaperOpacity; + + int get wallpaperAlpha => wallpaperOpacity.toInt(); @override - ScaleConfig copyWith({ - bool? useVisualIndicators, - }) => + ScaleConfig copyWith( + {bool? useVisualIndicators, + bool? preferBorders, + double? borderRadiusScale, + double? wallpaperOpacity}) => ScaleConfig( useVisualIndicators: useVisualIndicators ?? this.useVisualIndicators, + preferBorders: preferBorders ?? this.preferBorders, + borderRadiusScale: borderRadiusScale ?? this.borderRadiusScale, + wallpaperOpacity: wallpaperOpacity ?? this.wallpaperOpacity, ); @override @@ -127,8 +140,12 @@ class ScaleConfig extends ThemeExtension { return this; } return ScaleConfig( - useVisualIndicators: - t < .5 ? useVisualIndicators : other.useVisualIndicators, - ); + useVisualIndicators: + t < .5 ? useVisualIndicators : other.useVisualIndicators, + preferBorders: t < .5 ? preferBorders : other.preferBorders, + borderRadiusScale: + lerpDouble(borderRadiusScale, other.borderRadiusScale, t) ?? 1, + wallpaperOpacity: + lerpDouble(wallpaperOpacity, other.wallpaperOpacity, t) ?? 1); } } diff --git a/lib/theme/models/scale_theme/scale_theme.dart b/lib/theme/models/scale_theme/scale_theme.dart new file mode 100644 index 0000000..c1d41b2 --- /dev/null +++ b/lib/theme/models/scale_theme/scale_theme.dart @@ -0,0 +1,205 @@ +import 'package:flutter/material.dart'; + +import 'scale_input_decorator_theme.dart'; +import 'scale_scheme.dart'; + +export 'scale_app_bar_theme.dart'; +export 'scale_chat_theme.dart'; +export 'scale_color.dart'; +export 'scale_input_decorator_theme.dart'; +export 'scale_scheme.dart'; +export 'scale_tile_theme.dart'; +export 'scale_toast_theme.dart'; + +class ScaleTheme extends ThemeExtension { + ScaleTheme({ + required this.textTheme, + required this.scheme, + required this.config, + }); + + final TextTheme textTheme; + final ScaleScheme scheme; + final ScaleConfig config; + + @override + ScaleTheme copyWith({ + TextTheme? textTheme, + ScaleScheme? scheme, + ScaleConfig? config, + }) => + ScaleTheme( + textTheme: textTheme ?? this.textTheme, + scheme: scheme ?? this.scheme, + config: config ?? this.config, + ); + + @override + ScaleTheme lerp(ScaleTheme? other, double t) { + if (other is! ScaleTheme) { + return this; + } + return ScaleTheme( + textTheme: TextTheme.lerp(textTheme, other.textTheme, t), + scheme: scheme.lerp(other.scheme, t), + config: config.lerp(other.config, t)); + } + + WidgetStateProperty elementBorderWidgetStateProperty() => + WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return BorderSide( + color: scheme.grayScale.border.withAlpha(0x7F), + strokeAlign: BorderSide.strokeAlignOutside); + } else if (states.contains(WidgetState.pressed)) { + return BorderSide( + color: scheme.primaryScale.border, + ); + } else if (states.contains(WidgetState.hovered)) { + return BorderSide( + color: scheme.primaryScale.hoverBorder, + strokeAlign: BorderSide.strokeAlignOutside); + } else if (states.contains(WidgetState.focused)) { + return BorderSide( + color: scheme.primaryScale.hoverBorder, + width: 2, + strokeAlign: BorderSide.strokeAlignOutside); + } + return BorderSide( + color: scheme.primaryScale.border, + strokeAlign: BorderSide.strokeAlignOutside); + }); + + WidgetStateProperty elementColorWidgetStateProperty() => + WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return scheme.grayScale.primary.withAlpha(0x7F); + } else if (states.contains(WidgetState.pressed)) { + return scheme.primaryScale.borderText; + } else if (states.contains(WidgetState.hovered)) { + return scheme.primaryScale.borderText; + } else if (states.contains(WidgetState.focused)) { + return scheme.primaryScale.borderText; + } + return Color.lerp( + scheme.primaryScale.borderText, scheme.primaryScale.primary, 0.25); + }); + + WidgetStateProperty checkboxFillColorWidgetStateProperty() => + WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.disabled)) { + return scheme.grayScale.primary.withAlpha(0x7F); + } else if (states.contains(WidgetState.pressed)) { + return scheme.primaryScale.hoverBorder; + } else if (states.contains(WidgetState.hovered)) { + return scheme.primaryScale.hoverBorder; + } else if (states.contains(WidgetState.focused)) { + return scheme.primaryScale.border; + } + return scheme.primaryScale.border; + } else { + return Colors.transparent; + } + }); + + // WidgetStateProperty elementBackgroundWidgetStateProperty() { + // return null; + // } + + ThemeData toThemeData(Brightness brightness) { + final colorScheme = scheme.toColorScheme(brightness); + + final baseThemeData = ThemeData.from( + colorScheme: colorScheme, textTheme: textTheme, useMaterial3: true); + + final elevatedButtonTheme = ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + elevation: 0, + textStyle: textTheme.labelSmall, + backgroundColor: scheme.primaryScale.elementBackground, + disabledBackgroundColor: + scheme.grayScale.elementBackground.withAlpha(0x7F), + disabledForegroundColor: + scheme.grayScale.primary.withAlpha(0x7F), + shape: RoundedRectangleBorder( + side: BorderSide(color: scheme.primaryScale.border), + borderRadius: + BorderRadius.circular(8 * config.borderRadiusScale))) + .copyWith( + foregroundColor: elementColorWidgetStateProperty(), + side: elementBorderWidgetStateProperty(), + iconColor: elementColorWidgetStateProperty(), + )); + + final sliderTheme = SliderThemeData.fromPrimaryColors( + primaryColor: scheme.primaryScale.hoverBorder, + primaryColorDark: scheme.primaryScale.border, + primaryColorLight: scheme.primaryScale.border, + valueIndicatorTextStyle: textTheme.labelMedium! + .copyWith(color: scheme.primaryScale.borderText)); + + final themeData = baseThemeData.copyWith( + scrollbarTheme: baseThemeData.scrollbarTheme.copyWith( + thumbColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return scheme.primaryScale.border; + } else if (states.contains(WidgetState.hovered)) { + return scheme.primaryScale.hoverBorder; + } + return scheme.primaryScale.subtleBorder; + }), trackColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return scheme.primaryScale.activeElementBackground; + } else if (states.contains(WidgetState.hovered)) { + return scheme.primaryScale.hoverElementBackground; + } + return scheme.primaryScale.elementBackground; + }), trackBorderColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return scheme.primaryScale.subtleBorder; + } else if (states.contains(WidgetState.hovered)) { + return scheme.primaryScale.subtleBorder; + } + return scheme.primaryScale.subtleBorder; + })), + appBarTheme: baseThemeData.appBarTheme.copyWith( + backgroundColor: scheme.primaryScale.border, + foregroundColor: scheme.primaryScale.borderText, + toolbarHeight: 48, + ), + bottomSheetTheme: baseThemeData.bottomSheetTheme.copyWith( + elevation: 0, + modalElevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16 * config.borderRadiusScale), + topRight: Radius.circular(16 * config.borderRadiusScale)))), + canvasColor: scheme.primaryScale.subtleBackground, + checkboxTheme: baseThemeData.checkboxTheme.copyWith( + side: BorderSide(color: scheme.primaryScale.border, width: 2), + checkColor: elementColorWidgetStateProperty(), + fillColor: checkboxFillColorWidgetStateProperty(), + ), + chipTheme: baseThemeData.chipTheme.copyWith( + backgroundColor: scheme.primaryScale.elementBackground, + selectedColor: scheme.primaryScale.activeElementBackground, + surfaceTintColor: scheme.primaryScale.hoverElementBackground, + checkmarkColor: scheme.primaryScale.primary, + side: BorderSide(color: scheme.primaryScale.border)), + elevatedButtonTheme: elevatedButtonTheme, + inputDecorationTheme: + ScaleInputDecoratorTheme(scheme, config, textTheme), + sliderTheme: sliderTheme, + popupMenuTheme: PopupMenuThemeData( + color: scheme.primaryScale.elementBackground, + shadowColor: Colors.transparent, + shape: RoundedRectangleBorder( + side: BorderSide(color: scheme.primaryScale.border, width: 2), + borderRadius: + BorderRadius.circular(8 * config.borderRadiusScale))), + extensions: >[scheme, config, this]); + + return themeData; + } +} diff --git a/lib/theme/models/scale_theme/scale_tile_theme.dart b/lib/theme/models/scale_theme/scale_tile_theme.dart new file mode 100644 index 0000000..d549157 --- /dev/null +++ b/lib/theme/models/scale_theme/scale_tile_theme.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +import 'scale_theme.dart'; + +class ScaleTileTheme { + ScaleTileTheme( + {required this.textColor, + required this.backgroundColor, + required this.borderColor, + required this.shapeBorder, + required this.largeTextStyle, + required this.smallTextStyle}); + + final Color textColor; + final Color backgroundColor; + final Color borderColor; + final ShapeBorder shapeBorder; + final TextStyle largeTextStyle; + final TextStyle smallTextStyle; +} + +extension ScaleTileThemeExt on ScaleTheme { + ScaleTileTheme tileTheme( + {bool disabled = false, + bool selected = false, + ScaleKind scaleKind = ScaleKind.primary}) { + final tileColor = scheme.scale(!disabled ? scaleKind : ScaleKind.gray); + + final borderColor = selected ? tileColor.hoverBorder : tileColor.border; + final backgroundColor = config.useVisualIndicators && !selected + ? tileColor.borderText + : borderColor; + final textColor = config.useVisualIndicators && !selected + ? borderColor + : tileColor.borderText; + + final largeTextStyle = textTheme.labelMedium!.copyWith(color: textColor); + final smallTextStyle = textTheme.labelSmall!.copyWith(color: textColor); + + final shapeBorder = RoundedRectangleBorder( + side: config.useVisualIndicators + ? BorderSide( + width: 2, + color: borderColor, + ) + : BorderSide.none, + borderRadius: BorderRadius.circular(8 * config.borderRadiusScale)); + + return ScaleTileTheme( + textColor: textColor, + backgroundColor: backgroundColor, + borderColor: borderColor, + shapeBorder: shapeBorder, + largeTextStyle: largeTextStyle, + smallTextStyle: smallTextStyle, + ); + } +} diff --git a/lib/theme/models/scale_theme/scale_toast_theme.dart b/lib/theme/models/scale_theme/scale_toast_theme.dart new file mode 100644 index 0000000..61f119d --- /dev/null +++ b/lib/theme/models/scale_theme/scale_toast_theme.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import 'scale_theme.dart'; + +enum ScaleToastKind { + info, + error, +} + +class ScaleToastTheme { + ScaleToastTheme( + {required this.primaryColor, + required this.backgroundColor, + required this.foregroundColor, + required this.borderSide, + required this.borderRadius, + required this.padding, + required this.icon, + required this.titleTextStyle, + required this.descriptionTextStyle}); + + final Color primaryColor; + final Color backgroundColor; + final Color foregroundColor; + final BorderSide? borderSide; + final BorderRadiusGeometry borderRadius; + final EdgeInsetsGeometry padding; + final Icon icon; + final TextStyle titleTextStyle; + final TextStyle descriptionTextStyle; +} + +extension ScaleToastThemeExt on ScaleTheme { + ScaleToastTheme toastTheme(ScaleToastKind kind) { + final toastScaleColor = scheme.scale(ScaleKind.tertiary); + + final primaryColor = toastScaleColor.calloutText; + final borderColor = toastScaleColor.border; + final backgroundColor = config.useVisualIndicators + ? toastScaleColor.calloutText + : toastScaleColor.calloutBackground; + final textColor = config.useVisualIndicators + ? toastScaleColor.calloutBackground + : toastScaleColor.calloutText; + final titleColor = config.useVisualIndicators + ? toastScaleColor.calloutBackground + : toastScaleColor.calloutText; + Icon icon; + switch (kind) { + case ScaleToastKind.info: + icon = Icon(Icons.info, size: 32, color: primaryColor); + case ScaleToastKind.error: + icon = Icon(Icons.dangerous, size: 32, color: primaryColor); + } + + return ScaleToastTheme( + primaryColor: primaryColor, + backgroundColor: backgroundColor, + foregroundColor: textColor, + borderSide: (config.useVisualIndicators || config.preferBorders) + ? BorderSide(color: borderColor, width: 2) + : const BorderSide(color: Colors.transparent, width: 0), + borderRadius: BorderRadius.circular(12 * config.borderRadiusScale), + padding: const EdgeInsets.all(8), + icon: icon, + titleTextStyle: textTheme.labelMedium!.copyWith(color: titleColor), + descriptionTextStyle: + textTheme.labelMedium!.copyWith(color: textColor)); + } +} diff --git a/lib/theme/models/slider_tile.dart b/lib/theme/models/slider_tile.dart deleted file mode 100644 index 251581e..0000000 --- a/lib/theme/models/slider_tile.dart +++ /dev/null @@ -1,151 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_slidable/flutter_slidable.dart'; - -import '../theme.dart'; - -class SliderTileAction { - const SliderTileAction({ - required this.actionScale, - required this.onPressed, - this.key, - this.icon, - this.label, - }); - - final Key? key; - final ScaleKind actionScale; - final String? label; - final IconData? icon; - final SlidableActionCallback? onPressed; -} - -class SliderTile extends StatelessWidget { - const SliderTile( - {required this.disabled, - required this.selected, - required this.tileScale, - required this.title, - this.subtitle = '', - this.endActions = const [], - this.startActions = const [], - this.onTap, - this.icon, - super.key}); - - final bool disabled; - final bool selected; - final ScaleKind tileScale; - final List endActions; - final List startActions; - final GestureTapCallback? onTap; - final IconData? icon; - final String title; - final String subtitle; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty('disabled', disabled)) - ..add(DiagnosticsProperty('selected', selected)) - ..add(DiagnosticsProperty('tileScale', tileScale)) - ..add(IterableProperty('endActions', endActions)) - ..add(IterableProperty('startActions', startActions)) - ..add(ObjectFlagProperty.has('onTap', onTap)) - ..add(DiagnosticsProperty('icon', icon)) - ..add(StringProperty('title', title)) - ..add(StringProperty('subtitle', subtitle)); - } - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scale = theme.extension()!; - final tileColor = scale.scale(!disabled ? tileScale : ScaleKind.gray); - final scalecfg = theme.extension()!; - - final borderColor = selected ? tileColor.hoverBorder : tileColor.border; - final backgroundColor = scalecfg.useVisualIndicators && !selected - ? tileColor.borderText - : borderColor; - final textColor = scalecfg.useVisualIndicators && !selected - ? borderColor - : tileColor.borderText; - - return Container( - clipBehavior: Clip.antiAlias, - decoration: ShapeDecoration( - color: backgroundColor, - shape: RoundedRectangleBorder( - side: scalecfg.useVisualIndicators - ? BorderSide(width: 2, color: borderColor, strokeAlign: 0) - : BorderSide.none, - borderRadius: BorderRadius.circular(8), - )), - child: Slidable( - // Specify a key if the Slidable is dismissible. - key: key, - endActionPane: endActions.isEmpty - ? null - : ActionPane( - motion: const DrawerMotion(), - children: endActions - .map( - (a) => SlidableAction( - onPressed: disabled ? null : a.onPressed, - backgroundColor: scalecfg.useVisualIndicators - ? (selected - ? tileColor.borderText - : tileColor.border) - : scale.scale(a.actionScale).primary, - foregroundColor: scalecfg.useVisualIndicators - ? (selected - ? tileColor.border - : tileColor.borderText) - : scale.scale(a.actionScale).primaryText, - icon: a.icon, - label: a.label, - padding: const EdgeInsets.all(2)), - ) - .toList()), - startActionPane: startActions.isEmpty - ? null - : ActionPane( - motion: const DrawerMotion(), - children: startActions - .map( - (a) => SlidableAction( - onPressed: disabled ? null : a.onPressed, - backgroundColor: scalecfg.useVisualIndicators - ? (selected - ? tileColor.borderText - : tileColor.border) - : scale.scale(a.actionScale).primary, - foregroundColor: scalecfg.useVisualIndicators - ? (selected - ? tileColor.border - : tileColor.borderText) - : scale.scale(a.actionScale).primaryText, - icon: a.icon, - label: a.label, - padding: const EdgeInsets.all(2)), - ) - .toList()), - child: Padding( - padding: scalecfg.useVisualIndicators - ? EdgeInsets.zero - : const EdgeInsets.fromLTRB(0, 2, 0, 2), - child: ListTile( - onTap: onTap, - title: Text( - title, - softWrap: true, - ), - subtitle: subtitle.isNotEmpty ? Text(subtitle) : null, - iconColor: textColor, - textColor: textColor, - leading: icon == null ? null : Icon(icon))))); - } -} diff --git a/lib/theme/models/theme_preference.dart b/lib/theme/models/theme_preference.dart index 334bbba..44d06d8 100644 --- a/lib/theme/models/theme_preference.dart +++ b/lib/theme/models/theme_preference.dart @@ -1,10 +1,13 @@ import 'package:change_case/change_case.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import '../../init.dart'; import '../views/widget_helpers.dart'; import 'contrast_generator.dart'; import 'radix_generator.dart'; +import 'scale_theme/scale_scheme.dart'; part 'theme_preference.freezed.dart'; part 'theme_preference.g.dart'; @@ -37,6 +40,7 @@ enum ColorPreference { lime, grim, // Accessible Colors + elite, contrast; factory ColorPreference.fromJson(dynamic j) => @@ -45,25 +49,34 @@ enum ColorPreference { } @freezed -class ThemePreferences with _$ThemePreferences { +sealed class ThemePreferences with _$ThemePreferences { const factory ThemePreferences({ - required BrightnessPreference brightnessPreference, - required ColorPreference colorPreference, - required double displayScale, + @Default(BrightnessPreference.system) + BrightnessPreference brightnessPreference, + @Default(ColorPreference.vapor) ColorPreference colorPreference, + @Default(1) double displayScale, + @Default(true) bool enableWallpaper, }) = _ThemePreferences; factory ThemePreferences.fromJson(dynamic json) => _$ThemePreferencesFromJson(json as Map); - static const ThemePreferences defaults = ThemePreferences( - colorPreference: ColorPreference.vapor, - brightnessPreference: BrightnessPreference.system, - displayScale: 1, - ); + static const defaults = ThemePreferences(); } extension ThemePreferencesExt on ThemePreferences { - /// Get material 'ThemeData' for existinb + /// Get wallpaper for existing theme + Widget? wallpaper() { + if (enableWallpaper) { + final assetName = 'assets/images/wallpaper/${colorPreference.name}.svg'; + if (rootAssets.contains(assetName)) { + return SvgPicture.asset(assetName, fit: BoxFit.cover); + } + } + return null; + } + + /// Get material 'ThemeData' for existing theme ThemeData themeData() { late final Brightness brightness; switch (brightnessPreference) { @@ -83,8 +96,63 @@ extension ThemePreferencesExt on ThemePreferences { switch (colorPreference) { // Special cases case ColorPreference.contrast: - // xxx do contrastGenerator - themeData = contrastGenerator(brightness); + themeData = contrastGenerator( + brightness: brightness, + scaleConfig: ScaleConfig( + useVisualIndicators: true, + preferBorders: false, + borderRadiusScale: 1, + wallpaperOpacity: 255), + primaryFront: Colors.black, + primaryBack: Colors.white, + secondaryFront: Colors.black, + secondaryBack: Colors.white, + tertiaryFront: Colors.black, + tertiaryBack: Colors.white, + grayFront: Colors.black, + grayBack: Colors.white, + errorFront: Colors.black, + errorBack: Colors.white, + ); + case ColorPreference.elite: + themeData = brightness == Brightness.light + ? contrastGenerator( + brightness: Brightness.light, + scaleConfig: ScaleConfig( + useVisualIndicators: true, + preferBorders: true, + borderRadiusScale: 0.2, + wallpaperOpacity: 208), + primaryFront: const Color(0xFF000000), + primaryBack: const Color(0xFF00FF00), + secondaryFront: const Color(0xFF000000), + secondaryBack: const Color(0xFF00FFFF), + tertiaryFront: const Color(0xFF000000), + tertiaryBack: const Color(0xFFFF00FF), + grayFront: const Color(0xFF000000), + grayBack: const Color(0xFFFFFFFF), + errorFront: const Color(0xFFC0C0C0), + errorBack: const Color(0xFF0000FF), + customTextTheme: makeMonoSpaceTextTheme(Brightness.light)) + : contrastGenerator( + brightness: Brightness.dark, + scaleConfig: ScaleConfig( + useVisualIndicators: true, + preferBorders: true, + borderRadiusScale: 0.2, + wallpaperOpacity: 192), + primaryFront: const Color(0xFF000000), + primaryBack: const Color(0xFF00FF00), + secondaryFront: const Color(0xFF000000), + secondaryBack: const Color(0xFF00FFFF), + tertiaryFront: const Color(0xFF000000), + tertiaryBack: const Color(0xFFFF00FF), + grayFront: const Color(0xFF000000), + grayBack: const Color(0xFFFFFFFF), + errorFront: const Color(0xFF0000FF), + errorBack: const Color(0xFFC0C0C0), + customTextTheme: makeMonoSpaceTextTheme(Brightness.dark), + ); // Generate from Radix case ColorPreference.scarlet: themeData = radixGenerator(brightness, RadixThemeColor.scarlet); diff --git a/lib/theme/models/theme_preference.freezed.dart b/lib/theme/models/theme_preference.freezed.dart index 9f10955..c915bca 100644 --- a/lib/theme/models/theme_preference.freezed.dart +++ b/lib/theme/models/theme_preference.freezed.dart @@ -1,3 +1,4 @@ +// dart format width=80 // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint @@ -9,193 +10,222 @@ part of 'theme_preference.dart'; // FreezedGenerator // ************************************************************************** +// dart format off T _$identity(T value) => value; -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -ThemePreferences _$ThemePreferencesFromJson(Map json) { - return _ThemePreferences.fromJson(json); -} - /// @nodoc mixin _$ThemePreferences { - BrightnessPreference get brightnessPreference => - throw _privateConstructorUsedError; - ColorPreference get colorPreference => throw _privateConstructorUsedError; - double get displayScale => throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $ThemePreferencesCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $ThemePreferencesCopyWith<$Res> { - factory $ThemePreferencesCopyWith( - ThemePreferences value, $Res Function(ThemePreferences) then) = - _$ThemePreferencesCopyWithImpl<$Res, ThemePreferences>; - @useResult - $Res call( - {BrightnessPreference brightnessPreference, - ColorPreference colorPreference, - double displayScale}); -} - -/// @nodoc -class _$ThemePreferencesCopyWithImpl<$Res, $Val extends ThemePreferences> - implements $ThemePreferencesCopyWith<$Res> { - _$ThemePreferencesCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; + BrightnessPreference get brightnessPreference; + ColorPreference get colorPreference; + double get displayScale; + bool get enableWallpaper; + /// Create a copy of ThemePreferences + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') + $ThemePreferencesCopyWith get copyWith => + _$ThemePreferencesCopyWithImpl( + this as ThemePreferences, _$identity); + + /// Serializes this ThemePreferences to a JSON map. + Map toJson(); + @override - $Res call({ - Object? brightnessPreference = null, - Object? colorPreference = null, - Object? displayScale = null, - }) { - return _then(_value.copyWith( - brightnessPreference: null == brightnessPreference - ? _value.brightnessPreference - : brightnessPreference // ignore: cast_nullable_to_non_nullable - as BrightnessPreference, - colorPreference: null == colorPreference - ? _value.colorPreference - : colorPreference // ignore: cast_nullable_to_non_nullable - as ColorPreference, - displayScale: null == displayScale - ? _value.displayScale - : displayScale // ignore: cast_nullable_to_non_nullable - as double, - ) as $Val); + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is ThemePreferences && + (identical(other.brightnessPreference, brightnessPreference) || + other.brightnessPreference == brightnessPreference) && + (identical(other.colorPreference, colorPreference) || + other.colorPreference == colorPreference) && + (identical(other.displayScale, displayScale) || + other.displayScale == displayScale) && + (identical(other.enableWallpaper, enableWallpaper) || + other.enableWallpaper == enableWallpaper)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, brightnessPreference, + colorPreference, displayScale, enableWallpaper); + + @override + String toString() { + return 'ThemePreferences(brightnessPreference: $brightnessPreference, colorPreference: $colorPreference, displayScale: $displayScale, enableWallpaper: $enableWallpaper)'; } } /// @nodoc -abstract class _$$ThemePreferencesImplCopyWith<$Res> - implements $ThemePreferencesCopyWith<$Res> { - factory _$$ThemePreferencesImplCopyWith(_$ThemePreferencesImpl value, - $Res Function(_$ThemePreferencesImpl) then) = - __$$ThemePreferencesImplCopyWithImpl<$Res>; - @override +abstract mixin class $ThemePreferencesCopyWith<$Res> { + factory $ThemePreferencesCopyWith( + ThemePreferences value, $Res Function(ThemePreferences) _then) = + _$ThemePreferencesCopyWithImpl; @useResult $Res call( {BrightnessPreference brightnessPreference, ColorPreference colorPreference, - double displayScale}); + double displayScale, + bool enableWallpaper}); } /// @nodoc -class __$$ThemePreferencesImplCopyWithImpl<$Res> - extends _$ThemePreferencesCopyWithImpl<$Res, _$ThemePreferencesImpl> - implements _$$ThemePreferencesImplCopyWith<$Res> { - __$$ThemePreferencesImplCopyWithImpl(_$ThemePreferencesImpl _value, - $Res Function(_$ThemePreferencesImpl) _then) - : super(_value, _then); +class _$ThemePreferencesCopyWithImpl<$Res> + implements $ThemePreferencesCopyWith<$Res> { + _$ThemePreferencesCopyWithImpl(this._self, this._then); + final ThemePreferences _self; + final $Res Function(ThemePreferences) _then; + + /// Create a copy of ThemePreferences + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ Object? brightnessPreference = null, Object? colorPreference = null, Object? displayScale = null, + Object? enableWallpaper = null, }) { - return _then(_$ThemePreferencesImpl( + return _then(_self.copyWith( brightnessPreference: null == brightnessPreference - ? _value.brightnessPreference + ? _self.brightnessPreference : brightnessPreference // ignore: cast_nullable_to_non_nullable as BrightnessPreference, colorPreference: null == colorPreference - ? _value.colorPreference + ? _self.colorPreference : colorPreference // ignore: cast_nullable_to_non_nullable as ColorPreference, displayScale: null == displayScale - ? _value.displayScale + ? _self.displayScale : displayScale // ignore: cast_nullable_to_non_nullable as double, + enableWallpaper: null == enableWallpaper + ? _self.enableWallpaper + : enableWallpaper // ignore: cast_nullable_to_non_nullable + as bool, )); } } /// @nodoc @JsonSerializable() -class _$ThemePreferencesImpl implements _ThemePreferences { - const _$ThemePreferencesImpl( - {required this.brightnessPreference, - required this.colorPreference, - required this.displayScale}); - - factory _$ThemePreferencesImpl.fromJson(Map json) => - _$$ThemePreferencesImplFromJson(json); +class _ThemePreferences implements ThemePreferences { + const _ThemePreferences( + {this.brightnessPreference = BrightnessPreference.system, + this.colorPreference = ColorPreference.vapor, + this.displayScale = 1, + this.enableWallpaper = true}); + factory _ThemePreferences.fromJson(Map json) => + _$ThemePreferencesFromJson(json); @override + @JsonKey() final BrightnessPreference brightnessPreference; @override + @JsonKey() final ColorPreference colorPreference; @override + @JsonKey() final double displayScale; + @override + @JsonKey() + final bool enableWallpaper; + + /// Create a copy of ThemePreferences + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$ThemePreferencesCopyWith<_ThemePreferences> get copyWith => + __$ThemePreferencesCopyWithImpl<_ThemePreferences>(this, _$identity); @override - String toString() { - return 'ThemePreferences(brightnessPreference: $brightnessPreference, colorPreference: $colorPreference, displayScale: $displayScale)'; + Map toJson() { + return _$ThemePreferencesToJson( + this, + ); } @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$ThemePreferencesImpl && + other is _ThemePreferences && (identical(other.brightnessPreference, brightnessPreference) || other.brightnessPreference == brightnessPreference) && (identical(other.colorPreference, colorPreference) || other.colorPreference == colorPreference) && (identical(other.displayScale, displayScale) || - other.displayScale == displayScale)); + other.displayScale == displayScale) && + (identical(other.enableWallpaper, enableWallpaper) || + other.enableWallpaper == enableWallpaper)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash( - runtimeType, brightnessPreference, colorPreference, displayScale); + int get hashCode => Object.hash(runtimeType, brightnessPreference, + colorPreference, displayScale, enableWallpaper); - @JsonKey(ignore: true) + @override + String toString() { + return 'ThemePreferences(brightnessPreference: $brightnessPreference, colorPreference: $colorPreference, displayScale: $displayScale, enableWallpaper: $enableWallpaper)'; + } +} + +/// @nodoc +abstract mixin class _$ThemePreferencesCopyWith<$Res> + implements $ThemePreferencesCopyWith<$Res> { + factory _$ThemePreferencesCopyWith( + _ThemePreferences value, $Res Function(_ThemePreferences) _then) = + __$ThemePreferencesCopyWithImpl; + @override + @useResult + $Res call( + {BrightnessPreference brightnessPreference, + ColorPreference colorPreference, + double displayScale, + bool enableWallpaper}); +} + +/// @nodoc +class __$ThemePreferencesCopyWithImpl<$Res> + implements _$ThemePreferencesCopyWith<$Res> { + __$ThemePreferencesCopyWithImpl(this._self, this._then); + + final _ThemePreferences _self; + final $Res Function(_ThemePreferences) _then; + + /// Create a copy of ThemePreferences + /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') - _$$ThemePreferencesImplCopyWith<_$ThemePreferencesImpl> get copyWith => - __$$ThemePreferencesImplCopyWithImpl<_$ThemePreferencesImpl>( - this, _$identity); - - @override - Map toJson() { - return _$$ThemePreferencesImplToJson( - this, - ); + $Res call({ + Object? brightnessPreference = null, + Object? colorPreference = null, + Object? displayScale = null, + Object? enableWallpaper = null, + }) { + return _then(_ThemePreferences( + brightnessPreference: null == brightnessPreference + ? _self.brightnessPreference + : brightnessPreference // ignore: cast_nullable_to_non_nullable + as BrightnessPreference, + colorPreference: null == colorPreference + ? _self.colorPreference + : colorPreference // ignore: cast_nullable_to_non_nullable + as ColorPreference, + displayScale: null == displayScale + ? _self.displayScale + : displayScale // ignore: cast_nullable_to_non_nullable + as double, + enableWallpaper: null == enableWallpaper + ? _self.enableWallpaper + : enableWallpaper // ignore: cast_nullable_to_non_nullable + as bool, + )); } } -abstract class _ThemePreferences implements ThemePreferences { - const factory _ThemePreferences( - {required final BrightnessPreference brightnessPreference, - required final ColorPreference colorPreference, - required final double displayScale}) = _$ThemePreferencesImpl; - - factory _ThemePreferences.fromJson(Map json) = - _$ThemePreferencesImpl.fromJson; - - @override - BrightnessPreference get brightnessPreference; - @override - ColorPreference get colorPreference; - @override - double get displayScale; - @override - @JsonKey(ignore: true) - _$$ThemePreferencesImplCopyWith<_$ThemePreferencesImpl> get copyWith => - throw _privateConstructorUsedError; -} +// dart format on diff --git a/lib/theme/models/theme_preference.g.dart b/lib/theme/models/theme_preference.g.dart index 6f33c43..f052e2c 100644 --- a/lib/theme/models/theme_preference.g.dart +++ b/lib/theme/models/theme_preference.g.dart @@ -6,19 +6,22 @@ part of 'theme_preference.dart'; // JsonSerializableGenerator // ************************************************************************** -_$ThemePreferencesImpl _$$ThemePreferencesImplFromJson( - Map json) => - _$ThemePreferencesImpl( - brightnessPreference: - BrightnessPreference.fromJson(json['brightness_preference']), - colorPreference: ColorPreference.fromJson(json['color_preference']), - displayScale: (json['display_scale'] as num).toDouble(), +_ThemePreferences _$ThemePreferencesFromJson(Map json) => + _ThemePreferences( + brightnessPreference: json['brightness_preference'] == null + ? BrightnessPreference.system + : BrightnessPreference.fromJson(json['brightness_preference']), + colorPreference: json['color_preference'] == null + ? ColorPreference.vapor + : ColorPreference.fromJson(json['color_preference']), + displayScale: (json['display_scale'] as num?)?.toDouble() ?? 1, + enableWallpaper: json['enable_wallpaper'] as bool? ?? true, ); -Map _$$ThemePreferencesImplToJson( - _$ThemePreferencesImpl instance) => +Map _$ThemePreferencesToJson(_ThemePreferences instance) => { 'brightness_preference': instance.brightnessPreference.toJson(), 'color_preference': instance.colorPreference.toJson(), 'display_scale': instance.displayScale, + 'enable_wallpaper': instance.enableWallpaper, }; diff --git a/lib/theme/views/brightness_preferences.dart b/lib/theme/views/brightness_preferences.dart deleted file mode 100644 index 2f3f410..0000000 --- a/lib/theme/views/brightness_preferences.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:animated_theme_switcher/animated_theme_switcher.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:flutter_translate/flutter_translate.dart'; - -import '../../settings/settings.dart'; -import '../models/models.dart'; - -const String formFieldBrightness = 'brightness'; - -List> _getBrightnessDropdownItems() { - const brightnessPrefs = BrightnessPreference.values; - final brightnessNames = { - BrightnessPreference.system: translate('brightness.system'), - BrightnessPreference.light: translate('brightness.light'), - BrightnessPreference.dark: translate('brightness.dark') - }; - - return brightnessPrefs - .map((e) => DropdownMenuItem(value: e, child: Text(brightnessNames[e]!))) - .toList(); -} - -Widget buildSettingsPageBrightnessPreferences( - {required BuildContext context, required void Function() onChanged}) { - final preferencesRepository = PreferencesRepository.instance; - final themePreferences = preferencesRepository.value.themePreferences; - return ThemeSwitcher.withTheme( - builder: (_, switcher, theme) => FormBuilderDropdown( - name: formFieldBrightness, - decoration: InputDecoration( - label: Text(translate('settings_page.brightness_mode'))), - items: _getBrightnessDropdownItems(), - initialValue: themePreferences.brightnessPreference, - onChanged: (value) async { - final newThemePrefs = themePreferences.copyWith( - brightnessPreference: value as BrightnessPreference); - final newPrefs = preferencesRepository.value - .copyWith(themePreferences: newThemePrefs); - - await preferencesRepository.set(newPrefs); - switcher.changeTheme(theme: newThemePrefs.themeData()); - onChanged(); - })); -} diff --git a/lib/tools/enter_password.dart b/lib/theme/views/enter_password.dart similarity index 97% rename from lib/tools/enter_password.dart rename to lib/theme/views/enter_password.dart index 2240278..f28b69e 100644 --- a/lib/tools/enter_password.dart +++ b/lib/theme/views/enter_password.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import '../theme/theme.dart'; +import '../theme.dart'; class EnterPasswordDialog extends StatefulWidget { const EnterPasswordDialog({ @@ -32,7 +32,7 @@ class _EnterPasswordDialogState extends State { final passwordController = TextEditingController(); final focusNode = FocusNode(); final formKey = GlobalKey(); - bool _passwordVisible = false; + var _passwordVisible = false; @override void initState() { @@ -47,7 +47,6 @@ class _EnterPasswordDialogState extends State { } @override - // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { final theme = Theme.of(context); final scale = theme.extension()!; diff --git a/lib/tools/enter_pin.dart b/lib/theme/views/enter_pin.dart similarity index 96% rename from lib/tools/enter_pin.dart rename to lib/theme/views/enter_pin.dart index d0a21ec..f4055c1 100644 --- a/lib/tools/enter_pin.dart +++ b/lib/theme/views/enter_pin.dart @@ -5,7 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:pinput/pinput.dart'; -import '../theme/theme.dart'; +import '../theme.dart'; class EnterPinDialog extends StatefulWidget { const EnterPinDialog({ @@ -51,6 +51,7 @@ class _EnterPinDialogState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final scale = theme.extension()!; + final scaleConfig = theme.extension()!; final focusedBorderColor = scale.primaryScale.hoverBorder; final fillColor = scale.primaryScale.elementBackground; final borderColor = scale.primaryScale.border; @@ -61,7 +62,7 @@ class _EnterPinDialogState extends State { textStyle: TextStyle(fontSize: 22, color: scale.primaryScale.appText), decoration: BoxDecoration( color: fillColor, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(8 * scaleConfig.borderRadiusScale), border: Border.all(color: borderColor), ), ); diff --git a/lib/tools/pop_control.dart b/lib/theme/views/pop_control.dart similarity index 93% rename from lib/tools/pop_control.dart rename to lib/theme/views/pop_control.dart index 29dc562..d2e98f9 100644 --- a/lib/tools/pop_control.dart +++ b/lib/theme/views/pop_control.dart @@ -8,18 +8,15 @@ class PopControl extends StatelessWidget { super.key, }); - void _doDismiss(NavigatorState navigator) { + void _doDismiss(BuildContext context) { if (!dismissible) { return; } - navigator.pop(); + Navigator.of(context).pop(); } @override - // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { - final navigator = Navigator.of(context); - final route = ModalRoute.of(context); if (route != null && route is PopControlDialogRoute) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -29,11 +26,12 @@ class PopControl extends StatelessWidget { return PopScope( canPop: false, - onPopInvoked: (didPop) { + onPopInvokedWithResult: (didPop, _) { if (didPop) { return; } - _doDismiss(navigator); + _doDismiss(context); + return; }, child: child); } diff --git a/lib/theme/views/preferences/brightness_preferences.dart b/lib/theme/views/preferences/brightness_preferences.dart new file mode 100644 index 0000000..a149483 --- /dev/null +++ b/lib/theme/views/preferences/brightness_preferences.dart @@ -0,0 +1,40 @@ +import 'package:animated_theme_switcher/animated_theme_switcher.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../../settings/settings.dart'; +import '../../models/models.dart'; +import '../views.dart'; + +List> _getBrightnessDropdownItems() { + const brightnessPrefs = BrightnessPreference.values; + final brightnessNames = { + BrightnessPreference.system: translate('brightness.system'), + BrightnessPreference.light: translate('brightness.light'), + BrightnessPreference.dark: translate('brightness.dark') + }; + + return brightnessPrefs + .map((e) => DropdownMenuItem(value: e, child: Text(brightnessNames[e]!))) + .toList(); +} + +Widget buildSettingsPageBrightnessPreferences( + {required BuildContext context, required ThemeSwitcherState switcher}) { + final preferencesRepository = PreferencesRepository.instance; + final themePreferences = preferencesRepository.value.themePreference; + + return StyledDropdown( + items: _getBrightnessDropdownItems(), + value: themePreferences.brightnessPreference, + decoratorLabel: translate('settings_page.brightness_mode'), + onChanged: (value) async { + final newThemePrefs = + themePreferences.copyWith(brightnessPreference: value); + final newPrefs = preferencesRepository.value + .copyWith(themePreference: newThemePrefs); + + await preferencesRepository.set(newPrefs); + switcher.changeTheme(theme: newThemePrefs.themeData()); + }); +} diff --git a/lib/theme/views/color_preferences.dart b/lib/theme/views/preferences/color_preferences.dart similarity index 52% rename from lib/theme/views/color_preferences.dart rename to lib/theme/views/preferences/color_preferences.dart index 4a5219d..2c14a93 100644 --- a/lib/theme/views/color_preferences.dart +++ b/lib/theme/views/preferences/color_preferences.dart @@ -1,14 +1,12 @@ import 'package:animated_theme_switcher/animated_theme_switcher.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import '../../settings/settings.dart'; -import '../models/models.dart'; +import '../../../settings/settings.dart'; +import '../../models/models.dart'; +import '../views.dart'; -const String formFieldTheme = 'theme'; - -List> _getThemeDropdownItems() { +List> _getThemeDropdownItems() { const colorPrefs = ColorPreference.values; final colorNames = { ColorPreference.scarlet: translate('themes.scarlet'), @@ -22,6 +20,7 @@ List> _getThemeDropdownItems() { ColorPreference.eggplant: translate('themes.eggplant'), ColorPreference.lime: translate('themes.lime'), ColorPreference.grim: translate('themes.grim'), + ColorPreference.elite: translate('themes.elite'), ColorPreference.contrast: translate('themes.contrast') }; @@ -31,24 +30,20 @@ List> _getThemeDropdownItems() { } Widget buildSettingsPageColorPreferences( - {required BuildContext context, required void Function() onChanged}) { + {required BuildContext context, required ThemeSwitcherState switcher}) { final preferencesRepository = PreferencesRepository.instance; - final themePreferences = preferencesRepository.value.themePreferences; - return ThemeSwitcher.withTheme( - builder: (_, switcher, theme) => FormBuilderDropdown( - name: formFieldTheme, - decoration: InputDecoration( - label: Text(translate('settings_page.color_theme'))), - items: _getThemeDropdownItems(), - initialValue: themePreferences.colorPreference, - onChanged: (value) async { - final newThemePrefs = themePreferences.copyWith( - colorPreference: value as ColorPreference); - final newPrefs = preferencesRepository.value - .copyWith(themePreferences: newThemePrefs); + final themePreferences = preferencesRepository.value.themePreference; - await preferencesRepository.set(newPrefs); - switcher.changeTheme(theme: newThemePrefs.themeData()); - onChanged(); - })); + return StyledDropdown( + items: _getThemeDropdownItems(), + value: themePreferences.colorPreference, + decoratorLabel: translate('settings_page.color_theme'), + onChanged: (value) async { + final newThemePrefs = themePreferences.copyWith(colorPreference: value); + final newPrefs = preferencesRepository.value + .copyWith(themePreference: newThemePrefs); + + await preferencesRepository.set(newPrefs); + switcher.changeTheme(theme: newThemePrefs.themeData()); + }); } diff --git a/lib/theme/views/preferences/display_scale_preferences.dart b/lib/theme/views/preferences/display_scale_preferences.dart new file mode 100644 index 0000000..c056280 --- /dev/null +++ b/lib/theme/views/preferences/display_scale_preferences.dart @@ -0,0 +1,127 @@ +import 'dart:math'; + +import 'package:animated_theme_switcher/animated_theme_switcher.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../../settings/settings.dart'; +import '../../models/models.dart'; +import '../views.dart'; + +const _scales = [ + 1 / (1 + 1 / 2), + 1 / (1 + 1 / 3), + 1 / (1 + 1 / 4), + 1, + 1 + (1 / 4), + 1 + (1 / 2), + 1 + (1 / 1), +]; +const _scaleNames = [ + '-3', + '-2', + '-1', + '0', + '1', + '2', + '3', +]; + +const _scaleNumMult = [ + 1 / (1 + 1 / 2), + 1 / (1 + 1 / 3), + 1 / (1 + 1 / 4), + 1, + 1 + 1 / 4, + 1 + 1 / 2, + 1 + 1 / 1, +]; + +const _scaleNumMultNoShrink = [ + 1, + 1, + 1, + 1, + 1 + 1 / 4, + 1 + 1 / 2, + 1 + 1 / 1, +]; + +int displayScaleToIndex(double displayScale) { + final idx = _scales.indexWhere((elem) => elem > displayScale); + final currentScaleIdx = idx == -1 ? _scales.length - 1 : max(0, idx - 1); + return currentScaleIdx; +} + +double indexToDisplayScale(int scaleIdx) { + final displayScale = + _scales[max(min(scaleIdx, _scales.length - 1), 0)].toDouble(); + return displayScale; +} + +String indexToDisplayScaleName(int scaleIdx) => + _scaleNames[max(min(scaleIdx, _scales.length - 1), 0)]; + +final maxDisplayScaleIndex = _scales.length - 1; + +Widget buildSettingsPageDisplayScalePreferences( + {required BuildContext context, required ThemeSwitcherState switcher}) { + final preferencesRepository = PreferencesRepository.instance; + final themePreferences = preferencesRepository.value.themePreference; + + final currentScaleIdx = displayScaleToIndex(themePreferences.displayScale); + final currentScaleName = indexToDisplayScaleName(currentScaleIdx); + + return StyledSlider( + value: currentScaleIdx.toDouble(), + label: currentScaleName, + decoratorLabel: translate('settings_page.display_scale'), + max: _scales.length - 1.toDouble(), + divisions: _scales.length - 1, + leftWidget: const Icon(Icons.text_decrease), + rightWidget: const Icon(Icons.text_increase), + onChanged: (value) async { + final scaleIdx = value.toInt(); + final displayScale = indexToDisplayScale(scaleIdx); + final newThemePrefs = + themePreferences.copyWith(displayScale: displayScale); + final newPrefs = preferencesRepository.value + .copyWith(themePreference: newThemePrefs); + + await preferencesRepository.set(newPrefs); + switcher.changeTheme(theme: newThemePrefs.themeData()); + }); +} + +extension DisplayScaledNum on num { + double scaled(BuildContext context) { + final prefs = context.watch().state.asData?.value ?? + PreferencesRepository.instance.value; + final currentScaleIdx = + displayScaleToIndex(prefs.themePreference.displayScale); + return this * _scaleNumMult[currentScaleIdx]; + } + + double scaledNoShrink(BuildContext context) { + final prefs = context.watch().state.asData?.value ?? + PreferencesRepository.instance.value; + final currentScaleIdx = + displayScaleToIndex(prefs.themePreference.displayScale); + return this * _scaleNumMultNoShrink[currentScaleIdx]; + } +} + +extension DisplayScaledEdgeInsets on EdgeInsets { + EdgeInsets scaled(BuildContext context) { + final prefs = context.watch().state.asData?.value ?? + PreferencesRepository.instance.value; + final currentScaleIdx = + displayScaleToIndex(prefs.themePreference.displayScale); + return EdgeInsets.fromLTRB( + left * _scaleNumMult[currentScaleIdx], + top * _scaleNumMult[currentScaleIdx], + right * _scaleNumMult[currentScaleIdx], + bottom * _scaleNumMult[currentScaleIdx]); + } +} diff --git a/lib/theme/views/preferences/preferences.dart b/lib/theme/views/preferences/preferences.dart new file mode 100644 index 0000000..ddac4c1 --- /dev/null +++ b/lib/theme/views/preferences/preferences.dart @@ -0,0 +1,4 @@ +export 'brightness_preferences.dart'; +export 'color_preferences.dart'; +export 'display_scale_preferences.dart'; +export 'wallpaper_preferences.dart'; diff --git a/lib/theme/views/preferences/wallpaper_preferences.dart b/lib/theme/views/preferences/wallpaper_preferences.dart new file mode 100644 index 0000000..050e294 --- /dev/null +++ b/lib/theme/views/preferences/wallpaper_preferences.dart @@ -0,0 +1,25 @@ +import 'package:animated_theme_switcher/animated_theme_switcher.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../../settings/settings.dart'; +import '../../models/models.dart'; +import '../views.dart'; + +Widget buildSettingsPageWallpaperPreferences( + {required BuildContext context, required ThemeSwitcherState switcher}) { + final preferencesRepository = PreferencesRepository.instance; + final themePreferences = preferencesRepository.value.themePreference; + + return StyledCheckbox( + value: themePreferences.enableWallpaper, + label: translate('settings_page.enable_wallpaper'), + onChanged: (value) async { + final newThemePrefs = themePreferences.copyWith(enableWallpaper: value); + final newPrefs = preferencesRepository.value + .copyWith(themePreference: newThemePrefs); + + await preferencesRepository.set(newPrefs); + switcher.changeTheme(theme: newThemePrefs.themeData()); + }); +} diff --git a/lib/theme/views/recovery_key_widget.dart b/lib/theme/views/recovery_key_widget.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/theme/views/responsive.dart b/lib/theme/views/responsive.dart new file mode 100644 index 0000000..4ce42d8 --- /dev/null +++ b/lib/theme/views/responsive.dart @@ -0,0 +1,53 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +final isAndroid = !kIsWeb && defaultTargetPlatform == TargetPlatform.android; +final isiOS = !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; +final isMac = !kIsWeb && defaultTargetPlatform == TargetPlatform.macOS; +final isWindows = !kIsWeb && defaultTargetPlatform == TargetPlatform.windows; +final isLinux = !kIsWeb && defaultTargetPlatform == TargetPlatform.linux; + +final isMobile = !kIsWeb && + (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android); +final isDesktop = !kIsWeb && + !(defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android); + +const isWeb = kIsWeb; +final isWebMobile = kIsWeb && + (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android); +final isWebDesktop = kIsWeb && + !(defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android); + +final isAnyMobile = isMobile || isWebMobile; + +const kMobileWidthCutoff = 500.0; + +bool isMobileWidth(BuildContext context) => + MediaQuery.of(context).size.width < kMobileWidthCutoff; + +bool isMobileSize(BuildContext context) => + MediaQuery.of(context).size.width < kMobileWidthCutoff || + MediaQuery.of(context).size.height < kMobileWidthCutoff; + +bool responsiveVisibility({ + required BuildContext context, + bool phone = true, + bool tablet = true, + bool tabletLandscape = true, + bool desktop = true, +}) { + final width = MediaQuery.of(context).size.width; + if (width < kMobileWidthCutoff) { + return phone; + } else if (width < 767) { + return tablet; + } else if (width < 991) { + return tabletLandscape; + } else { + return desktop; + } +} diff --git a/lib/theme/views/scanner_error_widget.dart b/lib/theme/views/scanner_error_widget.dart deleted file mode 100644 index 0926128..0000000 --- a/lib/theme/views/scanner_error_widget.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:mobile_scanner/mobile_scanner.dart'; - -class ScannerErrorWidget extends StatelessWidget { - const ScannerErrorWidget({required this.error, super.key}); - - final MobileScannerException error; - - @override - Widget build(BuildContext context) { - String errorMessage; - - switch (error.errorCode) { - case MobileScannerErrorCode.controllerUninitialized: - errorMessage = 'Controller not ready.'; - break; - case MobileScannerErrorCode.permissionDenied: - errorMessage = 'Permission denied'; - break; - case MobileScannerErrorCode.unsupported: - errorMessage = 'Scanning is unsupported on this device'; - break; - default: - errorMessage = 'Generic Error'; - break; - } - - return ColoredBox( - color: Colors.black, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Padding( - padding: EdgeInsets.only(bottom: 16), - child: Icon(Icons.error, color: Colors.white), - ), - Text( - errorMessage, - style: const TextStyle(color: Colors.white), - ), - Text( - error.errorDetails?.message ?? '', - style: const TextStyle(color: Colors.white), - ), - ], - ), - ), - ); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('error', error)); - } -} diff --git a/lib/theme/views/styled_widgets/styled_alert.dart b/lib/theme/views/styled_widgets/styled_alert.dart new file mode 100644 index 0000000..4dec616 --- /dev/null +++ b/lib/theme/views/styled_widgets/styled_alert.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:rflutter_alert/rflutter_alert.dart'; + +import '../../theme.dart'; + +AlertStyle _alertStyle(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + return AlertStyle( + animationType: AnimationType.grow, + isCloseButton: false, + //animationDuration: const Duration(milliseconds: 200), + alertBorder: RoundedRectangleBorder( + side: !scaleConfig.useVisualIndicators + ? BorderSide.none + : BorderSide( + strokeAlign: BorderSide.strokeAlignCenter, + color: scale.primaryScale.border, + width: 2), + borderRadius: BorderRadius.all( + Radius.circular(12 * scaleConfig.borderRadiusScale))), + // isButtonVisible: true, + // isCloseButton: true, + // isOverlayTapDismiss: true, + backgroundColor: scale.primaryScale.subtleBackground, + // overlayColor: Colors.black87, + titleStyle: theme.textTheme.titleMedium! + .copyWith(color: scale.primaryScale.appText), + // titleTextAlign: TextAlign.center, + descStyle: + theme.textTheme.bodyMedium!.copyWith(color: scale.primaryScale.appText), + // descTextAlign: TextAlign.center, + // buttonAreaPadding: const EdgeInsets.all(20.0), + // constraints: null, + // buttonsDirection: ButtonsDirection.row, + // alertElevation: null, + // alertPadding: defaultAlertPadding, + // alertAlignment: Alignment.center, + // isTitleSelectable: false, + // isDescSelectable: false, + // titlePadding: null, + //descPadding: const EdgeInsets.all(0.0), + ); +} + +Color _buttonColor(BuildContext context, bool highlight) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + if (scaleConfig.useVisualIndicators && !scaleConfig.preferBorders) { + return scale.secondaryScale.border; + } + + return highlight + ? scale.secondaryScale.elementBackground + : scale.secondaryScale.hoverElementBackground; +} + +TextStyle _buttonTextStyle(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + if (scaleConfig.useVisualIndicators && !scaleConfig.preferBorders) { + return theme.textTheme.bodyMedium! + .copyWith(color: scale.secondaryScale.borderText); + } + + return theme.textTheme.bodyMedium! + .copyWith(color: scale.secondaryScale.appText); +} + +BoxBorder _buttonBorder(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + return Border.fromBorderSide(BorderSide( + color: scale.secondaryScale.border, + width: scaleConfig.preferBorders ? 2 : 0)); +} + +BorderRadius _buttonRadius(BuildContext context) { + final theme = Theme.of(context); + final scaleConfig = theme.extension()!; + + return BorderRadius.circular(8 * scaleConfig.borderRadiusScale); +} + +Future showErrorModal( + {required BuildContext context, + required String title, + required String text}) async { + await Alert( + context: context, + style: _alertStyle(context), + useRootNavigator: false, + type: AlertType.error, + title: title, + desc: text, + buttons: [ + DialogButton( + color: _buttonColor(context, false), + highlightColor: _buttonColor(context, true), + border: _buttonBorder(context), + radius: _buttonRadius(context), + width: 120, + onPressed: () { + Navigator.pop(context); + }, + child: Text( + translate('button.ok'), + style: _buttonTextStyle(context), + ), + ) + ], + ).show(); +} + +Future showErrorStacktraceModal( + {required BuildContext context, + required Object error, + StackTrace? stackTrace}) async { + await showErrorModal( + context: context, + title: translate('toast.error'), + text: 'Error: $error\n StackTrace: $stackTrace', + ); +} + +Future showAlertModal( + {required BuildContext context, + required String title, + required String text}) async { + await Alert( + context: context, + style: _alertStyle(context), + useRootNavigator: false, + type: AlertType.none, + title: title, + desc: text, + buttons: [ + DialogButton( + color: _buttonColor(context, false), + highlightColor: _buttonColor(context, true), + border: _buttonBorder(context), + radius: _buttonRadius(context), + width: 120, + onPressed: () { + Navigator.pop(context); + }, + child: Text( + translate('button.ok'), + style: _buttonTextStyle(context), + ), + ) + ], + ).show(); +} + +Future showAlertWidgetModal( + {required BuildContext context, + required String title, + required Widget child}) async { + await Alert( + context: context, + style: _alertStyle(context), + useRootNavigator: false, + type: AlertType.none, + title: title, + content: child, + buttons: [ + DialogButton( + color: _buttonColor(context, false), + highlightColor: _buttonColor(context, true), + border: _buttonBorder(context), + radius: _buttonRadius(context), + width: 120, + onPressed: () { + Navigator.pop(context); + }, + child: Text( + translate('button.ok'), + style: _buttonTextStyle(context), + softWrap: true, + ), + ) + ], + ).show(); +} + +Future showConfirmModal( + {required BuildContext context, + required String title, + required String text}) async { + var confirm = false; + + await Alert( + context: context, + style: _alertStyle(context), + useRootNavigator: false, + type: AlertType.none, + title: title, + desc: text, + buttons: [ + DialogButton( + color: _buttonColor(context, false), + highlightColor: _buttonColor(context, true), + border: _buttonBorder(context), + radius: _buttonRadius(context), + width: 120, + onPressed: () { + Navigator.pop(context); + }, + child: Text( + translate('button.no'), + style: _buttonTextStyle(context), + ), + ), + DialogButton( + color: _buttonColor(context, false), + highlightColor: _buttonColor(context, true), + border: _buttonBorder(context), + radius: _buttonRadius(context), + width: 120, + onPressed: () { + confirm = true; + Navigator.pop(context); + }, + child: Text( + translate('button.yes'), + style: _buttonTextStyle(context), + ), + ) + ], + ).show(); + + return confirm; +} diff --git a/lib/theme/views/styled_widgets/styled_avatar.dart b/lib/theme/views/styled_widgets/styled_avatar.dart new file mode 100644 index 0000000..dde39f2 --- /dev/null +++ b/lib/theme/views/styled_widgets/styled_avatar.dart @@ -0,0 +1,77 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +import '../../theme.dart'; + +class StyledAvatar extends StatelessWidget { + const StyledAvatar({ + required String name, + required double size, + bool enabled = true, + super.key, + ImageProvider? imageProvider, + }) : _name = name, + _size = size, + _imageProvider = imageProvider, + _enabled = enabled; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scaleTheme = Theme.of(context).extension()!; + + final borderColor = scaleTheme.config.useVisualIndicators + ? scaleTheme.scheme.primaryScale.primaryText + : scaleTheme.scheme.primaryScale.subtleBorder; + final foregroundColor = !_enabled + ? scaleTheme.scheme.grayScale.primaryText + : scaleTheme.scheme.primaryScale.calloutText; + final backgroundColor = !_enabled + ? scaleTheme.scheme.grayScale.primary + : scaleTheme.scheme.primaryScale.calloutBackground; + final scaleConfig = scaleTheme.config; + final textStyle = theme.textTheme.titleLarge!.copyWith(fontSize: _size / 2); + + final abbrev = _name.split(' ').map((s) => s.isEmpty ? '' : s[0]).join(); + late final String shortname; + if (abbrev.length >= 3) { + shortname = abbrev[0] + abbrev[1] + abbrev[abbrev.length - 1]; + } else { + shortname = abbrev; + } + + return Container( + height: _size, + width: _size, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: !scaleConfig.useVisualIndicators + ? null + : Border.all( + color: borderColor, + width: 1 * (_size ~/ 16 + 1), + strokeAlign: BorderSide.strokeAlignOutside)), + child: AvatarImage( + backgroundImage: _imageProvider, + backgroundColor: scaleConfig.useVisualIndicators + ? foregroundColor + : backgroundColor, + child: Text( + shortname.isNotEmpty ? shortname : '?', + softWrap: false, + textScaler: MediaQuery.of(context).textScaler, + style: textStyle.copyWith( + color: scaleConfig.useVisualIndicators + ? backgroundColor + : foregroundColor, + ), + ).paddingAll(4.scaled(context)).fit(fit: BoxFit.scaleDown))); + } + + //////////////////////////////////////////////////////////////////////////// + final String _name; + final double _size; + final ImageProvider? _imageProvider; + final bool _enabled; +} diff --git a/lib/theme/views/styled_widgets/styled_button_box.dart b/lib/theme/views/styled_widgets/styled_button_box.dart new file mode 100644 index 0000000..811e01c --- /dev/null +++ b/lib/theme/views/styled_widgets/styled_button_box.dart @@ -0,0 +1,59 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; + +import '../../theme.dart'; + +class StyledButtonBox extends StatelessWidget { + const StyledButtonBox( + {required String instructions, + required IconData buttonIcon, + required String buttonText, + required void Function() onClick, + super.key}) + : _instructions = instructions, + _buttonIcon = buttonIcon, + _buttonText = buttonText, + _onClick = onClick; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + return Container( + constraints: const BoxConstraints(maxWidth: 400), + decoration: BoxDecoration( + color: scale.primaryScale.subtleBackground, + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale), + border: Border.all(color: scale.primaryScale.border)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + style: theme.textTheme.labelMedium! + .copyWith(color: scale.primaryScale.appText), + softWrap: true, + textAlign: TextAlign.center, + _instructions), + ElevatedButton( + onPressed: _onClick, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(_buttonIcon, + size: 24.scaled(context), + color: scale.primaryScale.appText) + .paddingLTRB(0, 8.scaled(context), + 8.scaled(context), 8.scaled(context)), + Text(textAlign: TextAlign.center, _buttonText) + ])).paddingLTRB(0, 12.scaled(context), 0, 0).toCenter() + ]).paddingAll(12.scaled(context))) + .paddingLTRB( + 24.scaled(context), 0, 24.scaled(context), 12.scaled(context)); + } + + final String _instructions; + final IconData _buttonIcon; + final String _buttonText; + final void Function() _onClick; +} diff --git a/lib/theme/views/styled_widgets/styled_checkbox.dart b/lib/theme/views/styled_widgets/styled_checkbox.dart new file mode 100644 index 0000000..0213db2 --- /dev/null +++ b/lib/theme/views/styled_widgets/styled_checkbox.dart @@ -0,0 +1,67 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions_flutter.dart'; +import 'package:flutter/material.dart'; + +import '../views.dart'; + +const _kStyledCheckboxChanged = 'kStyledCheckboxChanged'; + +class StyledCheckbox extends StatelessWidget { + const StyledCheckbox( + {required bool value, + required String label, + String? decoratorLabel, + Future Function(bool)? onChanged, + super.key}) + : _value = value, + _onChanged = onChanged, + _label = label, + _decoratorLabel = decoratorLabel; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + + var textStyle = textTheme.labelLarge!; + if (_onChanged == null) { + textStyle = textStyle.copyWith(color: textStyle.color!.withAlpha(127)); + } + + Widget ctrl = Row(children: [ + Transform.scale( + scale: 1.scaled(context), + child: Checkbox( + value: _value, + onChanged: _onChanged == null + ? null + : (value) { + if (value == null) { + return; + } + singleFuture((this, _kStyledCheckboxChanged), () async { + await _onChanged(value); + }); + })), + Text( + _label, + style: textStyle, + overflow: TextOverflow.clip, + ).paddingLTRB(4.scaled(context), 0, 0, 0).flexible(), + ]); + + if (_decoratorLabel != null) { + ctrl = ctrl + .paddingLTRB(4.scaled(context), 4.scaled(context), 4.scaled(context), + 4.scaled(context)) + .decoratorLabel(context, _decoratorLabel); + } + + return ctrl; + } + + final String _label; + final String? _decoratorLabel; + final Future Function(bool)? _onChanged; + final bool _value; +} diff --git a/lib/theme/views/styled_dialog.dart b/lib/theme/views/styled_widgets/styled_dialog.dart similarity index 58% rename from lib/theme/views/styled_dialog.dart rename to lib/theme/views/styled_widgets/styled_dialog.dart index 0fd079c..54431b2 100644 --- a/lib/theme/views/styled_dialog.dart +++ b/lib/theme/views/styled_widgets/styled_dialog.dart @@ -1,8 +1,8 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import '../theme.dart'; +import '../../../settings/settings.dart'; +import '../../theme.dart'; class StyledDialog extends StatelessWidget { const StyledDialog({required this.title, required this.child, super.key}); @@ -11,15 +11,17 @@ class StyledDialog extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final scale = theme.extension()!; + final scaleConfig = theme.extension()!; final textTheme = theme.textTheme; return AlertDialog( elevation: 0, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(16)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(16 * scaleConfig.borderRadiusScale)), ), contentPadding: const EdgeInsets.all(4), - backgroundColor: scale.primaryScale.dialogBorder, + backgroundColor: scale.primaryScale.border, title: Text( title, style: textTheme.titleMedium! @@ -31,22 +33,30 @@ class StyledDialog extends StatelessWidget { decoration: ShapeDecoration( color: scale.primaryScale.border, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16))), + borderRadius: BorderRadius.circular( + 16 * scaleConfig.borderRadiusScale))), child: DecoratedBox( decoration: ShapeDecoration( color: scale.primaryScale.appBackground, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12))), - child: child.paddingAll(0)))); + borderRadius: BorderRadius.circular( + 12 * scaleConfig.borderRadiusScale))), + child: child))); } static Future show( {required BuildContext context, required String title, - required Widget child}) async => + required Widget child}) => showDialog( context: context, - builder: (context) => StyledDialog(title: title, child: child)); + useRootNavigator: false, + builder: (context) => AsyncBlocBuilder( + builder: (context, state) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear( + state.themePreference.displayScale)), + child: StyledDialog(title: title, child: child)))); final String title; final Widget child; diff --git a/lib/theme/views/styled_widgets/styled_dropdown.dart b/lib/theme/views/styled_widgets/styled_dropdown.dart new file mode 100644 index 0000000..3af6424 --- /dev/null +++ b/lib/theme/views/styled_widgets/styled_dropdown.dart @@ -0,0 +1,59 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions_flutter.dart'; +import 'package:flutter/material.dart'; + +import '../../models/models.dart'; +import '../views.dart'; + +const _kStyledDropdownChanged = 'kStyledDropdownChanged'; + +class StyledDropdown extends StatelessWidget { + const StyledDropdown( + {required List> items, + required T value, + String? decoratorLabel, + Future Function(T)? onChanged, + super.key}) + : _items = items, + _onChanged = onChanged, + _decoratorLabel = decoratorLabel, + _value = value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.extension()!; + + Widget ctrl = DropdownButton( + isExpanded: true, + padding: const EdgeInsets.fromLTRB(4, 0, 4, 0).scaled(context), + focusColor: theme.focusColor, + dropdownColor: scheme.primaryScale.elementBackground, + iconEnabledColor: scheme.primaryScale.appText, + iconDisabledColor: scheme.primaryScale.appText.withAlpha(127), + items: _items, + value: _value, + style: theme.textTheme.labelLarge, + onChanged: _onChanged == null + ? null + : (value) { + if (value == null) { + return; + } + singleFuture((this, _kStyledDropdownChanged), () async { + await _onChanged(value); + }); + }); + if (_decoratorLabel != null) { + ctrl = ctrl + .paddingLTRB(0, 4.scaled(context), 0, 4.scaled(context)) + .decoratorLabel(context, _decoratorLabel); + } + return ctrl; + } + + final List> _items; + final String? _decoratorLabel; + final Future Function(T)? _onChanged; + final T _value; +} diff --git a/lib/theme/views/styled_widgets/styled_scaffold.dart b/lib/theme/views/styled_widgets/styled_scaffold.dart new file mode 100644 index 0000000..9a32640 --- /dev/null +++ b/lib/theme/views/styled_widgets/styled_scaffold.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import '../../theme.dart'; + +class StyledScaffold extends StatelessWidget { + const StyledScaffold({required this.appBar, required this.body, super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scaleScheme = theme.extension()!; + final scaleConfig = theme.extension()!; + final scale = scaleScheme.scale(ScaleKind.primary); + + const enableBorder = false; //!isMobileSize(context); + + var scaffold = clipBorder( + clipEnabled: enableBorder, + borderEnabled: scaleConfig.useVisualIndicators, + borderRadius: 16 * scaleConfig.borderRadiusScale, + borderColor: scale.border, + child: Scaffold(appBar: appBar, body: body, key: key)); + + if (!scaleConfig.useVisualIndicators) { + scaffold = scaffold.withThemedShadow(scaleConfig, scale); + } + + return GestureDetector( + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), + child: scaffold); + } + + //////////////////////////////////////////////////////////////////////////// + final PreferredSizeWidget? appBar; + final Widget? body; +} diff --git a/lib/theme/views/styled_widgets/styled_slide_tile.dart b/lib/theme/views/styled_widgets/styled_slide_tile.dart new file mode 100644 index 0000000..e4a0e27 --- /dev/null +++ b/lib/theme/views/styled_widgets/styled_slide_tile.dart @@ -0,0 +1,145 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; + +import '../../theme.dart'; + +class SlideTileAction { + const SlideTileAction({ + required this.actionScale, + required this.onPressed, + this.key, + this.icon, + this.label, + }); + + final Key? key; + final ScaleKind actionScale; + final String? label; + final IconData? icon; + final SlidableActionCallback? onPressed; +} + +class StyledSlideTile extends StatelessWidget { + const StyledSlideTile( + {required this.disabled, + required this.selected, + required this.tileScale, + required this.title, + this.subtitle = '', + this.endActions = const [], + this.startActions = const [], + this.onTap, + this.onDoubleTap, + this.leading, + this.trailing, + super.key}); + + final bool disabled; + final bool selected; + final ScaleKind tileScale; + final List endActions; + final List startActions; + final GestureTapCallback? onTap; + final GestureTapCallback? onDoubleTap; + final Widget? leading; + final Widget? trailing; + final String title; + final String subtitle; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('disabled', disabled)) + ..add(DiagnosticsProperty('selected', selected)) + ..add(DiagnosticsProperty('tileScale', tileScale)) + ..add(IterableProperty('endActions', endActions)) + ..add(IterableProperty('startActions', startActions)) + ..add(ObjectFlagProperty.has('onTap', onTap)) + ..add(DiagnosticsProperty('leading', leading)) + ..add(StringProperty('title', title)) + ..add(StringProperty('subtitle', subtitle)) + ..add(ObjectFlagProperty.has( + 'onDoubleTap', onDoubleTap)) + ..add(DiagnosticsProperty('trailing', trailing)); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scaleTheme = theme.extension()!; + final scaleTileTheme = scaleTheme.tileTheme( + disabled: disabled, selected: selected, scaleKind: tileScale); + + return Container( + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + color: scaleTileTheme.backgroundColor, + shape: scaleTileTheme.shapeBorder), + child: Slidable( + // Specify a key if the Slidable is dismissible. + key: key, + endActionPane: endActions.isEmpty + ? null + : ActionPane( + motion: const DrawerMotion(), + children: endActions.map((a) { + final scaleActionTheme = scaleTheme.tileTheme( + disabled: disabled, + selected: true, + scaleKind: a.actionScale); + return SlidableAction( + onPressed: disabled ? null : a.onPressed, + backgroundColor: scaleActionTheme.backgroundColor, + foregroundColor: scaleActionTheme.textColor, + icon: subtitle.isEmpty ? a.icon : null, + label: a.label, + padding: const EdgeInsets.all(2).scaled(context), + ); + }).toList()), + startActionPane: startActions.isEmpty + ? null + : ActionPane( + motion: const DrawerMotion(), + children: startActions.map((a) { + final scaleActionTheme = scaleTheme.tileTheme( + disabled: disabled, + selected: true, + scaleKind: a.actionScale); + + return SlidableAction( + onPressed: disabled ? null : a.onPressed, + backgroundColor: scaleActionTheme.backgroundColor, + foregroundColor: scaleActionTheme.textColor, + icon: subtitle.isEmpty ? a.icon : null, + label: a.label, + padding: const EdgeInsets.all(2).scaled(context), + ); + }).toList()), + child: Padding( + padding: scaleTheme.config.useVisualIndicators + ? EdgeInsets.zero + : const EdgeInsets.fromLTRB(0, 4, 0, 4).scaled(context), + child: GestureDetector( + onDoubleTap: onDoubleTap, + child: ListTile( + onTap: onTap, + dense: true, + title: Text( + title, + overflow: TextOverflow.fade, + softWrap: false, + ), + subtitle: subtitle.isNotEmpty ? Text(subtitle) : null, + contentPadding: const EdgeInsets.fromLTRB(8, 2, 8, 2) + .scaled(context), + iconColor: scaleTileTheme.textColor, + textColor: scaleTileTheme.textColor, + leading: + leading != null ? FittedBox(child: leading) : null, + trailing: trailing != null + ? FittedBox(child: trailing) + : null))))); + } +} diff --git a/lib/theme/views/styled_widgets/styled_slider.dart b/lib/theme/views/styled_widgets/styled_slider.dart new file mode 100644 index 0000000..a0c7259 --- /dev/null +++ b/lib/theme/views/styled_widgets/styled_slider.dart @@ -0,0 +1,79 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions_flutter.dart'; +import 'package:flutter/material.dart'; + +import '../../models/models.dart'; +import '../views.dart'; + +const _kStyledSliderChanged = 'kStyledSliderChanged'; + +class StyledSlider extends StatelessWidget { + const StyledSlider( + {required double value, + String? label, + String? decoratorLabel, + Future Function(double)? onChanged, + Widget? leftWidget, + Widget? rightWidget, + double min = 0, + double max = 1, + int? divisions, + super.key}) + : _value = value, + _onChanged = onChanged, + _leftWidget = leftWidget, + _rightWidget = rightWidget, + _min = min, + _max = max, + _divisions = divisions, + _label = label, + _decoratorLabel = decoratorLabel; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + + Widget ctrl = Row(children: [ + if (_leftWidget != null) _leftWidget, + Slider( + activeColor: scale.scheme.primaryScale.border, + inactiveColor: scale.scheme.primaryScale.subtleBorder, + secondaryActiveColor: scale.scheme.secondaryScale.border, + value: _value, + min: _min, + max: _max, + divisions: _divisions, + label: _label, + thumbColor: scale.scheme.primaryScale.appText, + overlayColor: + WidgetStateColor.resolveWith((ws) => theme.focusColor), + onChanged: _onChanged == null + ? null + : (value) { + singleFuture((this, _kStyledSliderChanged), () async { + await _onChanged(value); + }); + }) + .expanded(), + if (_rightWidget != null) _rightWidget, + ]); + if (_decoratorLabel != null) { + ctrl = ctrl + .paddingLTRB(4.scaled(context), 4.scaled(context), 4.scaled(context), + 4.scaled(context)) + .decoratorLabel(context, _decoratorLabel); + } + return ctrl; + } + + final String? _label; + final String? _decoratorLabel; + final Future Function(double)? _onChanged; + final double _value; + final Widget? _leftWidget; + final Widget? _rightWidget; + final double _min; + final double _max; + final int? _divisions; +} diff --git a/lib/theme/views/styled_widgets/styled_widgets.dart b/lib/theme/views/styled_widgets/styled_widgets.dart new file mode 100644 index 0000000..ae45d59 --- /dev/null +++ b/lib/theme/views/styled_widgets/styled_widgets.dart @@ -0,0 +1,8 @@ +export 'styled_alert.dart'; +export 'styled_avatar.dart'; +export 'styled_checkbox.dart'; +export 'styled_dialog.dart'; +export 'styled_dropdown.dart'; +export 'styled_scaffold.dart'; +export 'styled_slide_tile.dart'; +export 'styled_slider.dart'; diff --git a/lib/theme/views/views.dart b/lib/theme/views/views.dart index 6f6d7ac..b62c4bc 100644 --- a/lib/theme/views/views.dart +++ b/lib/theme/views/views.dart @@ -1,5 +1,9 @@ -export 'brightness_preferences.dart'; -export 'color_preferences.dart'; -export 'scanner_error_widget.dart'; -export 'styled_dialog.dart'; +export 'enter_password.dart'; +export 'enter_pin.dart'; +export 'pop_control.dart'; +export 'preferences/preferences.dart'; +export 'recovery_key_widget.dart'; +export 'responsive.dart'; +export 'styled_widgets/styled_button_box.dart'; +export 'styled_widgets/styled_widgets.dart'; export 'widget_helpers.dart'; diff --git a/lib/theme/views/widget_helpers.dart b/lib/theme/views/widget_helpers.dart index 52f26ac..2d8d626 100644 --- a/lib/theme/views/widget_helpers.dart +++ b/lib/theme/views/widget_helpers.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:async_tools/async_tools.dart'; import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; @@ -5,9 +7,9 @@ import 'package:blurry_modal_progress_hud/blurry_modal_progress_hud.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_spinkit/flutter_spinkit.dart'; +import 'package:flutter_sticky_header/flutter_sticky_header.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import 'package:motion_toast/motion_toast.dart'; -import 'package:quickalert/quickalert.dart'; +import 'package:sliver_expandable/sliver_expandable.dart'; import '../theme.dart'; @@ -17,6 +19,80 @@ extension BorderExt on Widget { child: this); } +extension ShadowExt on Widget { + Container withThemedShadow(ScaleConfig scaleConfig, ScaleColor scale) => + // ignore: use_decorated_box + Container( + decoration: BoxDecoration( + boxShadow: themedShadow(scaleConfig, scale), + ), + child: this, + ); +} + +List themedShadow(ScaleConfig scaleConfig, ScaleColor scale) => [ + if (scaleConfig.useVisualIndicators && !scaleConfig.preferBorders) + BoxShadow( + color: scale.primary.darken(60), + spreadRadius: 2, + ) + else if (scaleConfig.useVisualIndicators && scaleConfig.preferBorders) + BoxShadow( + color: scale.border, + spreadRadius: 2, + ) + else + BoxShadow( + color: scale.primary.darken(60).withAlpha(0x7F), + blurRadius: 16, + spreadRadius: 2, + offset: const Offset( + 0, + 2, + ), + ), + ]; + +extension SizeToFixExt on Widget { + FittedBox fit({BoxFit? fit, Key? key}) => FittedBox( + key: key, + fit: fit ?? BoxFit.scaleDown, + child: this, + ); +} + +extension FocusExt on Widget { + Focus focus( + {Key? key, + FocusNode? focusNode, + FocusNode? parentNode, + bool autofocus = false, + ValueChanged? onFocusChange, + FocusOnKeyEventCallback? onKeyEvent, + bool? canRequestFocus, + bool? skipTraversal, + bool? descendantsAreFocusable, + bool? descendantsAreTraversable, + bool includeSemantics = true, + String? debugLabel}) => + Focus( + key: key, + focusNode: focusNode, + parentNode: parentNode, + autofocus: autofocus, + onFocusChange: onFocusChange, + onKeyEvent: onKeyEvent, + canRequestFocus: canRequestFocus, + skipTraversal: skipTraversal, + descendantsAreFocusable: descendantsAreFocusable, + descendantsAreTraversable: descendantsAreTraversable, + includeSemantics: includeSemantics, + debugLabel: debugLabel, + child: this); + Focus onFocusChange(void Function(bool) onFocusChange) => + Focus(onFocusChange: onFocusChange, child: this); +} + extension ModalProgressExt on Widget { BlurryModalProgressHUD withModalHUD(BuildContext context, bool isLoading) { final theme = Theme.of(context); @@ -31,25 +107,95 @@ extension ModalProgressExt on Widget { } } +extension LabelExt on Widget { + Widget decoratorLabel(BuildContext context, String label, + {ScaleColor? scale}) { + final theme = Theme.of(context); + final scaleScheme = theme.extension()!; + final scaleConfig = theme.extension()!; + scale = scale ?? scaleScheme.primaryScale; + + final border = scale.border; + final disabledBorder = scaleScheme.grayScale.border; + final hoverBorder = scale.hoverBorder; + final focusedErrorBorder = scaleScheme.errorScale.border; + final errorBorder = scaleScheme.errorScale.primary; + OutlineInputBorder makeBorder(Color color) => OutlineInputBorder( + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale), + borderSide: BorderSide(color: color), + ); + OutlineInputBorder makeFocusedBorder(Color color) => OutlineInputBorder( + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale), + borderSide: BorderSide(width: 2, color: color), + ); + return InputDecorator( + decoration: InputDecoration( + labelText: label, + floatingLabelStyle: TextStyle(color: hoverBorder), + border: makeBorder(border), + enabledBorder: makeBorder(border), + disabledBorder: makeBorder(disabledBorder), + focusedBorder: makeFocusedBorder(hoverBorder), + errorBorder: makeBorder(errorBorder), + focusedErrorBorder: makeFocusedBorder(focusedErrorBorder), + ), + child: this); + } + + Widget noEditDecoratorLabel(BuildContext context, String label, + {ScaleColor? scale}) { + final theme = Theme.of(context); + final scaleScheme = theme.extension()!; + scale = scale ?? scaleScheme.primaryScale; + + return Wrap(crossAxisAlignment: WrapCrossAlignment.end, children: [ + Text( + '$label:', + style: theme.textTheme.bodyLarge!.copyWith(color: scale.hoverBorder), + ).paddingLTRB(0, 0, 8, 0), + this + ]); + } +} + Widget buildProgressIndicator() => Builder(builder: (context) { final theme = Theme.of(context); final scale = theme.extension()!; - return SpinKitFoldingCube( - color: scale.tertiaryScale.primary, - size: 80, - ); + return FittedBox( + fit: BoxFit.scaleDown, + child: SpinKitFoldingCube( + color: scale.tertiaryScale.border, + size: 80, + )); }); -Widget waitingPage({String? text}) => Builder(builder: (context) { +Widget waitingPage({String? text, void Function()? onCancel}) => + Builder(builder: (context) { final theme = Theme.of(context); final scale = theme.extension()!; return ColoredBox( color: scale.tertiaryScale.appBackground, - child: Center( - child: Column(children: [ - buildProgressIndicator().expanded(), - if (text != null) Text(text) - ]))); + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + buildProgressIndicator().paddingAll(24), + if (text != null) + Text(text, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall! + .copyWith(color: scale.tertiaryScale.appText)), + if (onCancel != null) + ElevatedButton( + onPressed: onCancel, + child: Text(translate('button.cancel'), + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall!.copyWith( + color: scale.tertiaryScale.appText))) + .alignAtCenter(), + ])); }); Widget debugPage(String text) => Builder( @@ -108,49 +254,175 @@ class AsyncBlocBuilder>, S> data: (d) => builder(context, d))); } -Future showErrorModal( - BuildContext context, String title, String text) async { - await QuickAlert.show( - context: context, - type: QuickAlertType.error, - title: title, - text: text, - //backgroundColor: Colors.black, - //titleColor: Colors.white, - //textColor: Colors.white, +SliverAppBar styledSliverAppBar( + {required BuildContext context, required String title, Color? titleColor}) { + final theme = Theme.of(context); + final scale = theme.extension()!; + //final scaleConfig = theme.extension()!; + final textTheme = theme.textTheme; + + return SliverAppBar( + title: Text( + title, + style: textTheme.titleSmall! + .copyWith(color: titleColor ?? scale.primaryScale.borderText), + ), + pinned: true, ); } -void showErrorToast(BuildContext context, String message) { - MotionToast.error( - title: Text(translate('toast.error')), - description: Text(message), - ).show(context); +Widget styledHeaderSliver( + {required BuildContext context, + required String title, + required Widget sliver, + Color? borderColor, + Color? innerColor, + Color? titleColor, + Color? backgroundColor, + void Function()? onTap}) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + final textTheme = theme.textTheme; + + return SliverStickyHeader( + header: ColoredBox( + color: backgroundColor ?? Colors.transparent, + child: DecoratedBox( + decoration: ShapeDecoration( + color: borderColor ?? scale.primaryScale.border, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: + Radius.circular(12 * scaleConfig.borderRadiusScale), + topRight: Radius.circular( + 12 * scaleConfig.borderRadiusScale)))), + child: ListTile( + onTap: onTap, + title: Text(title, + textAlign: TextAlign.center, + style: textTheme.titleSmall!.copyWith( + color: titleColor ?? scale.primaryScale.borderText)), + ), + )), + sliver: DecoratedSliver( + decoration: ShapeDecoration( + color: borderColor ?? scale.primaryScale.border, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + bottomLeft: + Radius.circular(8 * scaleConfig.borderRadiusScale), + bottomRight: + Radius.circular(8 * scaleConfig.borderRadiusScale)))), + sliver: SliverPadding( + padding: const EdgeInsets.all(4), + sliver: DecoratedSliver( + decoration: ShapeDecoration( + color: innerColor ?? scale.primaryScale.subtleBackground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 8 * scaleConfig.borderRadiusScale))), + sliver: SliverPadding( + padding: const EdgeInsets.all(8), + sliver: sliver, + )))), + ); } -void showInfoToast(BuildContext context, String message) { - MotionToast.info( - title: Text(translate('toast.info')), - description: Text(message), - ).show(context); +Widget styledExpandingSliver( + {required BuildContext context, + required String title, + required Widget sliver, + required bool expanded, + required Animation animation, + Color? borderColor, + Color? innerColor, + Color? titleColor, + Color? backgroundColor, + void Function()? onTap}) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + final textTheme = theme.textTheme; + + return SliverStickyHeader( + header: ColoredBox( + color: backgroundColor ?? Colors.transparent, + child: DecoratedBox( + decoration: ShapeDecoration( + color: borderColor ?? scale.primaryScale.border, + shape: RoundedRectangleBorder( + borderRadius: expanded + ? BorderRadius.only( + topLeft: Radius.circular( + 12 * scaleConfig.borderRadiusScale), + topRight: Radius.circular( + 12 * scaleConfig.borderRadiusScale)) + : BorderRadius.circular( + 12 * scaleConfig.borderRadiusScale))), + child: ListTile( + onTap: onTap, + title: Text(title, + textAlign: TextAlign.center, + style: textTheme.titleSmall!.copyWith( + color: titleColor ?? scale.primaryScale.borderText)), + trailing: AnimatedBuilder( + animation: animation, + builder: (context, child) => Transform.rotate( + angle: (animation.value - 0.5) * pi, + child: child, + ), + child: Icon(Icons.chevron_left, + color: borderColor ?? scale.primaryScale.borderText), + ), + ), + )), + sliver: SliverExpandable( + sliver: DecoratedSliver( + decoration: ShapeDecoration( + color: borderColor ?? scale.primaryScale.border, + shape: RoundedRectangleBorder( + borderRadius: expanded + ? BorderRadius.only( + bottomLeft: Radius.circular( + 8 * scaleConfig.borderRadiusScale), + bottomRight: Radius.circular( + 8 * scaleConfig.borderRadiusScale)) + : BorderRadius.circular( + 8 * scaleConfig.borderRadiusScale))), + sliver: SliverPadding( + padding: const EdgeInsets.all(4), + sliver: DecoratedSliver( + decoration: ShapeDecoration( + color: + innerColor ?? scale.primaryScale.subtleBackground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 8 * scaleConfig.borderRadiusScale))), + sliver: SliverPadding( + padding: const EdgeInsets.all(8), + sliver: sliver, + )))), + animation: animation, + )); } -// Widget insetBorder( -// {required BuildContext context, -// required bool enabled, -// required Color color, -// required Widget child}) { -// if (!enabled) { -// return child; -// } +Widget styledHeader({required BuildContext context, required Widget child}) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + // final textTheme = theme.textTheme; -// return Stack({ -// children: [] { -// DecoratedBox(decoration: BoxDecoration() -// child, -// } -// }) -// } + return DecoratedBox( + decoration: ShapeDecoration( + color: scale.primaryScale.border, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(12 * scaleConfig.borderRadiusScale), + topRight: + Radius.circular(12 * scaleConfig.borderRadiusScale)))), + child: child); +} Widget styledTitleContainer({ required BuildContext context, @@ -162,26 +434,29 @@ Widget styledTitleContainer({ }) { final theme = Theme.of(context); final scale = theme.extension()!; + final scaleConfig = theme.extension()!; final textTheme = theme.textTheme; return DecoratedBox( decoration: ShapeDecoration( color: borderColor ?? scale.primaryScale.border, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale), )), child: Column(children: [ Text( title, - style: textTheme.titleMedium! + style: textTheme.titleSmall! .copyWith(color: titleColor ?? scale.primaryScale.borderText), - ).paddingLTRB(8, 8, 8, 4), + ).paddingLTRB(8, 6, 8, 2), DecoratedBox( decoration: ShapeDecoration( color: backgroundColor ?? scale.primaryScale.subtleBackground, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular( + 8 * scaleConfig.borderRadiusScale), )), child: child) .paddingAll(4) @@ -189,6 +464,63 @@ Widget styledTitleContainer({ ])); } +Widget styledContainer({ + required BuildContext context, + required Widget child, + Color? borderColor, + Color? backgroundColor, +}) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + return DecoratedBox( + decoration: ShapeDecoration( + color: borderColor ?? scale.primaryScale.border, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale), + )), + child: Column(children: [ + DecoratedBox( + decoration: ShapeDecoration( + color: + backgroundColor ?? scale.primaryScale.subtleBackground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 8 * scaleConfig.borderRadiusScale), + )), + child: child) + .paddingAll(4) + .expanded() + ])); +} + +Widget styledCard({ + required BuildContext context, + required Widget child, + Color? borderColor, + Color? backgroundColor, + Color? titleColor, +}) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + return DecoratedBox( + decoration: ShapeDecoration( + color: backgroundColor ?? scale.primaryScale.elementBackground, + shape: RoundedRectangleBorder( + side: (scaleConfig.useVisualIndicators || scaleConfig.preferBorders) + ? BorderSide( + color: borderColor ?? scale.primaryScale.border, width: 2) + : BorderSide.none, + borderRadius: + BorderRadius.circular(12 * scaleConfig.borderRadiusScale), + )), + child: child.paddingAll(4)); +} + Widget styledBottomSheet({ required BuildContext context, required String title, @@ -199,15 +531,17 @@ Widget styledBottomSheet({ }) { final theme = Theme.of(context); final scale = theme.extension()!; + final scaleConfig = theme.extension()!; final textTheme = theme.textTheme; return DecoratedBox( decoration: ShapeDecoration( color: borderColor ?? scale.primaryScale.dialogBorder, - shape: const RoundedRectangleBorder( + shape: RoundedRectangleBorder( borderRadius: BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16)))), + topLeft: Radius.circular(16 * scaleConfig.borderRadiusScale), + topRight: + Radius.circular(16 * scaleConfig.borderRadiusScale)))), child: Column(mainAxisSize: MainAxisSize.min, children: [ Text( title, @@ -218,10 +552,12 @@ Widget styledBottomSheet({ decoration: ShapeDecoration( color: backgroundColor ?? scale.primaryScale.subtleBackground, - shape: const RoundedRectangleBorder( + shape: RoundedRectangleBorder( borderRadius: BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16)))), + topLeft: Radius.circular( + 16 * scaleConfig.borderRadiusScale), + topRight: Radius.circular( + 16 * scaleConfig.borderRadiusScale)))), child: child) .paddingLTRB(4, 4, 4, 0) ])); @@ -230,3 +566,58 @@ Widget styledBottomSheet({ bool get isPlatformDark => WidgetsBinding.instance.platformDispatcher.platformBrightness == Brightness.dark; + +const grayColorFilter = ColorFilter.matrix([ + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0, + 0, + 0, + 1, + 0, +]); + +const src96StencilFilter = + ColorFilter.mode(Color.fromARGB(96, 255, 255, 255), BlendMode.srcIn); + +const dst127StencilFilter = + ColorFilter.mode(Color.fromARGB(127, 255, 255, 255), BlendMode.dstIn); + +Container clipBorder({ + required bool clipEnabled, + required bool borderEnabled, + required double borderRadius, + required Color borderColor, + required Widget child, +}) => + // We want to return a container here + // ignore: avoid_unnecessary_containers, use_decorated_box + Container( + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: borderEnabled && clipEnabled + ? BorderSide(color: borderColor, width: 2) + : BorderSide.none, + borderRadius: clipEnabled + ? BorderRadius.circular(borderRadius) + : BorderRadius.zero, + )), + child: ClipRRect( + clipBehavior: Clip.antiAliasWithSaveLayer, + borderRadius: clipEnabled + ? BorderRadius.circular(borderRadius - 4) + : BorderRadius.zero, + child: child)); diff --git a/lib/tick.dart b/lib/tick.dart index 8ec3db7..21c18a3 100644 --- a/lib/tick.dart +++ b/lib/tick.dart @@ -28,11 +28,7 @@ class BackgroundTickerState extends State { @override void dispose() { - final tickTimer = _tickTimer; - if (tickTimer != null) { - tickTimer.cancel(); - } - + _tickTimer?.cancel(); super.dispose(); } diff --git a/lib/tools/exceptions.dart b/lib/tools/exceptions.dart new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lib/tools/exceptions.dart @@ -0,0 +1 @@ + diff --git a/lib/tools/loggy.dart b/lib/tools/loggy.dart index c422ec8..adc62d1 100644 --- a/lib/tools/loggy.dart +++ b/lib/tools/loggy.dart @@ -8,8 +8,8 @@ import 'package:intl/intl.dart'; import 'package:loggy/loggy.dart'; import 'package:veilid_support/veilid_support.dart'; +import '../proto/proto.dart'; import '../veilid_processor/views/developer.dart'; -import 'responsive.dart'; import 'state_logger.dart'; String wrapWithLogColor(LogLevel? level, String text) { @@ -111,14 +111,19 @@ class CallbackPrinter extends LoggyPrinter { @override void onLog(LogRecord record) { - final out = record.pretty(); - if (isDesktop) { + final out = record.pretty().replaceAll('\uFFFD', ''); + + if (!kIsWeb && Platform.isAndroid) { + debugPrint(out); + } else { debugPrintSynchronously(out); } globalDebugTerminal.write('$out\n'.replaceAll('\n', '\r\n')); callback?.call(record); } + // Change callback function + // ignore: use_setters_to_change_properties void setCallback(void Function(LogRecord)? cb) { callback = cb; } @@ -144,17 +149,24 @@ void initLoggy() { logOptions: getLogOptions(null), ); + // Allow trace logging from the command line // ignore: do_not_use_environment const isTrace = String.fromEnvironment('LOG_TRACE') != ''; LogLevel logLevel; if (isTrace) { logLevel = traceLevel; } else { - logLevel = kDebugMode ? LogLevel.debug : LogLevel.info; + logLevel = kIsDebugMode ? LogLevel.debug : LogLevel.info; } Loggy('').level = getLogOptions(logLevel); // Create state logger - Bloc.observer = const StateLogger(); + registerVeilidProtoToDebug(); + registerVeilidDHTProtoToDebug(); + registerVeilidchatProtoToDebug(); + + if (kIsDebugMode) { + Bloc.observer = const StateLogger(); + } } diff --git a/lib/tools/package_info.dart b/lib/tools/package_info.dart new file mode 100644 index 0000000..7acd109 --- /dev/null +++ b/lib/tools/package_info.dart @@ -0,0 +1,14 @@ +import 'package:package_info_plus/package_info_plus.dart'; + +String packageInfoAppName = ''; +String packageInfoPackageName = ''; +String packageInfoVersion = ''; +String packageInfoBuildNumber = ''; + +Future initPackageInfo() async { + final packageInfo = await PackageInfo.fromPlatform(); + packageInfoAppName = packageInfo.appName; + packageInfoPackageName = packageInfo.packageName; + packageInfoVersion = packageInfo.version; + packageInfoBuildNumber = packageInfo.buildNumber; +} diff --git a/lib/tools/responsive.dart b/lib/tools/responsive.dart deleted file mode 100644 index 4ee206b..0000000 --- a/lib/tools/responsive.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -bool get isAndroid => !kIsWeb && Platform.isAndroid; -bool get isiOS => !kIsWeb && Platform.isIOS; -bool get isWeb => kIsWeb; -bool get isDesktop => - !isWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS); - -const kMobileWidthCutoff = 479.0; - -bool isMobileWidth(BuildContext context) => - MediaQuery.of(context).size.width < kMobileWidthCutoff; - -bool responsiveVisibility({ - required BuildContext context, - bool phone = true, - bool tablet = true, - bool tabletLandscape = true, - bool desktop = true, -}) { - final width = MediaQuery.of(context).size.width; - if (width < kMobileWidthCutoff) { - return phone; - } else if (width < 767) { - return tablet; - } else if (width < 991) { - return tabletLandscape; - } else { - return desktop; - } -} diff --git a/lib/tools/state_logger.dart b/lib/tools/state_logger.dart index 08e32b3..8782662 100644 --- a/lib/tools/state_logger.dart +++ b/lib/tools/state_logger.dart @@ -1,16 +1,23 @@ +import 'dart:convert'; + import 'package:bloc/bloc.dart'; import 'package:loggy/loggy.dart'; +import 'package:veilid_support/veilid_support.dart'; import 'loggy.dart'; const Map _blocChangeLogLevels = { - 'ConnectionStateCubit': LogLevel.off, - 'ActiveSingleContactChatBlocMapCubit': LogLevel.off, - 'ActiveConversationsBlocMapCubit': LogLevel.off, - 'PersistentQueueCubit': LogLevel.off, - 'TableDBArrayProtobufCubit': LogLevel.off, - 'DHTLogCubit': LogLevel.off, - 'SingleContactMessagesCubit': LogLevel.off, - 'ChatComponentCubit': LogLevel.off, + 'RouterCubit': LogLevel.debug, + 'PerAccountCollectionBlocMapCubit': LogLevel.debug, + 'PerAccountCollectionCubit': LogLevel.debug, + 'ActiveChatCubit': LogLevel.debug, + 'AccountRecordCubit': LogLevel.debug, + 'ContactListCubit': LogLevel.debug, + 'ContactInvitationListCubit': LogLevel.debug, + 'ChatListCubit': LogLevel.debug, + 'PreferencesCubit': LogLevel.debug, + 'ConversationCubit': LogLevel.debug, + 'DefaultDHTRecordCubit': LogLevel.debug, + 'WaitingInvitationCubit': LogLevel.debug, }; const Map _blocCreateCloseLogLevels = {}; @@ -37,8 +44,13 @@ class StateLogger extends BlocObserver { @override void onChange(BlocBase bloc, Change change) { super.onChange(bloc, change); - _checkLogLevel(_blocChangeLogLevels, LogLevel.debug, bloc, (logLevel) { - log.log(logLevel, 'Change: ${bloc.runtimeType} $change'); + _checkLogLevel(_blocChangeLogLevels, LogLevel.off, bloc, (logLevel) { + const encoder = JsonEncoder.withIndent(' ', DynamicDebug.toDebug); + log.log( + logLevel, + 'Change: ${bloc.runtimeType}\n' + 'currentState: ${encoder.convert(change.currentState)}\n' + 'nextState: ${encoder.convert(change.nextState)}\n'); }); } diff --git a/lib/tools/tools.dart b/lib/tools/tools.dart index 6b48001..470b648 100644 --- a/lib/tools/tools.dart +++ b/lib/tools/tools.dart @@ -1,11 +1,9 @@ export 'animations.dart'; -export 'enter_password.dart'; -export 'enter_pin.dart'; +export 'exceptions.dart'; export 'loggy.dart'; export 'misc.dart'; +export 'package_info.dart'; export 'phono_byte.dart'; -export 'pop_control.dart'; -export 'responsive.dart'; export 'shared_preferences.dart'; export 'state_logger.dart'; export 'stream_listenable.dart'; diff --git a/lib/tools/window_control.dart b/lib/tools/window_control.dart index c6e33d3..32c05ff 100644 --- a/lib/tools/window_control.dart +++ b/lib/tools/window_control.dart @@ -1,10 +1,12 @@ import 'dart:async'; +import 'package:async_tools/async_tools.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:window_manager/window_manager.dart'; -import '../../tools/responsive.dart'; +import '../theme/views/responsive.dart'; export 'package:window_manager/window_manager.dart' show TitleBarStyle; @@ -21,13 +23,13 @@ Future initializeWindowControl() async { const windowOptions = WindowOptions( size: Size(768, 1024), - //minimumSize: Size(480, 480), + minimumSize: Size(400, 500), center: true, backgroundColor: Colors.transparent, skipTaskbar: false, ); await windowManager.waitUntilReadyToShow(windowOptions, () async { - await changeWindowSetup( + await _asyncChangeWindowSetup( TitleBarStyle.hidden, OrientationCapability.normal); await windowManager.show(); await windowManager.focus(); @@ -35,7 +37,9 @@ Future initializeWindowControl() async { } } -Future changeWindowSetup(TitleBarStyle titleBarStyle, +const kWindowSetup = '__windowSetup'; + +Future _asyncChangeWindowSetup(TitleBarStyle titleBarStyle, OrientationCapability orientationCapability) async { if (isDesktop) { await windowManager.setTitleBarStyle(titleBarStyle); @@ -59,3 +63,47 @@ Future changeWindowSetup(TitleBarStyle titleBarStyle, } } } + +void changeWindowSetup( + TitleBarStyle titleBarStyle, OrientationCapability orientationCapability) { + singleFuture( + kWindowSetup, + () async => + _asyncChangeWindowSetup(titleBarStyle, orientationCapability)); +} + +abstract class WindowSetupState extends State { + WindowSetupState( + {required this.titleBarStyle, required this.orientationCapability}); + + @override + void initState() { + changeWindowSetup(this.titleBarStyle, this.orientationCapability); + super.initState(); + } + + @override + void activate() { + changeWindowSetup(this.titleBarStyle, this.orientationCapability); + super.activate(); + } + + @override + void deactivate() { + changeWindowSetup(TitleBarStyle.normal, OrientationCapability.normal); + super.deactivate(); + } + + //////////////////////////////////////////////////////////////////////////// + final TitleBarStyle titleBarStyle; + final OrientationCapability orientationCapability; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(EnumProperty('titleBarStyle', titleBarStyle)) + ..add(EnumProperty( + 'orientationCapability', orientationCapability)); + } +} diff --git a/lib/veilid_processor/models/processor_connection_state.dart b/lib/veilid_processor/models/processor_connection_state.dart index e92ebdc..6b68a8e 100644 --- a/lib/veilid_processor/models/processor_connection_state.dart +++ b/lib/veilid_processor/models/processor_connection_state.dart @@ -4,7 +4,7 @@ import 'package:veilid_support/veilid_support.dart'; part 'processor_connection_state.freezed.dart'; @freezed -class ProcessorConnectionState with _$ProcessorConnectionState { +sealed class ProcessorConnectionState with _$ProcessorConnectionState { const factory ProcessorConnectionState({ required VeilidStateAttachment attachment, required VeilidStateNetwork network, diff --git a/lib/veilid_processor/models/processor_connection_state.freezed.dart b/lib/veilid_processor/models/processor_connection_state.freezed.dart index d857318..c7c5288 100644 --- a/lib/veilid_processor/models/processor_connection_state.freezed.dart +++ b/lib/veilid_processor/models/processor_connection_state.freezed.dart @@ -1,3 +1,4 @@ +// dart format width=80 // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint @@ -9,147 +10,27 @@ part of 'processor_connection_state.dart'; // FreezedGenerator // ************************************************************************** +// dart format off T _$identity(T value) => value; -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - /// @nodoc mixin _$ProcessorConnectionState { - VeilidStateAttachment get attachment => throw _privateConstructorUsedError; - VeilidStateNetwork get network => throw _privateConstructorUsedError; + VeilidStateAttachment get attachment; + VeilidStateNetwork get network; - @JsonKey(ignore: true) + /// Create a copy of ProcessorConnectionState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') $ProcessorConnectionStateCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $ProcessorConnectionStateCopyWith<$Res> { - factory $ProcessorConnectionStateCopyWith(ProcessorConnectionState value, - $Res Function(ProcessorConnectionState) then) = - _$ProcessorConnectionStateCopyWithImpl<$Res, ProcessorConnectionState>; - @useResult - $Res call({VeilidStateAttachment attachment, VeilidStateNetwork network}); - - $VeilidStateAttachmentCopyWith<$Res> get attachment; - $VeilidStateNetworkCopyWith<$Res> get network; -} - -/// @nodoc -class _$ProcessorConnectionStateCopyWithImpl<$Res, - $Val extends ProcessorConnectionState> - implements $ProcessorConnectionStateCopyWith<$Res> { - _$ProcessorConnectionStateCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? attachment = null, - Object? network = null, - }) { - return _then(_value.copyWith( - attachment: null == attachment - ? _value.attachment - : attachment // ignore: cast_nullable_to_non_nullable - as VeilidStateAttachment, - network: null == network - ? _value.network - : network // ignore: cast_nullable_to_non_nullable - as VeilidStateNetwork, - ) as $Val); - } - - @override - @pragma('vm:prefer-inline') - $VeilidStateAttachmentCopyWith<$Res> get attachment { - return $VeilidStateAttachmentCopyWith<$Res>(_value.attachment, (value) { - return _then(_value.copyWith(attachment: value) as $Val); - }); - } - - @override - @pragma('vm:prefer-inline') - $VeilidStateNetworkCopyWith<$Res> get network { - return $VeilidStateNetworkCopyWith<$Res>(_value.network, (value) { - return _then(_value.copyWith(network: value) as $Val); - }); - } -} - -/// @nodoc -abstract class _$$ProcessorConnectionStateImplCopyWith<$Res> - implements $ProcessorConnectionStateCopyWith<$Res> { - factory _$$ProcessorConnectionStateImplCopyWith( - _$ProcessorConnectionStateImpl value, - $Res Function(_$ProcessorConnectionStateImpl) then) = - __$$ProcessorConnectionStateImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({VeilidStateAttachment attachment, VeilidStateNetwork network}); - - @override - $VeilidStateAttachmentCopyWith<$Res> get attachment; - @override - $VeilidStateNetworkCopyWith<$Res> get network; -} - -/// @nodoc -class __$$ProcessorConnectionStateImplCopyWithImpl<$Res> - extends _$ProcessorConnectionStateCopyWithImpl<$Res, - _$ProcessorConnectionStateImpl> - implements _$$ProcessorConnectionStateImplCopyWith<$Res> { - __$$ProcessorConnectionStateImplCopyWithImpl( - _$ProcessorConnectionStateImpl _value, - $Res Function(_$ProcessorConnectionStateImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? attachment = null, - Object? network = null, - }) { - return _then(_$ProcessorConnectionStateImpl( - attachment: null == attachment - ? _value.attachment - : attachment // ignore: cast_nullable_to_non_nullable - as VeilidStateAttachment, - network: null == network - ? _value.network - : network // ignore: cast_nullable_to_non_nullable - as VeilidStateNetwork, - )); - } -} - -/// @nodoc - -class _$ProcessorConnectionStateImpl extends _ProcessorConnectionState { - const _$ProcessorConnectionStateImpl( - {required this.attachment, required this.network}) - : super._(); - - @override - final VeilidStateAttachment attachment; - @override - final VeilidStateNetwork network; - - @override - String toString() { - return 'ProcessorConnectionState(attachment: $attachment, network: $network)'; - } + _$ProcessorConnectionStateCopyWithImpl( + this as ProcessorConnectionState, _$identity); @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$ProcessorConnectionStateImpl && + other is ProcessorConnectionState && (identical(other.attachment, attachment) || other.attachment == attachment) && (identical(other.network, network) || other.network == network)); @@ -158,27 +39,176 @@ class _$ProcessorConnectionStateImpl extends _ProcessorConnectionState { @override int get hashCode => Object.hash(runtimeType, attachment, network); - @JsonKey(ignore: true) + @override + String toString() { + return 'ProcessorConnectionState(attachment: $attachment, network: $network)'; + } +} + +/// @nodoc +abstract mixin class $ProcessorConnectionStateCopyWith<$Res> { + factory $ProcessorConnectionStateCopyWith(ProcessorConnectionState value, + $Res Function(ProcessorConnectionState) _then) = + _$ProcessorConnectionStateCopyWithImpl; + @useResult + $Res call({VeilidStateAttachment attachment, VeilidStateNetwork network}); + + $VeilidStateAttachmentCopyWith<$Res> get attachment; + $VeilidStateNetworkCopyWith<$Res> get network; +} + +/// @nodoc +class _$ProcessorConnectionStateCopyWithImpl<$Res> + implements $ProcessorConnectionStateCopyWith<$Res> { + _$ProcessorConnectionStateCopyWithImpl(this._self, this._then); + + final ProcessorConnectionState _self; + final $Res Function(ProcessorConnectionState) _then; + + /// Create a copy of ProcessorConnectionState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? attachment = null, + Object? network = null, + }) { + return _then(_self.copyWith( + attachment: null == attachment + ? _self.attachment + : attachment // ignore: cast_nullable_to_non_nullable + as VeilidStateAttachment, + network: null == network + ? _self.network + : network // ignore: cast_nullable_to_non_nullable + as VeilidStateNetwork, + )); + } + + /// Create a copy of ProcessorConnectionState + /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') - _$$ProcessorConnectionStateImplCopyWith<_$ProcessorConnectionStateImpl> - get copyWith => __$$ProcessorConnectionStateImplCopyWithImpl< - _$ProcessorConnectionStateImpl>(this, _$identity); + $VeilidStateAttachmentCopyWith<$Res> get attachment { + return $VeilidStateAttachmentCopyWith<$Res>(_self.attachment, (value) { + return _then(_self.copyWith(attachment: value)); + }); + } + + /// Create a copy of ProcessorConnectionState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $VeilidStateNetworkCopyWith<$Res> get network { + return $VeilidStateNetworkCopyWith<$Res>(_self.network, (value) { + return _then(_self.copyWith(network: value)); + }); + } } -abstract class _ProcessorConnectionState extends ProcessorConnectionState { - const factory _ProcessorConnectionState( - {required final VeilidStateAttachment attachment, - required final VeilidStateNetwork network}) = - _$ProcessorConnectionStateImpl; - const _ProcessorConnectionState._() : super._(); +/// @nodoc + +class _ProcessorConnectionState extends ProcessorConnectionState { + const _ProcessorConnectionState( + {required this.attachment, required this.network}) + : super._(); @override - VeilidStateAttachment get attachment; + final VeilidStateAttachment attachment; @override - VeilidStateNetwork get network; + final VeilidStateNetwork network; + + /// Create a copy of ProcessorConnectionState + /// with the given fields replaced by the non-null parameter values. @override - @JsonKey(ignore: true) - _$$ProcessorConnectionStateImplCopyWith<_$ProcessorConnectionStateImpl> - get copyWith => throw _privateConstructorUsedError; + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$ProcessorConnectionStateCopyWith<_ProcessorConnectionState> get copyWith => + __$ProcessorConnectionStateCopyWithImpl<_ProcessorConnectionState>( + this, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _ProcessorConnectionState && + (identical(other.attachment, attachment) || + other.attachment == attachment) && + (identical(other.network, network) || other.network == network)); + } + + @override + int get hashCode => Object.hash(runtimeType, attachment, network); + + @override + String toString() { + return 'ProcessorConnectionState(attachment: $attachment, network: $network)'; + } } + +/// @nodoc +abstract mixin class _$ProcessorConnectionStateCopyWith<$Res> + implements $ProcessorConnectionStateCopyWith<$Res> { + factory _$ProcessorConnectionStateCopyWith(_ProcessorConnectionState value, + $Res Function(_ProcessorConnectionState) _then) = + __$ProcessorConnectionStateCopyWithImpl; + @override + @useResult + $Res call({VeilidStateAttachment attachment, VeilidStateNetwork network}); + + @override + $VeilidStateAttachmentCopyWith<$Res> get attachment; + @override + $VeilidStateNetworkCopyWith<$Res> get network; +} + +/// @nodoc +class __$ProcessorConnectionStateCopyWithImpl<$Res> + implements _$ProcessorConnectionStateCopyWith<$Res> { + __$ProcessorConnectionStateCopyWithImpl(this._self, this._then); + + final _ProcessorConnectionState _self; + final $Res Function(_ProcessorConnectionState) _then; + + /// Create a copy of ProcessorConnectionState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? attachment = null, + Object? network = null, + }) { + return _then(_ProcessorConnectionState( + attachment: null == attachment + ? _self.attachment + : attachment // ignore: cast_nullable_to_non_nullable + as VeilidStateAttachment, + network: null == network + ? _self.network + : network // ignore: cast_nullable_to_non_nullable + as VeilidStateNetwork, + )); + } + + /// Create a copy of ProcessorConnectionState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $VeilidStateAttachmentCopyWith<$Res> get attachment { + return $VeilidStateAttachmentCopyWith<$Res>(_self.attachment, (value) { + return _then(_self.copyWith(attachment: value)); + }); + } + + /// Create a copy of ProcessorConnectionState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $VeilidStateNetworkCopyWith<$Res> get network { + return $VeilidStateNetworkCopyWith<$Res>(_self.network, (value) { + return _then(_self.copyWith(network: value)); + }); + } +} + +// dart format on diff --git a/lib/veilid_processor/repository/processor_repository.dart b/lib/veilid_processor/repository/processor_repository.dart index e021648..a92347c 100644 --- a/lib/veilid_processor/repository/processor_repository.dart +++ b/lib/veilid_processor/repository/processor_repository.dart @@ -12,10 +12,12 @@ class ProcessorRepository { : startedUp = false, _controllerConnectionState = StreamController.broadcast(sync: true), processorConnectionState = ProcessorConnectionState( - attachment: const VeilidStateAttachment( + attachment: VeilidStateAttachment( state: AttachmentState.detached, publicInternetReady: false, - localNetworkReady: false), + localNetworkReady: false, + uptime: TimestampDuration(value: BigInt.zero), + attachedUptime: null), network: VeilidStateNetwork( started: false, bpsDown: BigInt.zero, @@ -42,15 +44,21 @@ class ProcessorRepository { log.info('Veilid version: $veilidVersion'); - // HACK: In case of hot restart shut down first + Stream updateStream; + try { - await Veilid.instance.shutdownVeilidCore(); - } on Exception { - // Do nothing on failure here + log.debug('Starting VeilidCore'); + updateStream = await Veilid.instance + .startupVeilidCore(await getVeilidConfig(kIsWeb, VeilidChatApp.name)); + } on VeilidAPIExceptionAlreadyInitialized catch (_) { + log.debug( + 'VeilidCore is already started, shutting down and restarting...'); + startedUp = true; + await shutdown(); + updateStream = await Veilid.instance + .startupVeilidCore(await getVeilidConfig(kIsWeb, VeilidChatApp.name)); } - final updateStream = await Veilid.instance - .startupVeilidCore(await getVeilidConfig(kIsWeb, VeilidChatApp.name)); _updateSubscription = updateStream.listen((update) { if (update is VeilidLog) { processLog(update); @@ -96,7 +104,9 @@ class ProcessorRepository { attachment: VeilidStateAttachment( state: updateAttachment.state, publicInternetReady: updateAttachment.publicInternetReady, - localNetworkReady: updateAttachment.localNetworkReady)); + localNetworkReady: updateAttachment.localNetworkReady, + uptime: updateAttachment.uptime, + attachedUptime: updateAttachment.attachedUptime)); } void processUpdateConfig(VeilidUpdateConfig updateConfig) { diff --git a/lib/veilid_processor/views/developer.dart b/lib/veilid_processor/views/developer.dart index 8ca539a..c03b6bf 100644 --- a/lib/veilid_processor/views/developer.dart +++ b/lib/veilid_processor/views/developer.dart @@ -1,24 +1,27 @@ +import 'dart:async'; + +import 'package:animated_custom_dropdown/custom_dropdown.dart'; import 'package:ansicolor/ansicolor.dart'; import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:cool_dropdown/cool_dropdown.dart'; -import 'package:cool_dropdown/models/cool_dropdown_item.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:go_router/go_router.dart'; import 'package:loggy/loggy.dart'; -import 'package:quickalert/quickalert.dart'; import 'package:veilid_support/veilid_support.dart'; import 'package:xterm/xterm.dart'; import '../../layout/layout.dart'; +import '../../notifications/notifications.dart'; +import '../../theme/models/scale_theme/scale_custom_dropdown_theme.dart'; import '../../theme/theme.dart'; import '../../tools/tools.dart'; +import 'history_text_editing_controller.dart'; final globalDebugTerminal = Terminal( - maxLines: 50000, + maxLines: 10000, ); const kDefaultTerminalStyle = TerminalStyle( @@ -26,6 +29,15 @@ const kDefaultTerminalStyle = TerminalStyle( // height: 1.2, fontFamily: 'Source Code Pro'); +class LogLevelDropdownItem { + const LogLevelDropdownItem( + {required this.label, required this.icon, required this.value}); + + final String label; + final Widget icon; + final LogLevel value; +} + class DeveloperPage extends StatefulWidget { const DeveloperPage({super.key}); @@ -34,28 +46,18 @@ class DeveloperPage extends StatefulWidget { } class _DeveloperPageState extends State { - final _terminalController = TerminalController(); - final _debugCommandController = TextEditingController(); - final _logLevelController = DropdownController(duration: 250.ms); - final List> _logLevelDropdownItems = []; - var _logLevelDropDown = log.level.logLevel; - var _showEllet = false; - @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) async { - await changeWindowSetup( - TitleBarStyle.normal, OrientationCapability.normal); - }); + _historyController = HistoryTextEditingController(setState: setState); _terminalController.addListener(() { setState(() {}); }); for (var i = 0; i < logLevels.length; i++) { - _logLevelDropdownItems.add(CoolDropdownItem( + _logLevelDropdownItems.add(LogLevelDropdownItem( label: logLevelName(logLevels[i]), icon: Text(logLevelEmoji(logLevels[i])), value: logLevels[i])); @@ -63,55 +65,112 @@ class _DeveloperPageState extends State { } void _debugOut(String out) { + final sanitizedOut = out.replaceAll('\uFFFD', ''); final pen = AnsiPen()..cyan(bold: true); - final colorOut = pen(out); + final colorOut = pen(sanitizedOut); debugPrint(colorOut); globalDebugTerminal.write(colorOut.replaceAll('\n', '\r\n')); } - Future _sendDebugCommand(String debugCommand) async { - if (debugCommand == 'pool allocations') { - DHTRecordPool.instance.debugPrintAllocations(); - return; - } - - if (debugCommand == 'pool opened') { - DHTRecordPool.instance.debugPrintOpened(); - return; - } - - if (debugCommand.startsWith('change_log_ignore ')) { - final args = debugCommand.split(' '); - if (args.length < 3) { - _debugOut('Incorrect number of arguments'); - return; - } - final layer = args[1]; - final changes = args[2].split(','); - Veilid.instance.changeLogIgnore(layer, changes); - return; - } - - if (debugCommand == 'ellet') { - setState(() { - _showEllet = !_showEllet; - }); - return; - } - - _debugOut('DEBUG >>>\n$debugCommand\n'); + Future _sendDebugCommand(String debugCommand) async { try { - final out = await Veilid.instance.debug(debugCommand); - _debugOut('<<< DEBUG\n$out\n'); - } on Exception catch (e, st) { - _debugOut('<<< ERROR\n$e\n<<< STACK\n$st'); + setState(() { + _busy = true; + }); + + if (debugCommand == 'pool allocations') { + try { + DHTRecordPool.instance.debugPrintAllocations(); + } on Exception catch (e, st) { + _debugOut('<<< ERROR\n$e\n<<< STACK\n$st'); + return false; + } + return true; + } + + if (debugCommand == 'pool opened') { + try { + DHTRecordPool.instance.debugPrintOpened(); + } on Exception catch (e, st) { + _debugOut('<<< ERROR\n$e\n<<< STACK\n$st'); + return false; + } + return true; + } + + if (debugCommand == 'pool stats') { + try { + DHTRecordPool.instance.debugPrintStats(); + } on Exception catch (e, st) { + _debugOut('<<< ERROR\n$e\n<<< STACK\n$st'); + return false; + } + return true; + } + + if (debugCommand.startsWith('change_log_ignore ')) { + final args = debugCommand.split(' '); + if (args.length < 3) { + _debugOut('Incorrect number of arguments'); + return false; + } + final layer = args[1]; + final changes = args[2].split(','); + try { + Veilid.instance.changeLogIgnore(layer, changes); + } on Exception catch (e, st) { + _debugOut('<<< ERROR\n$e\n<<< STACK\n$st'); + return false; + } + + return true; + } + + if (debugCommand == 'ellet') { + setState(() { + _showEllet = !_showEllet; + }); + return true; + } + + _debugOut('DEBUG >>>\n$debugCommand\n'); + try { + var out = await Veilid.instance.debug(debugCommand); + + if (debugCommand == 'help') { + out = 'VeilidChat Commands:\n' + ' pool \n' + ' allocations - List DHTRecordPool allocations\n' + ' opened - List opened DHTRecord instances\n' + ' stats - Dump DHTRecordPool statistics\n' + ' change_log_ignore change the log' + ' target ignore list for a tracing layer\n' + ' targets to add to the ignore list can be separated by' + ' a comma.\n' + ' to remove a target from the ignore list, prepend it' + ' with a minus.\n\n$out'; + } + + _debugOut('<<< DEBUG\n$out\n'); + } on Exception catch (e, st) { + _debugOut('<<< ERROR\n$e\n<<< STACK\n$st'); + return false; + } + + return true; + } finally { + setState(() { + _busy = false; + }); } } Future clear(BuildContext context) async { globalDebugTerminal.buffer.clear(); if (context.mounted) { - showInfoToast(context, translate('developer.cleared')); + context + .read() + .info(text: translate('developer.cleared')); } } @@ -122,177 +181,208 @@ class _DeveloperPageState extends State { _terminalController.clearSelection(); await Clipboard.setData(ClipboardData(text: text)); if (context.mounted) { - showInfoToast(context, translate('developer.copied')); + context + .read() + .info(text: translate('developer.copied')); } } } + Future copyAll(BuildContext context) async { + final text = globalDebugTerminal.buffer.getText(); + await Clipboard.setData(ClipboardData(text: text)); + if (context.mounted) { + context + .read() + .info(text: translate('developer.copied_all')); + } + } + + Future _onSubmitCommand(String debugCommand) async { + final ok = await _sendDebugCommand(debugCommand); + if (ok) { + setState(() { + _historyController.submit(debugCommand); + }); + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); - final textTheme = theme.textTheme; final scale = theme.extension()!; + final scaleTheme = theme.extension()!; + final dropdownTheme = scaleTheme.customDropdownTheme(); + final scaleConfig = theme.extension()!; - // WidgetsBinding.instance.addPostFrameCallback((_) { - // if (!_isScrolling && _wantsBottom) { - // _scrollToBottom(); - // } - // }); + final hintColor = scaleConfig.useVisualIndicators + ? scale.primaryScale.primaryText + : scale.primaryScale.primary; return Scaffold( - backgroundColor: scale.primaryScale.primary, + backgroundColor: scale.primaryScale.border, appBar: DefaultAppBar( + context: context, title: Text(translate('developer.title')), leading: IconButton( - icon: Icon(Icons.arrow_back, color: scale.primaryScale.primaryText), + iconSize: 24.scaled(context), + icon: Icon(Icons.arrow_back, color: scale.primaryScale.borderText), onPressed: () => GoRouterHelper(context).pop(), ), actions: [ IconButton( + iconSize: 24.scaled(context), icon: const Icon(Icons.copy), - color: scale.primaryScale.primaryText, - disabledColor: scale.primaryScale.primaryText.withAlpha(0x3F), + color: scale.primaryScale.borderText, + disabledColor: scale.primaryScale.borderText.withAlpha(0x3F), onPressed: _terminalController.selection == null ? null : () async { await copySelection(context); }), IconButton( - icon: const Icon(Icons.clear_all), - color: scale.primaryScale.primaryText, - disabledColor: scale.primaryScale.primaryText.withAlpha(0x3F), + iconSize: 24.scaled(context), + icon: const Icon(Icons.copy_all), + color: scale.primaryScale.borderText, + disabledColor: scale.primaryScale.borderText.withAlpha(0x3F), onPressed: () async { - await QuickAlert.show( - context: context, - type: QuickAlertType.confirm, - title: translate('developer.are_you_sure_clear'), - titleColor: scale.primaryScale.appText, - textColor: scale.primaryScale.subtleText, - confirmBtnColor: scale.primaryScale.primary, - cancelBtnTextStyle: TextStyle( - fontWeight: FontWeight.w600, - fontSize: 18, - color: scale.primaryScale.appText), - backgroundColor: scale.primaryScale.appBackground, - headerBackgroundColor: scale.primaryScale.primary, - confirmBtnText: translate('button.ok'), - cancelBtnText: translate('button.cancel'), - onConfirmBtnTap: () async { - Navigator.pop(context); - if (context.mounted) { - await clear(context); - } - }); + await copyAll(context); }), - CoolDropdown( - controller: _logLevelController, - defaultItem: _logLevelDropdownItems - .singleWhere((x) => x.value == _logLevelDropDown), - onChange: (value) { - setState(() { - _logLevelDropDown = value; - Loggy('').level = getLogOptions(value); - setVeilidLogLevel(value); - _logLevelController.close(); - }); - }, - resultOptions: ResultOptions( - width: 64, - height: 40, - render: ResultRender.icon, - icon: SizedBox( - width: 10, - height: 10, - child: CustomPaint( - painter: DropdownArrowPainter( - color: scale.primaryScale.primaryText))), - textStyle: textTheme.labelMedium! - .copyWith(color: scale.primaryScale.primaryText), - padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), - openBoxDecoration: BoxDecoration( - color: scale.primaryScale.border, - borderRadius: BorderRadius.circular(8), - ), - boxDecoration: BoxDecoration( - color: scale.primaryScale.hoverBorder, - borderRadius: BorderRadius.circular(8), - ), - ), - dropdownOptions: DropdownOptions( - width: 160, - align: DropdownAlign.right, - duration: 150.ms, - color: scale.primaryScale.elementBackground, - borderSide: BorderSide(color: scale.primaryScale.border), - borderRadius: BorderRadius.circular(8), - padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), - ), - dropdownTriangleOptions: const DropdownTriangleOptions( - align: DropdownTriangleAlign.right), - dropdownItemOptions: DropdownItemOptions( - selectedTextStyle: textTheme.labelMedium! - .copyWith(color: scale.primaryScale.appText), - textStyle: textTheme.labelMedium! - .copyWith(color: scale.primaryScale.appText), - selectedBoxDecoration: BoxDecoration( - color: scale.primaryScale.activeElementBackground), - mainAxisAlignment: MainAxisAlignment.spaceBetween, - padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), - selectedPadding: const EdgeInsets.fromLTRB(8, 4, 8, 4)), - dropdownList: _logLevelDropdownItems, - ).paddingLTRB(0, 0, 8, 0) + IconButton( + iconSize: 24.scaled(context), + icon: const Icon(Icons.clear_all), + color: scale.primaryScale.borderText, + disabledColor: scale.primaryScale.borderText.withAlpha(0x3F), + onPressed: () async { + final confirm = await showConfirmModal( + context: context, + title: translate('confirmation.confirm'), + text: translate('developer.are_you_sure_clear'), + ); + if (confirm && context.mounted) { + await clear(context); + } + }), + SizedBox.fromSize( + size: Size(140.scaled(context), 48), + child: CustomDropdown( + items: _logLevelDropdownItems, + initialItem: _logLevelDropdownItems + .singleWhere((x) => x.value == _logLevelDropDown), + onChanged: (item) { + if (item != null) { + setState(() { + _logLevelDropDown = item.value; + Loggy('').level = getLogOptions(item.value); + setVeilidLogLevel(item.value); + }); + } + }, + headerBuilder: (context, item, enabled) => Row(children: [ + item.icon, + const Spacer(), + Text(item.label).copyWith(style: dropdownTheme.textStyle) + ]), + listItemBuilder: (context, item, enabled, onItemSelect) => + Row(children: [ + item.icon, + const Spacer(), + Text(item.label).copyWith(style: dropdownTheme.textStyle) + ]), + decoration: dropdownTheme.decoration, + disabledDecoration: dropdownTheme.disabledDecoration, + listItemPadding: dropdownTheme.listItemPadding, + itemsListPadding: dropdownTheme.itemsListPadding, + expandedHeaderPadding: dropdownTheme.expandedHeaderPadding, + closedHeaderPadding: dropdownTheme.closedHeaderPadding, + )).paddingLTRB(0, 4, 8, 4), ], ), - body: SafeArea( + body: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), child: Column(children: [ - Stack(alignment: AlignmentDirectional.center, children: [ - Image.asset('assets/images/ellet.png'), - TerminalView(globalDebugTerminal, - textStyle: kDefaultTerminalStyle, - controller: _terminalController, - //autofocus: true, - backgroundOpacity: _showEllet ? 0.75 : 1.0, - onSecondaryTapDown: (details, offset) async { - await copySelection(context); - }) - ]).expanded(), - TextField( - controller: _debugCommandController, - decoration: InputDecoration( - filled: true, - contentPadding: const EdgeInsets.fromLTRB(8, 2, 8, 2), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide.none), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - fillColor: scale.primaryScale.subtleBackground, - hintText: translate('developer.command'), - suffixIcon: IconButton( - icon: Icon(Icons.send, - color: _debugCommandController.text.isEmpty - ? scale.primaryScale.primary.withAlpha(0x3F) - : scale.primaryScale.primary), - onPressed: _debugCommandController.text.isEmpty - ? null - : () async { - final debugCommand = _debugCommandController.text; - _debugCommandController.clear(); - await _sendDebugCommand(debugCommand); - }, - )), - onChanged: (_) { - setState(() => {}); - }, - onSubmitted: (debugCommand) async { - _debugCommandController.clear(); - await _sendDebugCommand(debugCommand); - }, - ).paddingAll(4) - ]))); + Stack(alignment: AlignmentDirectional.center, children: [ + Image.asset('assets/images/ellet.png'), + TerminalView(globalDebugTerminal, + textStyle: kDefaultTerminalStyle, + textScaler: TextScaler.noScaling, + controller: _terminalController, + keyboardType: TextInputType.none, + backgroundOpacity: _showEllet ? 0.75 : 1.0, + onSecondaryTapDown: (details, offset) async { + await copySelection(context); + }) + ]).expanded(), + TextFormField( + enabled: !_busy, + autofocus: true, + controller: _historyController.controller, + focusNode: _historyController.focusNode, + textInputAction: TextInputAction.send, + onTapOutside: (event) { + FocusManager.instance.primaryFocus?.unfocus(); + }, + decoration: InputDecoration( + filled: true, + contentPadding: const EdgeInsets.fromLTRB(8, 2, 8, 2), + enabledBorder: + const OutlineInputBorder(borderSide: BorderSide.none), + border: + const OutlineInputBorder(borderSide: BorderSide.none), + focusedBorder: + const OutlineInputBorder(borderSide: BorderSide.none), + fillColor: scale.primaryScale.elementBackground, + hoverColor: scale.primaryScale.elementBackground, + hintStyle: scaleTheme.textTheme.labelMedium!.copyWith( + color: scaleConfig.useVisualIndicators + ? hintColor.withAlpha(0x7F) + : hintColor), + hintText: translate('developer.command'), + suffixIcon: IconButton( + icon: Icon(Icons.send, + color: _historyController.controller.text.isEmpty + ? hintColor.withAlpha(0x7F) + : hintColor), + onPressed: + (_historyController.controller.text.isEmpty || _busy) + ? null + : () async { + final debugCommand = + _historyController.controller.text; + _historyController.controller.clear(); + await _onSubmitCommand(debugCommand); + }, + )), + onChanged: (_) { + setState(() => {}); + }, + onEditingComplete: () { + // part of the default action if onEditingComplete is null + _historyController.controller.clearComposing(); + // don't give up focus though + }, + onFieldSubmitted: (debugCommand) async { + if (debugCommand.isEmpty) { + return; + } + await _onSubmitCommand(debugCommand); + _historyController.focusNode.requestFocus(); + }, + ).paddingAll(4) + ]))); } + //////////////////////////////////////////////////////////////////////////// + + final _terminalController = TerminalController(); + late final HistoryTextEditingController _historyController; + + final List _logLevelDropdownItems = []; + var _logLevelDropDown = log.level.logLevel; + + var _showEllet = false; + var _busy = false; + @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); diff --git a/lib/veilid_processor/views/history_text_editing_controller.dart b/lib/veilid_processor/views/history_text_editing_controller.dart new file mode 100644 index 0000000..f9646e5 --- /dev/null +++ b/lib/veilid_processor/views/history_text_editing_controller.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// TextField History Controller +class HistoryTextEditingController { + HistoryTextEditingController( + {required this.setState, TextEditingController? controller}) + : _controller = controller ?? TextEditingController() { + _historyFocusNode = FocusNode(onKeyEvent: (_node, event) { + if (event.runtimeType == KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.arrowUp) { + if (_historyPosition > 0) { + if (_historyPosition == _history.length) { + _historyCurrentEdit = _controller.text; + } + _historyPosition -= 1; + setState(() { + _controller.text = _history[_historyPosition]; + }); + } + return KeyEventResult.handled; + } else if (event.runtimeType == KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.arrowDown) { + if (_historyPosition < _history.length) { + _historyPosition += 1; + setState(() { + if (_historyPosition == _history.length) { + _controller.text = _historyCurrentEdit; + } else { + _controller.text = _history[_historyPosition]; + } + }); + } + return KeyEventResult.handled; + } else if (event.runtimeType == KeyDownEvent) { + _historyPosition = _history.length; + _historyCurrentEdit = _controller.text; + } + return KeyEventResult.ignored; + }); + } + + void submit(String v) { + // add to history + if (_history.isEmpty || _history.last != v) { + _history.add(v); + if (_history.length > 100) { + _history.removeAt(0); + } + } + _historyPosition = _history.length; + setState(() { + _controller.text = ''; + }); + } + + FocusNode get focusNode => _historyFocusNode; + TextEditingController get controller => _controller; + + //////////////////////////////////////////////////////////////////////////// + + late void Function(void Function()) setState; + final TextEditingController _controller; + late final FocusNode _historyFocusNode; + + final List _history = []; + int _historyPosition = 0; + String _historyCurrentEdit = ''; +} diff --git a/lib/veilid_processor/views/signal_strength_meter.dart b/lib/veilid_processor/views/signal_strength_meter.dart index 4691e87..44e3cb6 100644 --- a/lib/veilid_processor/views/signal_strength_meter.dart +++ b/lib/veilid_processor/views/signal_strength_meter.dart @@ -1,9 +1,9 @@ import 'package:async_tools/async_tools.dart'; import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import 'package:quickalert/quickalert.dart'; import 'package:signal_strength_indicator/signal_strength_indicator.dart'; import 'package:veilid_support/veilid_support.dart'; @@ -11,7 +11,7 @@ import '../../theme/theme.dart'; import '../cubit/connection_state_cubit.dart'; class SignalStrengthMeterWidget extends StatelessWidget { - const SignalStrengthMeterWidget({super.key}); + const SignalStrengthMeterWidget({super.key, this.color, this.inactiveColor}); @override // ignore: prefer_expression_function_bodies @@ -19,7 +19,7 @@ class SignalStrengthMeterWidget extends StatelessWidget { final theme = Theme.of(context); final scale = theme.extension()!; - const iconSize = 16.0; + final iconSize = 16.0.scaled(context); return BlocBuilder>(builder: (context, state) { @@ -33,32 +33,34 @@ class SignalStrengthMeterWidget extends StatelessWidget { switch (connectionState.attachment.state) { case AttachmentState.detached: iconWidget = Icon(Icons.signal_cellular_nodata, - size: iconSize, color: scale.primaryScale.primaryText); + size: iconSize, + color: this.color ?? scale.primaryScale.borderText); return; case AttachmentState.detaching: iconWidget = Icon(Icons.signal_cellular_off, - size: iconSize, color: scale.primaryScale.primaryText); + size: iconSize, + color: this.color ?? scale.primaryScale.borderText); return; case AttachmentState.attaching: value = 0; - color = scale.primaryScale.primaryText; + color = this.color ?? scale.primaryScale.borderText; case AttachmentState.attachedWeak: value = 1; - color = scale.primaryScale.primaryText; + color = this.color ?? scale.primaryScale.borderText; case AttachmentState.attachedStrong: value = 2; - color = scale.primaryScale.primaryText; + color = this.color ?? scale.primaryScale.borderText; case AttachmentState.attachedGood: value = 3; - color = scale.primaryScale.primaryText; + color = this.color ?? scale.primaryScale.borderText; case AttachmentState.fullyAttached: value = 4; - color = scale.primaryScale.primaryText; + color = this.color ?? scale.primaryScale.borderText; case AttachmentState.overAttached: value = 4; - color = scale.primaryScale.primaryText; + color = this.color ?? scale.primaryScale.borderText; } - inactiveColor = scale.primaryScale.primaryText; + inactiveColor = this.inactiveColor ?? scale.grayScale.borderText; iconWidget = SignalStrengthIndicator.bars( value: value, @@ -71,11 +73,8 @@ class SignalStrengthMeterWidget extends StatelessWidget { loading: () => {iconWidget = const Icon(Icons.warning)}, error: (e, st) => { iconWidget = const Icon(Icons.error).onTap( - () async => QuickAlert.show( - type: QuickAlertType.error, - context: context, - title: 'Error', - text: 'Error: {e}\n StackTrace: {st}'), + () async => showErrorStacktraceModal( + context: context, error: e, stackTrace: st), ) }); @@ -86,4 +85,16 @@ class SignalStrengthMeterWidget extends StatelessWidget { child: iconWidget); }); } + + //////////////////////////////////////////////////////////////////////////// + final Color? color; + final Color? inactiveColor; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ColorProperty('color', color)) + ..add(ColorProperty('inactiveColor', inactiveColor)); + } } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index d2ecb95..0ac222b 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,23 +6,27 @@ #include "generated_plugin_registrant.h" +#include #include -#include -#include +#include +#include #include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_saver_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSaverPlugin"); + file_saver_plugin_register_with_registrar(file_saver_registrar); g_autoptr(FlPluginRegistrar) pasteboard_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin"); pasteboard_plugin_register_with_registrar(pasteboard_registrar); - g_autoptr(FlPluginRegistrar) screen_retriever_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin"); - screen_retriever_plugin_register_with_registrar(screen_retriever_registrar); - g_autoptr(FlPluginRegistrar) smart_auth_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "SmartAuthPlugin"); - smart_auth_plugin_register_with_registrar(smart_auth_registrar); + g_autoptr(FlPluginRegistrar) printing_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin"); + printing_plugin_register_with_registrar(printing_registrar); + g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); + screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 10f8b07..a48f10f 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,9 +3,10 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_saver pasteboard - screen_retriever - smart_auth + printing + screen_retriever_linux url_launcher_linux veilid window_manager diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index fa5dd07..7e4a42f 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,26 +5,28 @@ import FlutterMacOS import Foundation -import mobile_scanner +import file_saver +import package_info_plus import pasteboard import path_provider_foundation -import screen_retriever +import printing +import screen_retriever_macos import share_plus import shared_preferences_foundation -import smart_auth -import sqflite +import sqflite_darwin import url_launcher_macos import veilid import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) + FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) - ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) + PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) + ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) - SmartAuthPlugin.register(with: registry.registrar(forPlugin: "SmartAuthPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) VeilidPlugin.register(with: registry.registrar(forPlugin: "VeilidPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index faa2836..bd9fccf 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,22 +1,24 @@ PODS: + - file_saver (0.0.1): + - FlutterMacOS - FlutterMacOS (1.0.0) - - mobile_scanner (5.1.1): + - package_info_plus (0.0.1): - FlutterMacOS - pasteboard (0.0.1): - FlutterMacOS - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - screen_retriever (0.0.1): + - printing (1.0.0): + - FlutterMacOS + - screen_retriever_macos (0.0.1): - FlutterMacOS - share_plus (0.0.1): - FlutterMacOS - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - smart_auth (0.0.1): - - FlutterMacOS - - sqflite (0.0.3): + - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS - url_launcher_macos (0.0.1): @@ -27,38 +29,41 @@ PODS: - FlutterMacOS DEPENDENCIES: + - file_saver (from `Flutter/ephemeral/.symlinks/plugins/file_saver/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - - mobile_scanner (from `Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - pasteboard (from `Flutter/ephemeral/.symlinks/plugins/pasteboard/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - - screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`) + - printing (from `Flutter/ephemeral/.symlinks/plugins/printing/macos`) + - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`) - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - - smart_auth (from `Flutter/ephemeral/.symlinks/plugins/smart_auth/macos`) - - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`) + - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - veilid (from `Flutter/ephemeral/.symlinks/plugins/veilid/macos`) - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) EXTERNAL SOURCES: + file_saver: + :path: Flutter/ephemeral/.symlinks/plugins/file_saver/macos FlutterMacOS: :path: Flutter/ephemeral - mobile_scanner: - :path: Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos pasteboard: :path: Flutter/ephemeral/.symlinks/plugins/pasteboard/macos path_provider_foundation: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin - screen_retriever: - :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos + printing: + :path: Flutter/ephemeral/.symlinks/plugins/printing/macos + screen_retriever_macos: + :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos share_plus: :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin - smart_auth: - :path: Flutter/ephemeral/.symlinks/plugins/smart_auth/macos - sqflite: - :path: Flutter/ephemeral/.symlinks/plugins/sqflite/darwin + sqflite_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos veilid: @@ -67,19 +72,20 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: + file_saver: e35bd97de451dde55ff8c38862ed7ad0f3418d0f FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 - mobile_scanner: 1efac1e53c294b24e3bb55bcc7f4deee0233a86b - pasteboard: 9b69dba6fedbb04866be632205d532fe2f6b1d99 - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 - screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 - share_plus: 36537c04ce0c3e3f5bd297ce4318b6d5ee5fd6cf - shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 - smart_auth: b38e3ab4bfe089eacb1e233aca1a2340f96c28e9 - sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec - url_launcher_macos: 5f437abeda8c85500ceb03f5c1938a8c5a705399 - veilid: a54f57b7bcf0e4e072fe99272d76ca126b2026d0 - window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + pasteboard: 278d8100149f940fb795d6b3a74f0720c890ecb7 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + printing: c4cf83c78fd684f9bc318e6aadc18972aa48f617 + screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f + share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 + veilid: 319e2e78836d7b3d08203596d0b4a0e244b68d29 + window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c PODFILE CHECKSUM: ff0a9a3ce75ee73f200ca7e2f47745698c917ef9 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index c94b139..408e781 100644 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -48,6 +48,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index d53ef64..b3c1761 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -1,9 +1,13 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@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/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index c55503e..fe015f3 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -12,6 +12,8 @@ com.apple.security.network.server + com.apple.security.print + keychain-access-groups $(AppIdentifierPrefix)com.veilid.veilidchat diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index d5ed4c2..8d195f4 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -10,6 +10,8 @@ com.apple.security.network.server + com.apple.security.print + keychain-access-groups $(AppIdentifierPrefix)com.veilid.veilidchat diff --git a/packages/veilid_support/example/.gitignore b/packages/veilid_support/example/.gitignore index 29a3a50..79c113f 100644 --- a/packages/veilid_support/example/.gitignore +++ b/packages/veilid_support/example/.gitignore @@ -5,9 +5,11 @@ *.swp .DS_Store .atom/ +.build/ .buildlog/ .history .svn/ +.swiftpm/ migrate_working_dir/ # IntelliJ related diff --git a/packages/veilid_support/example/android/app/build.gradle b/packages/veilid_support/example/android/app/build.gradle index 033f4ac..2ba6503 100644 --- a/packages/veilid_support/example/android/app/build.gradle +++ b/packages/veilid_support/example/android/app/build.gradle @@ -23,19 +23,19 @@ if (flutterVersionName == null) { } android { - ndkVersion '26.3.11579264' - ndkVersion '26.3.11579264' + ndkVersion '27.0.12077973' + ndkVersion '27.0.12077973' namespace "com.example.example" compileSdk flutter.compileSdkVersion ndkVersion flutter.ndkVersion compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = '17' } sourceSets { diff --git a/packages/veilid_support/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/veilid_support/example/android/gradle/wrapper/gradle-wrapper.properties index c3433f7..6f8524c 100644 --- a/packages/veilid_support/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/veilid_support/example/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https://services.gradle.org/distributions/gradle-7.3.3-all.zip +distributionUrl=https://services.gradle.org/distributions/gradle-8.10.2-all.zip diff --git a/packages/veilid_support/example/android/settings.gradle b/packages/veilid_support/example/android/settings.gradle index 1d6d19b..b1ae36a 100644 --- a/packages/veilid_support/example/android/settings.gradle +++ b/packages/veilid_support/example/android/settings.gradle @@ -5,10 +5,9 @@ pluginManagement { def flutterSdkPath = properties.getProperty("flutter.sdk") assert flutterSdkPath != null, "flutter.sdk not set in local.properties" return flutterSdkPath - } - settings.ext.flutterSdkPath = flutterSdkPath() + }() - includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") repositories { google() @@ -19,8 +18,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.3.0" apply false - id "org.jetbrains.kotlin.android" version "1.7.10" apply false + id "com.android.application" version "8.8.0" apply false + id "org.jetbrains.kotlin.android" version "1.9.25" apply false } -include ":app" +include ":app" \ No newline at end of file diff --git a/packages/veilid_support/example/dev-setup/flutter_config.sh b/packages/veilid_support/example/dev-setup/flutter_config.sh index 9cb53c7..a1b8c8d 100755 --- a/packages/veilid_support/example/dev-setup/flutter_config.sh +++ b/packages/veilid_support/example/dev-setup/flutter_config.sh @@ -14,13 +14,13 @@ sed -i '' 's/MACOSX_DEPLOYMENT_TARGET = [^;]*/MACOSX_DEPLOYMENT_TARGET = 10.14.6 sed -i '' "s/platform :osx, '[^']*'/platform :osx, '10.14.6'/g" $APPDIR/macos/Podfile # Android: Set NDK version -if [[ "$TMPDIR" != "" ]]; then +if [[ "$TMPDIR" != "" ]]; then ANDTMP=$TMPDIR/andtmp_$(date +%s) -else +else ANDTMP=/tmp/andtmp_$(date +%s) fi cat < $ANDTMP - ndkVersion '26.3.11579264' + ndkVersion '27.0.12077973' EOF sed -i '' -e "/android {/r $ANDTMP" $APPDIR/android/app/build.gradle rm -- $ANDTMP @@ -29,7 +29,7 @@ rm -- $ANDTMP sed -i '' 's/minSdkVersion .*/minSdkVersion Math.max(flutter.minSdkVersion, 24)/g' $APPDIR/android/app/build.gradle # Android: Set gradle plugin version -sed -i '' "s/classpath \'com.android.tools.build:gradle:[^\']*\'/classpath 'com.android.tools.build:gradle:7.2.0'/g" $APPDIR/android/build.gradle +sed -i '' "s/classpath \'com.android.tools.build:gradle:[^\']*\'/classpath 'com.android.tools.build:gradle:8.8.0'/g" $APPDIR/android/build.gradle # Android: Set gradle version -sed -i '' 's/distributionUrl=.*/distributionUrl=https:\/\/services.gradle.org\/distributions\/gradle-7.3.3-all.zip/g' $APPDIR/android/gradle/wrapper/gradle-wrapper.properties +sed -i '' 's/distributionUrl=.*/distributionUrl=https:\/\/services.gradle.org\/distributions\/gradle-8.10.2-all.zip/g' $APPDIR/android/gradle/wrapper/gradle-wrapper.properties diff --git a/packages/veilid_support/example/integration_test/app_test.dart b/packages/veilid_support/example/integration_test/app_test.dart index a0f3b7f..2d3d0e2 100644 --- a/packages/veilid_support/example/integration_test/app_test.dart +++ b/packages/veilid_support/example/integration_test/app_test.dart @@ -8,6 +8,7 @@ import 'fixtures/fixtures.dart'; import 'test_dht_log.dart'; import 'test_dht_record_pool.dart'; import 'test_dht_short_array.dart'; +import 'test_persistent_queue.dart'; import 'test_table_db_array.dart'; void main() { @@ -32,165 +33,177 @@ void main() { debugPrintSynchronously('Duration: ${endTime.difference(startTime)}'); }); - group('Attached Tests', () { + group('attached', () { setUpAll(veilidFixture.attach); tearDownAll(veilidFixture.detach); - // group('TableDB Tests', () { - // group('TableDBArray Tests', () { - // // test('create/delete TableDBArray', testTableDBArrayCreateDelete); + group('persistent_queue', () { + test('persistent_queue:open_close', testPersistentQueueOpenClose); + test('persistent_queue:add', testPersistentQueueAdd); + test('persistent_queue:add_sync', testPersistentQueueAddSync); + test('persistent_queue:add_persist', testPersistentQueueAddPersist); + test('persistent_queue:add_sync_persist', + testPersistentQueueAddSyncPersist); + }); - // group('TableDBArray Add/Get Tests', () { - // for (final params in [ - // // - // (99, 3, 15), - // (100, 4, 16), - // (101, 5, 17), - // // - // (511, 3, 127), - // (512, 4, 128), - // (513, 5, 129), - // // - // (4095, 3, 1023), - // (4096, 4, 1024), - // (4097, 5, 1025), - // // - // (65535, 3, 16383), - // (65536, 4, 16384), - // (65537, 5, 16385), - // ]) { - // final count = params.$1; - // final singles = params.$2; - // final batchSize = params.$3; - - // test( - // timeout: const Timeout(Duration(seconds: 480)), - // 'add/remove TableDBArray count = $count batchSize=$batchSize', - // makeTestTableDBArrayAddGetClear( - // count: count, - // singles: singles, - // batchSize: batchSize, - // crypto: const VeilidCryptoPublic()), - // ); - // } - // }); - - // group('TableDBArray Insert Tests', () { - // for (final params in [ - // // - // (99, 3, 15), - // (100, 4, 16), - // (101, 5, 17), - // // - // (511, 3, 127), - // (512, 4, 128), - // (513, 5, 129), - // // - // (4095, 3, 1023), - // (4096, 4, 1024), - // (4097, 5, 1025), - // // - // (65535, 3, 16383), - // (65536, 4, 16384), - // (65537, 5, 16385), - // ]) { - // final count = params.$1; - // final singles = params.$2; - // final batchSize = params.$3; - - // test( - // timeout: const Timeout(Duration(seconds: 480)), - // 'insert TableDBArray count=$count singles=$singles batchSize=$batchSize', - // makeTestTableDBArrayInsert( - // count: count, - // singles: singles, - // batchSize: batchSize, - // crypto: const VeilidCryptoPublic()), - // ); - // } - // }); - - // group('TableDBArray Remove Tests', () { - // for (final params in [ - // // - // (99, 3, 15), - // (100, 4, 16), - // (101, 5, 17), - // // - // (511, 3, 127), - // (512, 4, 128), - // (513, 5, 129), - // // - // (4095, 3, 1023), - // (4096, 4, 1024), - // (4097, 5, 1025), - // // - // (16383, 3, 4095), - // (16384, 4, 4096), - // (16385, 5, 4097), - // ]) { - // final count = params.$1; - // final singles = params.$2; - // final batchSize = params.$3; - - // test( - // timeout: const Timeout(Duration(seconds: 480)), - // 'remove TableDBArray count=$count singles=$singles batchSize=$batchSize', - // makeTestTableDBArrayRemove( - // count: count, - // singles: singles, - // batchSize: batchSize, - // crypto: const VeilidCryptoPublic()), - // ); - // } - // }); - // }); - // }); - - group('DHT Support Tests', () { + group('dht_support', () { setUpAll(updateProcessorFixture.setUp); setUpAll(tickerFixture.setUp); tearDownAll(tickerFixture.tearDown); tearDownAll(updateProcessorFixture.tearDown); - test('create pool', testDHTRecordPoolCreate); + test('create_pool', testDHTRecordPoolCreate); - group('DHTRecordPool Tests', () { + group('dht_record_pool', () { setUpAll(dhtRecordPoolFixture.setUp); tearDownAll(dhtRecordPoolFixture.tearDown); - test('create/delete record', testDHTRecordCreateDelete); - test('record scopes', testDHTRecordScopes); - test('create/delete deep record', testDHTRecordDeepCreateDelete); + test('dht_record_pool:create_delete', testDHTRecordCreateDelete); + test('dht_record_pool:scopes', testDHTRecordScopes); + test('dht_record_pool:deep_create_delete', + testDHTRecordDeepCreateDelete); }); - group('DHTShortArray Tests', () { + group('dht_short_array', () { setUpAll(dhtRecordPoolFixture.setUp); tearDownAll(dhtRecordPoolFixture.tearDown); for (final stride in [256, 16 /*64, 32, 16, 8, 4, 2, 1 */]) { - test('create shortarray stride=$stride', + test('dht_short_array:create_stride_$stride', makeTestDHTShortArrayCreateDelete(stride: stride)); - test('add shortarray stride=$stride', + test('dht_short_array:add_stride_$stride', makeTestDHTShortArrayAdd(stride: stride)); } }); - group('DHTLog Tests', () { + group('dht_log', () { setUpAll(dhtRecordPoolFixture.setUp); tearDownAll(dhtRecordPoolFixture.tearDown); for (final stride in [256, 16 /*64, 32, 16, 8, 4, 2, 1 */]) { - test('create log stride=$stride', + test('dht_log:create_stride_$stride', makeTestDHTLogCreateDelete(stride: stride)); test( timeout: const Timeout(Duration(seconds: 480)), - 'add/truncate log stride=$stride', + 'dht_log:add_truncate_stride_$stride', makeTestDHTLogAddTruncate(stride: stride), ); } }); }); + + group('table_db', () { + group('table_db_array', () { + test('table_db_array:create_delete', testTableDBArrayCreateDelete); + + group('table_db_array:add_get', () { + for (final params in [ + // + (99, 3, 15), + (100, 4, 16), + (101, 5, 17), + // + (511, 3, 127), + (512, 4, 128), + (513, 5, 129), + // + (4095, 3, 1023), + (4096, 4, 1024), + (4097, 5, 1025), + // + (65535, 3, 16383), + (65536, 4, 16384), + (65537, 5, 16385), + ]) { + final count = params.$1; + final singles = params.$2; + final batchSize = params.$3; + + test( + timeout: const Timeout(Duration(seconds: 480)), + 'table_db_array:add_remove_count=${count}_batchSize=$batchSize', + makeTestTableDBArrayAddGetClear( + count: count, + singles: singles, + batchSize: batchSize, + crypto: const VeilidCryptoPublic()), + ); + } + }); + + group('table_db_array:insert', () { + for (final params in [ + // + (99, 3, 15), + (100, 4, 16), + (101, 5, 17), + // + (511, 3, 127), + (512, 4, 128), + (513, 5, 129), + // + (4095, 3, 1023), + (4096, 4, 1024), + (4097, 5, 1025), + // + (65535, 3, 16383), + (65536, 4, 16384), + (65537, 5, 16385), + ]) { + final count = params.$1; + final singles = params.$2; + final batchSize = params.$3; + + test( + timeout: const Timeout(Duration(seconds: 480)), + 'table_db_array:insert_count=${count}_' + 'singles=${singles}_batchSize=$batchSize', + makeTestTableDBArrayInsert( + count: count, + singles: singles, + batchSize: batchSize, + crypto: const VeilidCryptoPublic()), + ); + } + }); + + group('table_db_array:remove', () { + for (final params in [ + // + (99, 3, 15), + (100, 4, 16), + (101, 5, 17), + // + (511, 3, 127), + (512, 4, 128), + (513, 5, 129), + // + (4095, 3, 1023), + (4096, 4, 1024), + (4097, 5, 1025), + // + (16383, 3, 4095), + (16384, 4, 4096), + (16385, 5, 4097), + ]) { + final count = params.$1; + final singles = params.$2; + final batchSize = params.$3; + + test( + timeout: const Timeout(Duration(seconds: 480)), + 'table_db_array:remove_count=${count}_' + 'singles=${singles}_batchSize=$batchSize', + makeTestTableDBArrayRemove( + count: count, + singles: singles, + batchSize: batchSize, + crypto: const VeilidCryptoPublic()), + ); + } + }); + }); + }); }); }); } diff --git a/packages/veilid_support/example/integration_test/test_persistent_queue.dart b/packages/veilid_support/example/integration_test/test_persistent_queue.dart new file mode 100644 index 0000000..51f8004 --- /dev/null +++ b/packages/veilid_support/example/integration_test/test_persistent_queue.dart @@ -0,0 +1,196 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:async_tools/async_tools.dart'; +import 'package:collection/collection.dart'; +import 'package:test/test.dart'; +import 'package:veilid_support/veilid_support.dart'; + +Future testPersistentQueueOpenClose() async { + final pq = PersistentQueue( + table: 'persistent_queue_integration_test', + key: 'open_close', + fromBuffer: (buf) => utf8.decode(buf), + toBuffer: (s) => utf8.encode(s), + closure: (elems) async { + // + }); + + await pq.close(); +} + +Future testPersistentQueueAdd() async { + final added = {}; + for (var n = 0; n < 100; n++) { + final elem = 'FOOBAR #$n'; + added.add(elem); + } + + final done = {}; + final pq = PersistentQueue( + table: 'persistent_queue_integration_test', + key: 'add', + fromBuffer: (buf) => utf8.decode(buf), + toBuffer: (s) => utf8.encode(s), + closure: (elems) async { + done.addAll(elems); + }); + + var oddeven = false; + for (final chunk in added.slices(10)) { + if (!oddeven) { + await chunk.map(pq.add).wait; + } else { + await pq.addAll(chunk); + } + oddeven = !oddeven; + } + + await pq.close(); + + expect(done, equals(added)); +} + +Future testPersistentQueueAddSync() async { + final added = {}; + for (var n = 0; n < 100; n++) { + final elem = 'FOOBAR #$n'; + added.add(elem); + } + + final done = {}; + final pq = PersistentQueue( + table: 'persistent_queue_integration_test', + key: 'add_sync', + fromBuffer: (buf) => utf8.decode(buf), + toBuffer: (s) => utf8.encode(s), + closure: (elems) async { + done.addAll(elems); + }); + + var oddeven = false; + for (final chunk in added.slices(10)) { + if (!oddeven) { + await chunk.map((x) async { + await asyncSleep(Duration.zero); + pq.addSync(x); + }).wait; + } else { + pq.addAllSync(chunk); + } + oddeven = !oddeven; + } + + await pq.close(); + + expect(done, equals(added)); +} + +Future testPersistentQueueAddPersist() async { + final added = {}; + for (var n = 0; n < 100; n++) { + final elem = 'FOOBAR #$n'; + added.add(elem); + } + + final done = {}; + + late final PersistentQueue pq; + + pq = PersistentQueue( + table: 'persistent_queue_integration_test', + key: 'add_persist', + fromBuffer: (buf) => utf8.decode(buf), + toBuffer: (s) => utf8.encode(s), + closure: (elems) async { + done.addAll(elems); + }); + + // Start it paused + await pq.pause(); + + // Add all elements + var oddeven = false; + + for (final chunk in added.slices(10)) { + if (!oddeven) { + await chunk.map(pq.add).wait; + } else { + await pq.addAll(chunk); + } + oddeven = !oddeven; + } + + // Close the persistent queue + await pq.close(); + + // Create a new persistent queue that processes the items + final pq2 = PersistentQueue( + table: 'persistent_queue_integration_test', + key: 'add_persist', + fromBuffer: (buf) => utf8.decode(buf), + toBuffer: (s) => utf8.encode(s), + closure: (elems) async { + done.addAll(elems); + }); + await pq2.waitEmpty; + await pq2.close(); + + expect(done, equals(added)); +} + +Future testPersistentQueueAddSyncPersist() async { + final added = {}; + for (var n = 0; n < 100; n++) { + final elem = 'FOOBAR #$n'; + added.add(elem); + } + + final done = {}; + + late final PersistentQueue pq; + + pq = PersistentQueue( + table: 'persistent_queue_integration_test', + key: 'add_persist', + fromBuffer: (buf) => utf8.decode(buf), + toBuffer: (s) => utf8.encode(s), + closure: (elems) async { + done.addAll(elems); + }); + + // Start it paused + await pq.pause(); + + // Add all elements + var oddeven = false; + + for (final chunk in added.slices(10)) { + if (!oddeven) { + await chunk.map((x) async { + await asyncSleep(Duration.zero); + pq.addSync(x); + }).wait; + } else { + pq.addAllSync(chunk); + } + oddeven = !oddeven; + } + + // Close the persistent queue + await pq.close(); + + // Create a new persistent queue that processes the items + final pq2 = PersistentQueue( + table: 'persistent_queue_integration_test', + key: 'add_persist', + fromBuffer: (buf) => utf8.decode(buf), + toBuffer: (s) => utf8.encode(s), + closure: (elems) async { + done.addAll(elems); + }); + await pq2.waitEmpty; + await pq2.close(); + + expect(done, equals(added)); +} diff --git a/packages/veilid_support/example/macos/Podfile.lock b/packages/veilid_support/example/macos/Podfile.lock index 6a58494..a2618bd 100644 --- a/packages/veilid_support/example/macos/Podfile.lock +++ b/packages/veilid_support/example/macos/Podfile.lock @@ -21,9 +21,9 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 - veilid: a54f57b7bcf0e4e072fe99272d76ca126b2026d0 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + veilid: 319e2e78836d7b3d08203596d0b4a0e244b68d29 PODFILE CHECKSUM: 16208599a12443d53889ba2270a4985981cfb204 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/packages/veilid_support/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/veilid_support/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 15368ec..ac78810 100644 --- a/packages/veilid_support/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/veilid_support/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -59,6 +59,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/packages/veilid_support/example/macos/Runner/AppDelegate.swift b/packages/veilid_support/example/macos/Runner/AppDelegate.swift index d53ef64..b3c1761 100644 --- a/packages/veilid_support/example/macos/Runner/AppDelegate.swift +++ b/packages/veilid_support/example/macos/Runner/AppDelegate.swift @@ -1,9 +1,13 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } diff --git a/packages/veilid_support/example/pubspec.lock b/packages/veilid_support/example/pubspec.lock index 5ff4899..5c4355b 100644 --- a/packages/veilid_support/example/pubspec.lock +++ b/packages/veilid_support/example/pubspec.lock @@ -5,130 +5,146 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f url: "https://pub.dev" source: hosted - version: "67.0.0" + version: "82.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0" url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "7.4.5" args: dependency: transitive description: name: args - sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.7.0" async: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.13.0" async_tools: dependency: "direct dev" description: name: async_tools - sha256: "72590010ed6c6f5cbd5d40e33834abc08a43da6a73ac3c3645517d53899b8684" + sha256: "9611c1efeae7e6d342721d0c2caf2e4783d91fba6a9637d7badfa2dccf8de2a2" url: "https://pub.dev" source: hosted - version: "0.1.2" + version: "0.1.10" bloc: dependency: transitive description: name: bloc - sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e" + sha256: "52c10575f4445c61dd9e0cafcc6356fdd827c4c64dd7945ef3c4105f6b6ac189" url: "https://pub.dev" source: hosted - version: "8.1.4" + version: "9.0.0" bloc_advanced_tools: dependency: transitive description: name: bloc_advanced_tools - sha256: "0cf9b3a73a67addfe22ec3f97a1ac240f6ad53870d6b21a980260f390d7901cd" + sha256: "63e57000df7259e3007dbfbbfd7dae3e0eca60eb2ac93cbe0c5a3de0e77c9972" url: "https://pub.dev" source: hosted - version: "0.1.2" + version: "0.1.13" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" + buffer: + dependency: transitive + description: + name: buffer + sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1" + url: "https://pub.dev" + source: hosted + version: "1.2.3" change_case: dependency: transitive description: name: change_case - sha256: "47c48c36f95f20c6d0ba03efabceff261d05026cca322cc2c4c01c343371b5bb" + sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003 url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.2.0" characters: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" charcode: dependency: transitive description: name: charcode - sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.4.0" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" clock: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" collection: - dependency: transitive + dependency: "direct main" description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.1" convert: dependency: transitive description: name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" coverage: dependency: transitive description: name: coverage - sha256: "3945034e86ea203af7a056d98e98e42a5518fff200d6e8e6647e1886b07e936e" + sha256: "802bd084fb82e55df091ec8ad1553a7331b61c08251eef19a508b6f3f3a9858d" url: "https://pub.dev" source: hosted - version: "1.8.0" + version: "1.13.1" crypto: dependency: transitive description: name: crypto - sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.6" cupertino_icons: dependency: "direct main" description: @@ -141,50 +157,50 @@ packages: dependency: transitive description: name: equatable - sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" url: "https://pub.dev" source: hosted - version: "2.0.5" + version: "2.0.7" fake_async: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.3" fast_immutable_collections: dependency: transitive description: name: fast_immutable_collections - sha256: "533806a7f0c624c2e479d05d3fdce4c87109a7cd0db39b8cc3830d3a2e8dedc7" + sha256: d1aa3d7788fab06cce7f303f4969c7a16a10c865e1bd2478291a8ebcbee084e5 url: "https://pub.dev" source: hosted - version: "10.2.3" + version: "11.0.4" ffi: dependency: transitive description: name: ffi - sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" file: dependency: transitive description: name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" fixnum: dependency: transitive description: name: fixnum - sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -209,10 +225,10 @@ packages: dependency: transitive description: name: freezed_annotation - sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d + sha256: c87ff004c8aa6af2d531668b46a4ea379f7191dc6dfa066acd53d506da6e044b url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "3.0.0" frontend_server_client: dependency: transitive description: @@ -230,10 +246,10 @@ packages: dependency: transitive description: name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" globbing: dependency: transitive description: @@ -246,18 +262,26 @@ packages: dependency: transitive description: name: http_multi_server - sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" http_parser: dependency: transitive description: name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.1.2" + indent: + dependency: transitive + description: + name: indent + sha256: "819319a5c185f7fe412750c798953378b37a0d0d32564ce33e7c5acfd1372d2a" + url: "https://pub.dev" + source: hosted + version: "2.0.0" integration_test: dependency: "direct dev" description: flutter @@ -267,18 +291,18 @@ packages: dependency: transitive description: name: io - sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.5" js: dependency: transitive description: name: js - sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "0.7.2" json_annotation: dependency: transitive description: @@ -291,18 +315,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.9" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: @@ -315,18 +339,18 @@ packages: dependency: "direct dev" description: name: lint_hard - sha256: "44d15ec309b1a8e1aff99069df9dcb1597f49d5f588f32811ca28fb7b38c32fe" + sha256: "2073d4e83ac4e3f2b87cc615fff41abb5c2c5618e117edcd3d71f40f2186f4d5" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "6.1.1" logging: dependency: transitive description: name: logging - sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" loggy: dependency: transitive description: @@ -339,34 +363,34 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.16.0" mime: dependency: transitive description: name: mime - sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "2.0.0" node_preamble: dependency: transitive description: @@ -379,42 +403,42 @@ packages: dependency: transitive description: name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.0" path: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" path_provider: dependency: transitive description: name: path_provider - sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.5" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: a248d8146ee5983446bf03ed5ea8f6533129a12b11f12057ad1b4a67a2b3b41d + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 url: "https://pub.dev" source: hosted - version: "2.2.4" + version: "2.2.17" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" path_provider_linux: dependency: transitive description: @@ -435,18 +459,18 @@ packages: dependency: transitive description: name: path_provider_windows - sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.3.0" platform: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -467,34 +491,34 @@ packages: dependency: transitive description: name: process - sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" + sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "5.0.3" protobuf: dependency: transitive description: name: protobuf - sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" + sha256: "579fe5557eae58e3adca2e999e38f02441d8aa908703854a9e0a0f47fa857731" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "4.1.0" pub_semver: dependency: transitive description: name: pub_semver - sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" shelf: dependency: transitive description: name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.2" shelf_packages_handler: dependency: transitive description: @@ -507,71 +531,71 @@ packages: dependency: transitive description: name: shelf_static - sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.3" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_map_stack_trace: dependency: transitive description: name: source_map_stack_trace - sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" source_maps: dependency: transitive description: name: source_maps - sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" url: "https://pub.dev" source: hosted - version: "0.10.12" + version: "0.10.13" source_span: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.1" stack_trace: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" string_scanner: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.4.1" sync_http: dependency: transitive description: @@ -592,50 +616,50 @@ packages: dependency: transitive description: name: system_info_plus - sha256: b915c811c6605b802f3988859bc2bb79c95f735762a75b5451741f7a2b949d1b + sha256: df94187e95527f9cb459e6a9f6e0b1ea20c157d8029bc233de34b3c1e17e1c48 url: "https://pub.dev" source: hosted - version: "0.0.5" + version: "0.0.6" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test: dependency: "direct dev" description: name: test - sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073" + sha256: "301b213cd241ca982e9ba50266bd3f5bd1ea33f1455554c5abb85d1be0e2d87e" url: "https://pub.dev" source: hosted - version: "1.25.2" + version: "1.25.15" test_api: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.4" test_core: dependency: transitive description: name: test_core - sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4" + sha256: "84d17c3486c8dfdbe5e12a50c8ae176d15e2a771b96909a9442b40173649ccaa" url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "0.6.8" typed_data: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.4.0" vector_math: dependency: transitive description: @@ -650,7 +674,7 @@ packages: path: "../../../../veilid/veilid-flutter" relative: true source: path - version: "0.3.2" + version: "0.4.6" veilid_support: dependency: "direct main" description: @@ -669,42 +693,50 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "15.0.0" watcher: dependency: transitive description: name: watcher - sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" web: dependency: transitive description: name: web - sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 url: "https://pub.dev" source: hosted - version: "2.4.5" + version: "3.0.3" webdriver: dependency: transitive description: name: webdriver - sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.1.0" webkit_inspection_protocol: dependency: transitive description: @@ -713,30 +745,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" - win32: - dependency: transitive - description: - name: win32 - sha256: a79dbe579cb51ecd6d30b17e0cae4e0ea15e2c0e66f69ad4198f22a6789e94f4 - url: "https://pub.dev" - source: hosted - version: "5.5.1" xdg_directories: dependency: transitive description: name: xdg_directories - sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.1.0" yaml: dependency: transitive description: name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.3" sdks: - dart: ">=3.4.0 <4.0.0" - flutter: ">=3.19.1" + dart: ">=3.7.0 <4.0.0" + flutter: ">=3.27.0" diff --git a/packages/veilid_support/example/pubspec.yaml b/packages/veilid_support/example/pubspec.yaml index 2599f5f..86c8e7e 100644 --- a/packages/veilid_support/example/pubspec.yaml +++ b/packages/veilid_support/example/pubspec.yaml @@ -1,12 +1,13 @@ name: example description: "Veilid Support Example" -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - sdk: '>=3.3.4 <4.0.0' + sdk: ">=3.3.4 <4.0.0" dependencies: + collection: ^1.19.1 cupertino_icons: ^1.0.8 flutter: sdk: flutter @@ -14,13 +15,17 @@ dependencies: path: ../ dev_dependencies: - async_tools: ^0.1.2 + async_tools: ^0.1.10 integration_test: sdk: flutter - lint_hard: ^4.0.0 - test: ^1.25.2 + lint_hard: ^6.0.0 + test: ^1.25.15 veilid_test: path: ../../../../veilid/veilid-flutter/packages/veilid_test +# dependency_overrides: +# async_tools: +# path: ../../../../dart_async_tools + flutter: uses-material-design: true diff --git a/packages/veilid_support/example/test/widget_test.dart b/packages/veilid_support/example/test/widget_test.dart index 092d222..d5cfa06 100644 --- a/packages/veilid_support/example/test/widget_test.dart +++ b/packages/veilid_support/example/test/widget_test.dart @@ -5,13 +5,12 @@ // 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:example/main.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:example/main.dart'; - void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { + testWidgets('Counter increments smoke test', (tester) async { // Build our app and trigger a frame. await tester.pumpWidget(const MyApp()); diff --git a/packages/veilid_support/lib/dht_support/proto/dht.proto b/packages/veilid_support/lib/dht_support/proto/dht.proto index c27915c..da1aa15 100644 --- a/packages/veilid_support/lib/dht_support/proto/dht.proto +++ b/packages/veilid_support/lib/dht_support/proto/dht.proto @@ -23,7 +23,6 @@ message DHTData { uint32 size = 4; } - // DHTLog - represents a ring buffer of many elements with append/truncate semantics // Header in subkey 0 of first key follows this structure message DHTLog { @@ -62,27 +61,6 @@ message DHTShortArray { // calculated through iteration } -// Reference to data on the DHT -message DHTDataReference { - veilid.TypedKey dht_data = 1; - veilid.TypedKey hash = 2; -} - -// Reference to data on the BlockStore -message BlockStoreDataReference { - veilid.TypedKey block = 1; -} - -// DataReference -// Pointer to data somewhere in Veilid -// Abstraction over DHTData and BlockStore -message DataReference { - oneof kind { - DHTDataReference dht_data = 1; - BlockStoreDataReference block_store_data = 2; - } -} - // A pointer to an child DHT record message OwnedDHTRecordPointer { // DHT Record key diff --git a/packages/veilid_support/lib/dht_support/proto/proto.dart b/packages/veilid_support/lib/dht_support/proto/proto.dart index 6b36970..ceac3d5 100644 --- a/packages/veilid_support/lib/dht_support/proto/proto.dart +++ b/packages/veilid_support/lib/dht_support/proto/proto.dart @@ -1,5 +1,6 @@ import '../../proto/dht.pb.dart' as dhtproto; import '../../proto/proto.dart' as veilidproto; +import '../../src/dynamic_debug.dart'; import '../dht_support.dart'; export '../../proto/dht.pb.dart'; @@ -23,3 +24,44 @@ extension ProtoOwnedDHTRecordPointer on dhtproto.OwnedDHTRecordPointer { OwnedDHTRecordPointer toVeilid() => OwnedDHTRecordPointer( recordKey: recordKey.toVeilid(), owner: owner.toVeilid()); } + +void registerVeilidDHTProtoToDebug() { + dynamic toDebug(dynamic obj) { + if (obj is dhtproto.OwnedDHTRecordPointer) { + return { + r'$runtimeType': obj.runtimeType, + 'recordKey': obj.recordKey, + 'owner': obj.owner, + }; + } + if (obj is dhtproto.DHTData) { + return { + r'$runtimeType': obj.runtimeType, + 'keys': obj.keys, + 'hash': obj.hash, + 'chunk': obj.chunk, + 'size': obj.size + }; + } + if (obj is dhtproto.DHTLog) { + return { + r'$runtimeType': obj.runtimeType, + 'head': obj.head, + 'tail': obj.tail, + 'stride': obj.stride, + }; + } + if (obj is dhtproto.DHTShortArray) { + return { + r'$runtimeType': obj.runtimeType, + 'keys': obj.keys, + 'index': obj.index, + 'seqs': obj.seqs, + }; + } + + return obj; + } + + DynamicDebug.registerToDebug(toDebug); +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log.dart b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log.dart index 71bcfa2..da74df1 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log.dart @@ -7,6 +7,7 @@ import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:meta/meta.dart'; +import '../../../src/veilid_log.dart'; import '../../../veilid_support.dart'; import '../../proto/proto.dart' as proto; @@ -171,32 +172,31 @@ class DHTLog implements DHTDeleteable { /// Add a reference to this log @override - Future ref() async => _mutex.protect(() async { - _openCount++; - }); + void ref() { + _openCount++; + } /// Free all resources for the DHTLog @override - Future close() async => _mutex.protect(() async { - if (_openCount == 0) { - throw StateError('already closed'); - } - _openCount--; - if (_openCount != 0) { - return false; - } - await _watchController?.close(); - _watchController = null; - await _spine.close(); - return true; - }); + Future close() async { + if (_openCount == 0) { + throw StateError('already closed'); + } + _openCount--; + if (_openCount != 0) { + return false; + } + // + await _watchController?.close(); + _watchController = null; + await _spine.close(); + return true; + } /// Free all resources for the DHTLog and delete it from the DHT /// Will wait until the short array is closed to delete it @override - Future delete() async { - await _spine.delete(); - } + Future delete() => _spine.delete(); //////////////////////////////////////////////////////////////////////////// // Public API @@ -211,12 +211,12 @@ class DHTLog implements DHTDeleteable { OwnedDHTRecordPointer get recordPointer => _spine.recordPointer; /// Runs a closure allowing read-only access to the log - Future operate(Future Function(DHTLogReadOperations) closure) async { + Future operate(Future Function(DHTLogReadOperations) closure) { if (!isOpen) { - throw StateError('log is not open"'); + throw StateError('log is not open'); } - return _spine.operate((spine) async { + return _spine.operate((spine) { final reader = _DHTLogRead._(spine); return closure(reader); }); @@ -228,12 +228,12 @@ class DHTLog implements DHTDeleteable { /// Throws DHTOperateException if the write could not be performed /// at this time Future operateAppend( - Future Function(DHTLogWriteOperations) closure) async { + Future Function(DHTLogWriteOperations) closure) { if (!isOpen) { - throw StateError('log is not open"'); + throw StateError('log is not open'); } - return _spine.operateAppend((spine) async { + return _spine.operateAppend((spine) { final writer = _DHTLogWrite._(spine); return closure(writer); }); @@ -243,16 +243,16 @@ class DHTLog implements DHTDeleteable { /// Will execute the closure multiple times if a consistent write to the DHT /// is not achieved. Timeout if specified will be thrown as a /// TimeoutException. The closure should return a value if its changes also - /// succeeded, and throw DHTExceptionTryAgain to trigger another + /// succeeded, and throw DHTExceptionOutdated to trigger another /// eventual consistency pass. Future operateAppendEventual( Future Function(DHTLogWriteOperations) closure, - {Duration? timeout}) async { + {Duration? timeout}) { if (!isOpen) { - throw StateError('log is not open"'); + throw StateError('log is not open'); } - return _spine.operateAppendEventual((spine) async { + return _spine.operateAppendEventual((spine) { final writer = _DHTLogWrite._(spine); return closure(writer); }, timeout: timeout); @@ -264,7 +264,7 @@ class DHTLog implements DHTDeleteable { void Function(DHTLogUpdate) onChanged, ) { if (!isOpen) { - throw StateError('log is not open"'); + throw StateError('log is not open'); } return _listenMutex.protect(() async { @@ -293,11 +293,12 @@ class DHTLog implements DHTDeleteable { //////////////////////////////////////////////////////////////// // Fields - // 56 subkeys * 512 segments * 36 bytes per typedkey = - // 1032192 bytes per record + // 55 subkeys * 512 segments * 36 bytes per typedkey = + // 1013760 bytes per record + // Leaves 34816 bytes for 0th subkey as head, 56 subkeys total // 512*36 = 18432 bytes per subkey - // 28672 shortarrays * 256 elements = 7340032 elements - static const spineSubkeys = 56; + // 28160 shortarrays * 256 elements = 7208960 elements + static const spineSubkeys = 55; static const segmentsPerSubkey = 512; // Internal representation refreshed from spine record @@ -305,10 +306,9 @@ class DHTLog implements DHTDeleteable { // Openable int _openCount; - final _mutex = Mutex(); // Watch mutex to ensure we keep the representation valid - final Mutex _listenMutex = Mutex(); + final _listenMutex = Mutex(debugLockTimeout: kIsDebugMode ? 60 : null); // Stream of external changes StreamController? _watchController; } diff --git a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_cubit.dart b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_cubit.dart index 570474f..3a618dc 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_cubit.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_cubit.dart @@ -31,25 +31,47 @@ class DHTLogStateData extends Equatable { @override List get props => [length, window, windowTail, windowSize, follow]; + + @override + String toString() => 'DHTLogStateData(' + 'length: $length, ' + 'windowTail: $windowTail, ' + 'windowSize: $windowSize, ' + 'follow: $follow, ' + 'window: ${DynamicDebug.toDebug(window)})'; } typedef DHTLogState = AsyncValue>; typedef DHTLogBusyState = BlocBusyState>; class DHTLogCubit extends Cubit> - with BlocBusyWrapper> { + with BlocBusyWrapper>, RefreshableCubit { DHTLogCubit({ required Future Function() open, required T Function(List data) decodeElement, }) : _decodeElement = decodeElement, super(const BlocBusyState(AsyncValue.loading())) { - _initWait.add(() async { - // Open DHT record - _log = await open(); - _wantsCloseRecord = true; - + _initWait.add((cancel) async { + try { + // Do record open/create + while (!cancel.isCompleted) { + try { + // Open DHT record + _log = await open(); + _wantsCloseRecord = true; + break; + } on DHTExceptionNotAvailable { + // Wait for a bit + await asyncSleep(); + } + } + } on Exception catch (e, st) { + addError(e, st); + emit(DHTLogBusyState(AsyncValue.error(e, st))); + return; + } // Make initial state update - await _refreshNoWait(); + _initialUpdate(); _subscription = await _log.listen(_update); }); } @@ -78,85 +100,73 @@ class DHTLogCubit extends Cubit> await _refreshNoWait(forceRefresh: forceRefresh); } + @override Future refresh({bool forceRefresh = false}) async { await _initWait(); await _refreshNoWait(forceRefresh: forceRefresh); } - Future _refreshNoWait({bool forceRefresh = false}) async => - busy((emit) async => _refreshInner(emit, forceRefresh: forceRefresh)); + Future _refreshNoWait({bool forceRefresh = false}) => + busy((emit) => _refreshInner(emit, forceRefresh: forceRefresh)); Future _refreshInner(void Function(AsyncValue>) emit, {bool forceRefresh = false}) async { - late final AsyncValue>> avElements; late final int length; - await _log.operate((reader) async { + final windowElements = await _log.operate((reader) { length = reader.length; - avElements = - await loadElementsFromReader(reader, _windowTail, _windowSize); + return _loadElementsFromReader(reader, _windowTail, _windowSize); }); - final err = avElements.asError; - if (err != null) { - emit(AsyncValue.error(err.error, err.stackTrace)); + if (windowElements == null) { + setWantsRefresh(); return; } - final loading = avElements.asLoading; - if (loading != null) { - emit(const AsyncValue.loading()); - return; - } - final window = avElements.asData!.value; + emit(AsyncValue.data(DHTLogStateData( length: length, - window: window, - windowTail: _windowTail, - windowSize: _windowSize, + window: windowElements.$2, + windowTail: windowElements.$1 + windowElements.$2.length, + windowSize: windowElements.$2.length, follow: _follow))); + setRefreshed(); } // Tail is one past the last element to load - Future>>> loadElementsFromReader( + Future<(int, IList>)?> _loadElementsFromReader( DHTLogReadOperations reader, int tail, int count, {bool forceRefresh = false}) async { - try { - final length = reader.length; - if (length == 0) { - return const AsyncValue.data(IList.empty()); - } - final end = ((tail - 1) % length) + 1; - final start = (count < end) ? end - count : 0; - - // If this is writeable get the offline positions - Set? offlinePositions; - if (_log.writer != null) { - offlinePositions = await reader.getOfflinePositions(); - if (offlinePositions == null) { - return const AsyncValue.loading(); - } - } - - // Get the items - final allItems = (await reader.getRange(start, - length: end - start, forceRefresh: forceRefresh)) - ?.indexed - .map((x) => OnlineElementState( - value: _decodeElement(x.$2), - isOffline: offlinePositions?.contains(x.$1) ?? false)) - .toIList(); - if (allItems == null) { - return const AsyncValue.loading(); - } - return AsyncValue.data(allItems); - } on Exception catch (e, st) { - return AsyncValue.error(e, st); + final length = reader.length; + final end = ((tail - 1) % length) + 1; + final start = (count < end) ? end - count : 0; + if (length == 0) { + return (start, IList>.empty()); } + + // If this is writeable get the offline positions + Set? offlinePositions; + if (_log.writer != null) { + offlinePositions = await reader.getOfflinePositions(); + } + + // Get the items + final allItems = (await reader.getRange(start, + length: end - start, forceRefresh: forceRefresh)) + ?.indexed + .map((x) => OnlineElementState( + value: _decodeElement(x.$2), + isOffline: offlinePositions?.contains(x.$1) ?? false)) + .toIList(); + if (allItems == null) { + return null; + } + + return (start, allItems); } void _update(DHTLogUpdate upd) { // Run at most one background update process // Because this is async, we could get an update while we're // still processing the last one. Only called after init future has run - // so we dont have to wait for that here. + // or during it, so we dont have to wait for that here. // Accumulate head and tail deltas _headDelta += upd.headDelta; @@ -188,9 +198,15 @@ class DHTLogCubit extends Cubit> }); } + void _initialUpdate() { + _sspUpdate.busyUpdate>(busy, (emit) async { + await _refreshInner(emit); + }); + } + @override Future close() async { - await _initWait(); + await _initWait(cancelValue: true); await _subscription?.cancel(); _subscription = null; if (_wantsCloseRecord) { @@ -217,11 +233,11 @@ class DHTLogCubit extends Cubit> return _log.operateAppendEventual(closure, timeout: timeout); } - final WaitSet _initWait = WaitSet(); + final WaitSet _initWait = WaitSet(); late final DHTLog _log; final T Function(List data) _decodeElement; StreamSubscription? _subscription; - bool _wantsCloseRecord = false; + var _wantsCloseRecord = false; final _sspUpdate = SingleStatelessProcessor(); // Accumulated deltas since last update diff --git a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_read.dart b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_read.dart index d7b3541..3ebb2b8 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_read.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_read.dart @@ -21,8 +21,14 @@ class _DHTLogRead implements DHTLogReadOperations { return null; } - return lookup.scope((sa) => - sa.operate((read) => read.get(lookup.pos, forceRefresh: forceRefresh))); + return lookup.scope((sa) => sa.operate((read) async { + if (lookup.pos >= read.length) { + veilidLoggy.error('DHTLog shortarray read @ ${lookup.pos}' + ' >= length ${read.length}'); + return null; + } + return read.get(lookup.pos, forceRefresh: forceRefresh); + })); } (int, int) _clampStartLen(int start, int? len) { @@ -45,39 +51,56 @@ class _DHTLogRead implements DHTLogReadOperations { final out = []; (start, length) = _clampStartLen(start, length); - final chunks = Iterable.generate(length).slices(maxDHTConcurrency).map( - (chunk) => - chunk.map((pos) => get(pos + start, forceRefresh: forceRefresh))); + final chunks = Iterable.generate(length) + .slices(kMaxDHTConcurrency) + .map((chunk) => chunk.map((pos) async { + try { + return await get(pos + start, forceRefresh: forceRefresh); + // Need some way to debug ParallelWaitError + // ignore: avoid_catches_without_on_clauses + } catch (e, st) { + veilidLoggy.error('$e\n$st\n'); + rethrow; + } + })); for (final chunk in chunks) { - final elems = await chunk.wait; - if (elems.contains(null)) { - return null; + var elems = await chunk.wait; + + // Return only the first contiguous range, anything else is garbage + // due to a representational error in the head or shortarray legnth + final nullPos = elems.indexOf(null); + if (nullPos != -1) { + elems = elems.sublist(0, nullPos); } + out.addAll(elems.cast()); + + if (nullPos != -1) { + break; + } } return out; } @override - Future?> getOfflinePositions() async { + Future> getOfflinePositions() async { final positionOffline = {}; // Iterate positions backward from most recent for (var pos = _spine.length - 1; pos >= 0; pos--) { + // Get position final lookup = await _spine.lookupPosition(pos); + // If position doesn't exist then it definitely wasn't written to offline if (lookup == null) { - return null; + continue; } // Check each segment for offline positions var foundOffline = false; - final success = await lookup.scope((sa) => sa.operate((read) async { + await lookup.scope((sa) => sa.operate((read) async { final segmentOffline = await read.getOfflinePositions(); - if (segmentOffline == null) { - return false; - } // For each shortarray segment go through their segment positions // in reverse order and see if they are offline @@ -91,11 +114,7 @@ class _DHTLogRead implements DHTLogReadOperations { foundOffline = true; } } - return true; })); - if (!success) { - return null; - } // If we found nothing offline in this segment then we can stop if (!foundOffline) { break; diff --git a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_spine.dart b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_spine.dart index 3105fa8..ce231e2 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_spine.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_spine.dart @@ -24,13 +24,11 @@ class _DHTLogPosition extends DHTCloseable { /// Add a reference to this log @override - Future ref() async { - await shortArray.ref(); - } + void ref() => shortArray.ref(); /// Free all resources for the DHTLogPosition @override - Future close() async => _dhtLogSpine._segmentClosed(_segmentNumber); + Future close() => _dhtLogSpine._segmentClosed(_segmentNumber); } class _DHTLogSegmentLookup extends Equatable { @@ -46,6 +44,8 @@ class _SubkeyData { _SubkeyData({required this.subkey, required this.data}); int subkey; Uint8List data; + // lint conflict + // ignore: omit_obvious_property_types bool changed = false; } @@ -71,6 +71,12 @@ class _DHTLogSpine { // Write new spine head record to the network await spine.operate((spine) async { + // Write first empty subkey + final subkeyData = _makeEmptySubkey(); + final existingSubkeyData = + await spineRecord.tryWriteBytes(subkeyData, subkey: 1); + assert(existingSubkeyData == null, 'Should never conflict on create'); + final success = await spine.writeSpineHead(); assert(success, 'false return should never happen on create'); }); @@ -118,20 +124,13 @@ class _DHTLogSpine { }); } - Future delete() async { - await _spineMutex.protect(() async { - // Will deep delete all segment records as they are children - await _spineRecord.delete(); - }); - } + // Will deep delete all segment records as they are children + Future delete() => _spineMutex.protect(_spineRecord.delete); - Future operate(Future Function(_DHTLogSpine) closure) async => - // ignore: prefer_expression_function_bodies - _spineMutex.protect(() async { - return closure(this); - }); + Future operate(Future Function(_DHTLogSpine) closure) => + _spineMutex.protect(() => closure(this)); - Future operateAppend(Future Function(_DHTLogSpine) closure) async => + Future operateAppend(Future Function(_DHTLogSpine) closure) => _spineMutex.protect(() async { final oldHead = _head; final oldTail = _tail; @@ -141,7 +140,7 @@ class _DHTLogSpine { if (!await writeSpineHead(old: (oldHead, oldTail))) { // Failed to write head means head got overwritten so write should // be considered failed - throw DHTExceptionTryAgain(); + throw const DHTExceptionOutdated(); } return out; } on Exception { @@ -153,7 +152,7 @@ class _DHTLogSpine { }); Future operateAppendEventual(Future Function(_DHTLogSpine) closure, - {Duration? timeout}) async { + {Duration? timeout}) { final timeoutTs = timeout == null ? null : Veilid.instance.now().offset(TimestampDuration.fromDuration(timeout)); @@ -181,7 +180,7 @@ class _DHTLogSpine { try { out = await closure(this); break; - } on DHTExceptionTryAgain { + } on DHTExceptionOutdated { // Failed to write in closure resets state _head = oldHead; _tail = oldTail; @@ -251,7 +250,13 @@ class _DHTLogSpine { final headDelta = _ringDistance(newHead, oldHead); final tailDelta = _ringDistance(newTail, oldTail); if (headDelta > _positionLimit ~/ 2 || tailDelta > _positionLimit ~/ 2) { - throw DHTExceptionInvalidData(); + throw DHTExceptionInvalidData( + cause: '_DHTLogSpine::_updateHead ' + '_head=$_head _tail=$_tail ' + 'oldHead=$oldHead oldTail=$oldTail ' + 'newHead=$newHead newTail=$newTail ' + 'headDelta=$headDelta tailDelta=$tailDelta ' + '_positionLimit=$_positionLimit'); } } @@ -262,7 +267,7 @@ class _DHTLogSpine { ///////////////////////////////////////////////////////////////////////////// // Spine element management - static final Uint8List _emptySegmentKey = + static final _emptySegmentKey = Uint8List.fromList(List.filled(TypedKey.decodedLength(), 0)); static Uint8List _makeEmptySubkey() => Uint8List.fromList(List.filled( DHTLog.segmentsPerSubkey * TypedKey.decodedLength(), 0)); @@ -290,7 +295,7 @@ class _DHTLogSpine { segmentKeyBytes); } - Future _openOrCreateSegment(int segmentNumber) async { + Future _openOrCreateSegment(int segmentNumber) async { assert(_spineMutex.isLocked, 'should be in mutex here'); assert(_spineRecord.writer != null, 'should be writable'); @@ -300,51 +305,56 @@ class _DHTLogSpine { final subkey = l.subkey; final segment = l.segment; - var subkeyData = await _spineRecord.get(subkey: subkey); - subkeyData ??= _makeEmptySubkey(); - while (true) { - final segmentKey = _getSegmentKey(subkeyData!, segment); - if (segmentKey == null) { - // Create a shortarray segment - final segmentRec = await DHTShortArray.create( - debugName: '${_spineRecord.debugName}_spine_${subkey}_$segment', - stride: _segmentStride, - crypto: _spineRecord.crypto, - parent: _spineRecord.key, - routingContext: _spineRecord.routingContext, - writer: _spineRecord.writer, - ); - var success = false; - try { - // Write it back to the spine record - _setSegmentKey(subkeyData, segment, segmentRec.recordKey); - subkeyData = - await _spineRecord.tryWriteBytes(subkeyData, subkey: subkey); - // If the write was successful then we're done - if (subkeyData == null) { - // Return it - success = true; - return segmentRec; - } - } finally { - if (!success) { - await segmentRec.close(); - await segmentRec.delete(); + try { + var subkeyData = await _spineRecord.get(subkey: subkey); + subkeyData ??= _makeEmptySubkey(); + + while (true) { + final segmentKey = _getSegmentKey(subkeyData!, segment); + if (segmentKey == null) { + // Create a shortarray segment + final segmentRec = await DHTShortArray.create( + debugName: '${_spineRecord.debugName}_spine_${subkey}_$segment', + stride: _segmentStride, + crypto: _spineRecord.crypto, + parent: _spineRecord.key, + routingContext: _spineRecord.routingContext, + writer: _spineRecord.writer, + ); + var success = false; + try { + // Write it back to the spine record + _setSegmentKey(subkeyData, segment, segmentRec.recordKey); + subkeyData = + await _spineRecord.tryWriteBytes(subkeyData, subkey: subkey); + // If the write was successful then we're done + if (subkeyData == null) { + // Return it + success = true; + return segmentRec; + } + } finally { + if (!success) { + await segmentRec.close(); + await segmentRec.delete(); + } } + } else { + // Open a shortarray segment + final segmentRec = await DHTShortArray.openWrite( + segmentKey, + _spineRecord.writer!, + debugName: '${_spineRecord.debugName}_spine_${subkey}_$segment', + crypto: _spineRecord.crypto, + parent: _spineRecord.key, + routingContext: _spineRecord.routingContext, + ); + return segmentRec; } - } else { - // Open a shortarray segment - final segmentRec = await DHTShortArray.openWrite( - segmentKey, - _spineRecord.writer!, - debugName: '${_spineRecord.debugName}_spine_${subkey}_$segment', - crypto: _spineRecord.crypto, - parent: _spineRecord.key, - routingContext: _spineRecord.routingContext, - ); - return segmentRec; + // Loop if we need to try again with the new data from the network } - // Loop if we need to try again with the new data from the network + } on DHTExceptionNotAvailable { + return null; } } @@ -358,34 +368,38 @@ class _DHTLogSpine { final segment = l.segment; // See if we have the segment key locally - TypedKey? segmentKey; - var subkeyData = await _spineRecord.get( - subkey: subkey, refreshMode: DHTRecordRefreshMode.local); - if (subkeyData != null) { - segmentKey = _getSegmentKey(subkeyData, segment); - } - if (segmentKey == null) { - // If not, try from the network - subkeyData = await _spineRecord.get( - subkey: subkey, refreshMode: DHTRecordRefreshMode.network); - if (subkeyData == null) { - return null; + try { + TypedKey? segmentKey; + var subkeyData = await _spineRecord.get( + subkey: subkey, refreshMode: DHTRecordRefreshMode.local); + if (subkeyData != null) { + segmentKey = _getSegmentKey(subkeyData, segment); } - segmentKey = _getSegmentKey(subkeyData, segment); if (segmentKey == null) { - return null; + // If not, try from the network + subkeyData = await _spineRecord.get( + subkey: subkey, refreshMode: DHTRecordRefreshMode.network); + if (subkeyData == null) { + return null; + } + segmentKey = _getSegmentKey(subkeyData, segment); + if (segmentKey == null) { + return null; + } } - } - // Open a shortarray segment - final segmentRec = await DHTShortArray.openRead( - segmentKey, - debugName: '${_spineRecord.debugName}_spine_${subkey}_$segment', - crypto: _spineRecord.crypto, - parent: _spineRecord.key, - routingContext: _spineRecord.routingContext, - ); - return segmentRec; + // Open a shortarray segment + final segmentRec = await DHTShortArray.openRead( + segmentKey, + debugName: '${_spineRecord.debugName}_spine_${subkey}_$segment', + crypto: _spineRecord.crypto, + parent: _spineRecord.key, + routingContext: _spineRecord.routingContext, + ); + return segmentRec; + } on DHTExceptionNotAvailable { + return null; + } } _DHTLogSegmentLookup _lookupSegment(int segmentNumber) { @@ -409,14 +423,14 @@ class _DHTLogSpine { Future<_DHTLogPosition?> lookupPositionBySegmentNumber( int segmentNumber, int segmentPos, - {bool onlyOpened = false}) async => + {bool onlyOpened = false}) => _spineCacheMutex.protect(() async { // See if we have this segment opened already final openedSegment = _openedSegments[segmentNumber]; late DHTShortArray shortArray; if (openedSegment != null) { // If so, return a ref - await openedSegment.ref(); + openedSegment.ref(); shortArray = openedSegment; } else { // Otherwise open a segment @@ -438,7 +452,7 @@ class _DHTLogSpine { // LRU cache the segment number if (!_openCache.remove(segmentNumber)) { // If this is new to the cache ref it when it goes in - await shortArray.ref(); + shortArray.ref(); } _openCache.add(segmentNumber); if (_openCache.length > _openCacheSize) { @@ -457,7 +471,7 @@ class _DHTLogSpine { segmentNumber: segmentNumber); }); - Future<_DHTLogPosition?> lookupPosition(int pos) async { + Future<_DHTLogPosition?> lookupPosition(int pos) { assert(_spineMutex.isLocked, 'should be locked'); // Check if our position is in bounds @@ -476,7 +490,7 @@ class _DHTLogSpine { return lookupPositionBySegmentNumber(segmentNumber, segmentPos); } - Future _segmentClosed(int segmentNumber) async { + Future _segmentClosed(int segmentNumber) { assert(_spineMutex.isLocked, 'should be locked'); return _spineCacheMutex.protect(() async { final sa = _openedSegments[segmentNumber]!; @@ -597,13 +611,13 @@ class _DHTLogSpine { Future watch() async { // This will update any existing watches if necessary try { - await _spineRecord.watch(subkeys: [ValueSubkeyRange.single(0)]); - // Update changes to the head record - // Don't watch for local changes because this class already handles - // notifying listeners and knows when it makes local changes + // xxx: check if this localChanges can be false... + // xxx: Don't watch for local changes because this class already handles + // xxx: notifying listeners and knows when it makes local changes _subscription ??= await _spineRecord.listen(localChanges: false, _onSpineChanged); + await _spineRecord.watch(subkeys: [ValueSubkeyRange.single(0)]); } on Exception { // If anything fails, try to cancel the watches await cancelWatch(); @@ -698,7 +712,7 @@ class _DHTLogSpine { DHTShortArray.maxElements; // Spine head mutex to ensure we keep the representation valid - final Mutex _spineMutex = Mutex(); + final _spineMutex = Mutex(debugLockTimeout: kIsDebugMode ? 60 : null); // Subscription to head record internal changes StreamSubscription? _subscription; // Notify closure for external spine head changes @@ -718,8 +732,8 @@ class _DHTLogSpine { // LRU cache of DHT spine elements accessed recently // Pair of position and associated shortarray segment - final Mutex _spineCacheMutex = Mutex(); + final _spineCacheMutex = Mutex(debugLockTimeout: kIsDebugMode ? 60 : null); final List _openCache; final Map _openedSegments; - static const int _openCacheSize = 3; + static const _openCacheSize = 3; } diff --git a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_write.dart b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_write.dart index ca47e00..590fbb2 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_write.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_write.dart @@ -17,7 +17,9 @@ class _DHTLogWrite extends _DHTLogRead implements DHTLogWriteOperations { } final lookup = await _spine.lookupPosition(pos); if (lookup == null) { - throw DHTExceptionInvalidData(); + throw DHTExceptionInvalidData( + cause: '_DHTLogRead::tryWriteItem pos=$pos ' + '_spine.length=${_spine.length}'); } // Write item to the segment @@ -26,64 +28,15 @@ class _DHTLogWrite extends _DHTLogRead implements DHTLogWriteOperations { final success = await write.tryWriteItem(lookup.pos, newValue, output: output); if (!success) { - throw DHTExceptionTryAgain(); + throw const DHTExceptionOutdated(); } })); - } on DHTExceptionTryAgain { + } on DHTExceptionOutdated { return false; } return true; } - @override - Future swap(int aPos, int bPos) async { - if (aPos < 0 || aPos >= _spine.length) { - throw IndexError.withLength(aPos, _spine.length); - } - if (bPos < 0 || bPos >= _spine.length) { - throw IndexError.withLength(bPos, _spine.length); - } - final aLookup = await _spine.lookupPosition(aPos); - if (aLookup == null) { - throw DHTExceptionInvalidData(); - } - final bLookup = await _spine.lookupPosition(bPos); - if (bLookup == null) { - await aLookup.close(); - throw DHTExceptionInvalidData(); - } - - // Swap items in the segments - if (aLookup.shortArray == bLookup.shortArray) { - await bLookup.close(); - return aLookup.scope((sa) => sa.operateWriteEventual( - (aWrite) async => aWrite.swap(aLookup.pos, bLookup.pos))); - } else { - final bItem = Output(); - return aLookup.scope( - (sa) => bLookup.scope((sb) => sa.operateWriteEventual((aWrite) async { - if (bItem.value == null) { - final aItem = await aWrite.get(aLookup.pos); - if (aItem == null) { - throw DHTExceptionInvalidData(); - } - await sb.operateWriteEventual((bWrite) async { - final success = await bWrite - .tryWriteItem(bLookup.pos, aItem, output: bItem); - if (!success) { - throw DHTExceptionTryAgain(); - } - }); - } - final success = - await aWrite.tryWriteItem(aLookup.pos, bItem.value!); - if (!success) { - throw DHTExceptionTryAgain(); - } - }))); - } - } - @override Future add(Uint8List value) async { // Allocate empty index at the end of the list @@ -95,13 +48,13 @@ class _DHTLogWrite extends _DHTLogRead implements DHTLogWriteOperations { } // Write item to the segment - return lookup.scope((sa) async => sa.operateWrite((write) async { + return lookup.scope((sa) => sa.operateWrite((write) async { // If this a new segment, then clear it in case we have wrapped around if (lookup.pos == 0) { await write.clear(); } else if (lookup.pos != write.length) { // We should always be appending at the length - throw DHTExceptionInvalidData(); + await write.truncate(lookup.pos); } return write.add(value); })); @@ -114,45 +67,55 @@ class _DHTLogWrite extends _DHTLogRead implements DHTLogWriteOperations { _spine.allocateTail(values.length); // Look up the first position and shortarray - final dws = DelayedWaitSet(); + final dws = DelayedWaitSet(); var success = true; - for (var valueIdx = 0; valueIdx < values.length;) { + for (var valueIdxIter = 0; valueIdxIter < values.length;) { + final valueIdx = valueIdxIter; final remaining = values.length - valueIdx; final lookup = await _spine.lookupPosition(insertPos + valueIdx); if (lookup == null) { - throw DHTExceptionInvalidData(); + throw DHTExceptionInvalidData( + cause: '_DHTLogWrite::addAll ' + '_spine.length=${_spine.length}' + 'insertPos=$insertPos valueIdx=$valueIdx ' + 'values.length=${values.length} '); } final sacount = min(remaining, DHTShortArray.maxElements - lookup.pos); final sublistValues = values.sublist(valueIdx, valueIdx + sacount); - dws.add(() async { + dws.add((_) async { try { - await lookup.scope((sa) async => sa.operateWrite((write) async { + await lookup.scope((sa) => sa.operateWrite((write) async { // If this a new segment, then clear it in // case we have wrapped around if (lookup.pos == 0) { await write.clear(); } else if (lookup.pos != write.length) { // We should always be appending at the length - throw DHTExceptionInvalidData(); + await write.truncate(lookup.pos); } - return write.addAll(sublistValues); + await write.addAll(sublistValues); + success = true; })); - } on DHTExceptionTryAgain { + } on DHTExceptionOutdated { success = false; + // Need some way to debug ParallelWaitError + // ignore: avoid_catches_without_on_clauses + } catch (e, st) { + veilidLoggy.error('$e\n$st\n'); } }); - valueIdx += sacount; + valueIdxIter += sacount; } await dws(); if (!success) { - throw DHTExceptionTryAgain(); + throw const DHTExceptionOutdated(); } } diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/barrel.dart b/packages/veilid_support/lib/dht_support/src/dht_record/barrel.dart index 06933be..2d7e677 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_record/barrel.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_record/barrel.dart @@ -1,3 +1,4 @@ export 'default_dht_record_cubit.dart'; export 'dht_record_cubit.dart'; export 'dht_record_pool.dart'; +export 'stats.dart'; diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/default_dht_record_cubit.dart b/packages/veilid_support/lib/dht_support/src/dht_record/default_dht_record_cubit.dart index a333160..3d396d2 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_record/default_dht_record_cubit.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_record/default_dht_record_cubit.dart @@ -6,22 +6,14 @@ import '../../../veilid_support.dart'; class DefaultDHTRecordCubit extends DHTRecordCubit { DefaultDHTRecordCubit({ required super.open, - required T Function(List data) decodeState, + required T Function(Uint8List data) decodeState, }) : super( initialStateFunction: _makeInitialStateFunction(decodeState), stateFunction: _makeStateFunction(decodeState), watchFunction: _makeWatchFunction()); - // DefaultDHTRecordCubit.value({ - // required super.record, - // required T Function(List data) decodeState, - // }) : super.value( - // initialStateFunction: _makeInitialStateFunction(decodeState), - // stateFunction: _makeStateFunction(decodeState), - // watchFunction: _makeWatchFunction()); - static InitialStateFunction _makeInitialStateFunction( - T Function(List data) decodeState) => + T Function(Uint8List data) decodeState) => (record) async { final initialData = await record.get(); if (initialData == null) { @@ -31,7 +23,7 @@ class DefaultDHTRecordCubit extends DHTRecordCubit { }; static StateFunction _makeStateFunction( - T Function(List data) decodeState) => + T Function(Uint8List data) decodeState) => (record, subkeys, updatedata) async { final defaultSubkey = record.subkeyOrDefault(-1); if (subkeys.containsSubkey(defaultSubkey)) { @@ -60,8 +52,11 @@ class DefaultDHTRecordCubit extends DHTRecordCubit { Future refreshDefault() async { await initWait(); - - final defaultSubkey = record.subkeyOrDefault(-1); - await refresh([ValueSubkeyRange(low: defaultSubkey, high: defaultSubkey)]); + final rec = record; + if (rec != null) { + final defaultSubkey = rec.subkeyOrDefault(-1); + await refresh( + [ValueSubkeyRange(low: defaultSubkey, high: defaultSubkey)]); + } } } diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record.dart index cd6c859..6fb9d9e 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record.dart @@ -1,7 +1,5 @@ part of 'dht_record_pool.dart'; -const _sfListen = 'listen'; - @immutable class DHTRecordWatchChange extends Equatable { const DHTRecordWatchChange( @@ -41,7 +39,7 @@ enum DHTRecordRefreshMode { class DHTRecord implements DHTDeleteable { DHTRecord._( {required VeilidRoutingContext routingContext, - required SharedDHTRecordData sharedDHTRecordData, + required _SharedDHTRecordData sharedDHTRecordData, required int defaultSubkey, required KeyPair? writer, required VeilidCrypto crypto, @@ -66,34 +64,35 @@ class DHTRecord implements DHTDeleteable { /// Add a reference to this DHTRecord @override - Future ref() async => _mutex.protect(() async { - _openCount++; - }); + void ref() { + _openCount++; + } /// Free all resources for the DHTRecord @override - Future close() async => _mutex.protect(() async { - if (_openCount == 0) { - throw StateError('already closed'); - } - _openCount--; - if (_openCount != 0) { - return false; - } + Future close() async { + if (_openCount == 0) { + throw StateError('already closed'); + } + _openCount--; + if (_openCount != 0) { + return false; + } - await serialFuturePause((this, _sfListen)); - await _watchController?.close(); - _watchController = null; - await DHTRecordPool.instance._recordClosed(this); - return true; - }); + await _watchController?.close(); + _watchController = null; + await serialFutureClose((this, _sfListen)); + + await DHTRecordPool.instance._recordClosed(this); + + return true; + } /// Free all resources for the DHTRecord and delete it from the DHT - /// Will wait until the record is closed to delete it + /// Returns true if the deletion was processed immediately + /// Returns false if the deletion was marked for later @override - Future delete() async => _mutex.protect(() async { - await DHTRecordPool.instance.deleteRecord(key); - }); + Future delete() => DHTRecordPool.instance.deleteRecord(key); //////////////////////////////////////////////////////////////////////////// // Public API @@ -120,41 +119,56 @@ class DHTRecord implements DHTDeleteable { /// * 'outSeqNum' optionally returns the sequence number of the value being /// returned if one was returned. Future get( - {int subkey = -1, - VeilidCrypto? crypto, - DHTRecordRefreshMode refreshMode = DHTRecordRefreshMode.cached, - Output? outSeqNum}) async { - subkey = subkeyOrDefault(subkey); + {int subkey = -1, + VeilidCrypto? crypto, + DHTRecordRefreshMode refreshMode = DHTRecordRefreshMode.cached, + Output? outSeqNum}) => + _wrapStats('get', () async { + subkey = subkeyOrDefault(subkey); - // Get the last sequence number if we need it - final lastSeq = - refreshMode._inspectLocal ? await _localSubkeySeq(subkey) : null; + // Get the last sequence number if we need it + final lastSeq = + refreshMode._inspectLocal ? await _localSubkeySeq(subkey) : null; - // See if we only ever want the locally stored value - if (refreshMode == DHTRecordRefreshMode.local && lastSeq == null) { - // If it's not available locally already just return null now - return null; - } + // See if we only ever want the locally stored value + if (refreshMode == DHTRecordRefreshMode.local && lastSeq == null) { + // If it's not available locally already just return null now + return null; + } - final valueData = await _routingContext.getDHTValue(key, subkey, - forceRefresh: refreshMode._forceRefresh); - if (valueData == null) { - return null; - } - // See if this get resulted in a newer sequence number - if (refreshMode == DHTRecordRefreshMode.update && - lastSeq != null && - valueData.seq <= lastSeq) { - // If we're only returning updates then punt now - return null; - } - // If we're returning a value, decrypt it - final out = (crypto ?? _crypto).decrypt(valueData.data); - if (outSeqNum != null) { - outSeqNum.save(valueData.seq); - } - return out; - } + var retry = kDHTTryAgainTries; + ValueData? valueData; + while (true) { + try { + valueData = await _routingContext.getDHTValue(key, subkey, + forceRefresh: refreshMode._forceRefresh); + break; + } on VeilidAPIExceptionTryAgain { + retry--; + if (retry == 0) { + throw const DHTExceptionNotAvailable(); + } + await asyncSleep(); + } + } + if (valueData == null) { + return null; + } + + // See if this get resulted in a newer sequence number + if (refreshMode == DHTRecordRefreshMode.update && + lastSeq != null && + valueData.seq <= lastSeq) { + // If we're only returning updates then punt now + return null; + } + // If we're returning a value, decrypt it + final out = (crypto ?? _crypto).decrypt(valueData.data); + if (outSeqNum != null) { + outSeqNum.save(valueData.seq); + } + return out; + }); /// Get a subkey value from this record. /// Process the record returned with a JSON unmarshal function 'fromJson'. @@ -210,97 +224,105 @@ class DHTRecord implements DHTDeleteable { /// If a newer value was found on the network, it is returned /// If the value was succesfully written, null is returned Future tryWriteBytes(Uint8List newValue, - {int subkey = -1, - VeilidCrypto? crypto, - KeyPair? writer, - Output? outSeqNum}) async { - subkey = subkeyOrDefault(subkey); - final lastSeq = await _localSubkeySeq(subkey); - final encryptedNewValue = await (crypto ?? _crypto).encrypt(newValue); + {int subkey = -1, + VeilidCrypto? crypto, + SetDHTValueOptions? options, + Output? outSeqNum}) => + _wrapStats('tryWriteBytes', () async { + subkey = subkeyOrDefault(subkey); + final lastSeq = await _localSubkeySeq(subkey); + final encryptedNewValue = await (crypto ?? _crypto).encrypt(newValue); - // Set the new data if possible - var newValueData = await _routingContext - .setDHTValue(key, subkey, encryptedNewValue, writer: writer ?? _writer); - if (newValueData == null) { - // A newer value wasn't found on the set, but - // we may get a newer value when getting the value for the sequence number - newValueData = await _routingContext.getDHTValue(key, subkey); - if (newValueData == null) { - assert(newValueData != null, "can't get value that was just set"); - return null; - } - } + // Set the new data if possible + var newValueData = await _routingContext.setDHTValue( + key, subkey, encryptedNewValue, + options: SetDHTValueOptions( + writer: options?.writer ?? _writer, + allowOffline: options?.allowOffline)); + if (newValueData == null) { + // A newer value wasn't found on the set, but we may get a newer value + // when getting the value for the sequence number + newValueData = await _routingContext.getDHTValue(key, subkey); + if (newValueData == null) { + assert(newValueData != null, "can't get value that was just set"); + return null; + } + } - // Record new sequence number - final isUpdated = newValueData.seq != lastSeq; - if (isUpdated && outSeqNum != null) { - outSeqNum.save(newValueData.seq); - } + // Record new sequence number + final isUpdated = newValueData.seq != lastSeq; + if (isUpdated && outSeqNum != null) { + outSeqNum.save(newValueData.seq); + } - // See if the encrypted data returned is exactly the same - // if so, shortcut and don't bother decrypting it - if (newValueData.data.equals(encryptedNewValue)) { - if (isUpdated) { - DHTRecordPool.instance.processLocalValueChange(key, newValue, subkey); - } - return null; - } + // See if the encrypted data returned is exactly the same + // if so, shortcut and don't bother decrypting it + if (newValueData.data.equals(encryptedNewValue)) { + if (isUpdated) { + DHTRecordPool.instance + ._processLocalValueChange(key, newValue, subkey); + } + return null; + } - // Decrypt value to return it - final decryptedNewValue = - await (crypto ?? _crypto).decrypt(newValueData.data); - if (isUpdated) { - DHTRecordPool.instance - .processLocalValueChange(key, decryptedNewValue, subkey); - } - return decryptedNewValue; - } + // Decrypt value to return it + final decryptedNewValue = + await (crypto ?? _crypto).decrypt(newValueData.data); + if (isUpdated) { + DHTRecordPool.instance + ._processLocalValueChange(key, decryptedNewValue, subkey); + } + return decryptedNewValue; + }); /// Attempt to write a byte buffer to a DHTRecord subkey /// If a newer value was found on the network, another attempt /// will be made to write the subkey until this succeeds Future eventualWriteBytes(Uint8List newValue, - {int subkey = -1, - VeilidCrypto? crypto, - KeyPair? writer, - Output? outSeqNum}) async { - subkey = subkeyOrDefault(subkey); - final lastSeq = await _localSubkeySeq(subkey); - final encryptedNewValue = await (crypto ?? _crypto).encrypt(newValue); + {int subkey = -1, + VeilidCrypto? crypto, + KeyPair? writer, + Output? outSeqNum}) => + _wrapStats('eventualWriteBytes', () async { + subkey = subkeyOrDefault(subkey); + final lastSeq = await _localSubkeySeq(subkey); + final encryptedNewValue = await (crypto ?? _crypto).encrypt(newValue); - ValueData? newValueData; - do { - do { - // Set the new data - newValueData = await _routingContext.setDHTValue( - key, subkey, encryptedNewValue, - writer: writer ?? _writer); + ValueData? newValueData; + do { + do { + // Set the new data + newValueData = await _routingContext.setDHTValue( + key, subkey, encryptedNewValue, + options: SetDHTValueOptions( + writer: writer ?? _writer, allowOffline: false)); - // Repeat if newer data on the network was found - } while (newValueData != null); + // Repeat if newer data on the network was found + } while (newValueData != null); - // Get the data to check its sequence number - newValueData = await _routingContext.getDHTValue(key, subkey); - if (newValueData == null) { - assert(newValueData != null, "can't get value that was just set"); - return; - } + // Get the data to check its sequence number + newValueData = await _routingContext.getDHTValue(key, subkey); + if (newValueData == null) { + assert(newValueData != null, "can't get value that was just set"); + return; + } - // Record new sequence number - if (outSeqNum != null) { - outSeqNum.save(newValueData.seq); - } + // Record new sequence number + if (outSeqNum != null) { + outSeqNum.save(newValueData.seq); + } - // The encrypted data returned should be exactly the same - // as what we are trying to set, - // otherwise we still need to keep trying to set the value - } while (!newValueData.data.equals(encryptedNewValue)); + // The encrypted data returned should be exactly the same + // as what we are trying to set, + // otherwise we still need to keep trying to set the value + } while (!newValueData.data.equals(encryptedNewValue)); - final isUpdated = newValueData.seq != lastSeq; - if (isUpdated) { - DHTRecordPool.instance.processLocalValueChange(key, newValue, subkey); - } - } + final isUpdated = newValueData.seq != lastSeq; + if (isUpdated) { + DHTRecordPool.instance + ._processLocalValueChange(key, newValue, subkey); + } + }); /// Attempt to write a byte buffer to a DHTRecord subkey /// If a newer value was found on the network, another attempt @@ -308,40 +330,48 @@ class DHTRecord implements DHTDeleteable { /// Each attempt to write the value calls an update function with the /// old value to determine what new value should be attempted for that write. Future eventualUpdateBytes( - Future Function(Uint8List? oldValue) update, - {int subkey = -1, - VeilidCrypto? crypto, - KeyPair? writer, - Output? outSeqNum}) async { - subkey = subkeyOrDefault(subkey); + Future Function(Uint8List? oldValue) update, + {int subkey = -1, + VeilidCrypto? crypto, + KeyPair? writer, + Output? outSeqNum}) => + _wrapStats('eventualUpdateBytes', () async { + subkey = subkeyOrDefault(subkey); - // Get the existing data, do not allow force refresh here - // because if we need a refresh the setDHTValue will fail anyway - var oldValue = - await get(subkey: subkey, crypto: crypto, outSeqNum: outSeqNum); + // Get the existing data, do not allow force refresh here + // because if we need a refresh the setDHTValue will fail anyway + var oldValue = + await get(subkey: subkey, crypto: crypto, outSeqNum: outSeqNum); - do { - // Update the data - final updatedValue = await update(oldValue); + do { + // Update the data + final updatedValue = await update(oldValue); + if (updatedValue == null) { + // If null is returned from the update, stop trying to do the update + break; + } + // Try to write it back to the network + oldValue = await tryWriteBytes(updatedValue, + subkey: subkey, + crypto: crypto, + options: SetDHTValueOptions( + writer: writer ?? _writer, allowOffline: false), + outSeqNum: outSeqNum); - // Try to write it back to the network - oldValue = await tryWriteBytes(updatedValue, - subkey: subkey, crypto: crypto, writer: writer, outSeqNum: outSeqNum); - - // Repeat update if newer data on the network was found - } while (oldValue != null); - } + // Repeat update if newer data on the network was found + } while (oldValue != null); + }); /// Like 'tryWriteBytes' but with JSON marshal/unmarshal of the value Future tryWriteJson(T Function(dynamic) fromJson, T newValue, {int subkey = -1, VeilidCrypto? crypto, - KeyPair? writer, + SetDHTValueOptions? options, Output? outSeqNum}) => tryWriteBytes(jsonEncodeBytes(newValue), subkey: subkey, crypto: crypto, - writer: writer, + options: options, outSeqNum: outSeqNum) .then((out) { if (out == null) { @@ -355,12 +385,12 @@ class DHTRecord implements DHTDeleteable { T Function(List) fromBuffer, T newValue, {int subkey = -1, VeilidCrypto? crypto, - KeyPair? writer, + SetDHTValueOptions? options, Output? outSeqNum}) => tryWriteBytes(newValue.writeToBuffer(), subkey: subkey, crypto: crypto, - writer: writer, + options: options, outSeqNum: outSeqNum) .then((out) { if (out == null) { @@ -389,7 +419,7 @@ class DHTRecord implements DHTDeleteable { /// Like 'eventualUpdateBytes' but with JSON marshal/unmarshal of the value Future eventualUpdateJson( - T Function(dynamic) fromJson, Future Function(T?) update, + T Function(dynamic) fromJson, Future Function(T?) update, {int subkey = -1, VeilidCrypto? crypto, KeyPair? writer, @@ -399,7 +429,7 @@ class DHTRecord implements DHTDeleteable { /// Like 'eventualUpdateBytes' but with protobuf marshal/unmarshal of the value Future eventualUpdateProtobuf( - T Function(List) fromBuffer, Future Function(T?) update, + T Function(List) fromBuffer, Future Function(T?) update, {int subkey = -1, VeilidCrypto? crypto, KeyPair? writer, @@ -414,10 +444,10 @@ class DHTRecord implements DHTDeleteable { Timestamp? expiration, int? count}) async { // Set up watch requirements which will get picked up by the next tick - final oldWatchState = watchState; - watchState = - WatchState(subkeys: subkeys, expiration: expiration, count: count); - if (oldWatchState != watchState) { + final oldWatchState = _watchState; + _watchState = + _WatchState(subkeys: subkeys, expiration: expiration, count: count); + if (oldWatchState != _watchState) { _sharedDHTRecordData.needsWatchStateUpdate = true; } } @@ -475,8 +505,8 @@ class DHTRecord implements DHTDeleteable { /// Takes effect on the next DHTRecordPool tick Future cancelWatch() async { // Tear down watch requirements - if (watchState != null) { - watchState = null; + if (_watchState != null) { + _watchState = null; _sharedDHTRecordData.needsWatchStateUpdate = true; } } @@ -495,14 +525,14 @@ class DHTRecord implements DHTDeleteable { key, subkeys: [ValueSubkeyRange.single(subkey)], ); - return rr.localSeqs.firstOrNull ?? 0xFFFFFFFF; + return rr.localSeqs.firstOrNull; } void _addValueChange( {required bool local, required Uint8List? data, required List subkeys}) { - final ws = watchState; + final ws = _watchState; if (ws != null) { final watchedSubkeys = ws.subkeys; if (watchedSubkeys == null) { @@ -539,17 +569,18 @@ class DHTRecord implements DHTDeleteable { local: false, data: update.value?.data, subkeys: update.subkeys); } + Future _wrapStats(String func, Future Function() closure) => + DHTRecordPool.instance._stats.measure(key, debugName, func, closure); + ////////////////////////////////////////////////////////////// - final SharedDHTRecordData _sharedDHTRecordData; + final _SharedDHTRecordData _sharedDHTRecordData; final VeilidRoutingContext _routingContext; final int _defaultSubkey; final KeyPair? _writer; final VeilidCrypto _crypto; final String debugName; - final _mutex = Mutex(); int _openCount; StreamController? _watchController; - @internal - WatchState? watchState; + _WatchState? _watchState; } diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_cubit.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_cubit.dart index 1cfcfcd..eb56f7c 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_cubit.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_cubit.dart @@ -12,7 +12,7 @@ typedef StateFunction = Future Function( DHTRecord, List, Uint8List?); typedef WatchFunction = Future Function(DHTRecord); -class DHTRecordCubit extends Cubit> { +abstract class DHTRecordCubit extends Cubit> { DHTRecordCubit({ required Future Function() open, required InitialStateFunction initialStateFunction, @@ -21,28 +21,28 @@ class DHTRecordCubit extends Cubit> { }) : _wantsCloseRecord = false, _stateFunction = stateFunction, super(const AsyncValue.loading()) { - initWait.add(() async { - // Do record open/create - _record = await open(); - _wantsCloseRecord = true; + initWait.add((cancel) async { + try { + // Do record open/create + while (!cancel.isCompleted) { + try { + record = await open(); + _wantsCloseRecord = true; + break; + } on DHTExceptionNotAvailable { + // Wait for a bit + await asyncSleep(); + } + } + } on Exception catch (e, st) { + addError(e, st); + emit(AsyncValue.error(e, st)); + return; + } await _init(initialStateFunction, stateFunction, watchFunction); }); } - // DHTRecordCubit.value({ - // required DHTRecord record, - // required InitialStateFunction initialStateFunction, - // required StateFunction stateFunction, - // required WatchFunction watchFunction, - // }) : _record = record, - // _stateFunction = stateFunction, - // _wantsCloseRecord = false, - // super(const AsyncValue.loading()) { - // Future.delayed(Duration.zero, () async { - // await _init(initialStateFunction, stateFunction, watchFunction); - // }); - // } - Future _init( InitialStateFunction initialStateFunction, StateFunction stateFunction, @@ -50,41 +50,47 @@ class DHTRecordCubit extends Cubit> { ) async { // Make initial state update try { - final initialState = await initialStateFunction(_record); + final initialState = await initialStateFunction(record!); if (initialState != null) { emit(AsyncValue.data(initialState)); } - } on Exception catch (e) { - emit(AsyncValue.error(e)); + } on Exception catch (e, st) { + addError(e, st); + emit(AsyncValue.error(e, st)); } - _subscription = await _record.listen((record, data, subkeys) async { + _subscription = await record!.listen((record, data, subkeys) async { try { final newState = await stateFunction(record, subkeys, data); if (newState != null) { emit(AsyncValue.data(newState)); } - } on Exception catch (e) { - emit(AsyncValue.error(e)); + } on Exception catch (e, st) { + addError(e, st); + emit(AsyncValue.error(e, st)); } }); - await watchFunction(_record); + await watchFunction(record!); } @override Future close() async { - await initWait(); - await _record.cancelWatch(); + await initWait(cancelValue: true); + await record?.cancelWatch(); await _subscription?.cancel(); _subscription = null; if (_wantsCloseRecord) { - await _record.close(); + await record?.close(); _wantsCloseRecord = false; } await super.close(); } + Future ready() async { + await initWait(); + } + Future refresh(List subkeys) async { await initWait(); @@ -92,10 +98,10 @@ class DHTRecordCubit extends Cubit> { for (final skr in subkeys) { for (var sk = skr.low; sk <= skr.high; sk++) { - final data = await _record.get( - subkey: sk, refreshMode: DHTRecordRefreshMode.update); + final data = await record! + .get(subkey: sk, refreshMode: DHTRecordRefreshMode.update); if (data != null) { - final newState = await _stateFunction(_record, updateSubkeys, data); + final newState = await _stateFunction(record!, updateSubkeys, data); if (newState != null) { // Emit the new state emit(AsyncValue.data(newState)); @@ -109,13 +115,11 @@ class DHTRecordCubit extends Cubit> { } } - DHTRecord get record => _record; - @protected - final WaitSet initWait = WaitSet(); + final WaitSet initWait = WaitSet(); StreamSubscription? _subscription; - late DHTRecord _record; + DHTRecord? record; bool _wantsCloseRecord; final StateFunction _stateFunction; } diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.dart index 8b65d41..cfa123d 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.dart @@ -9,6 +9,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:protobuf/protobuf.dart'; import '../../../../veilid_support.dart'; +import 'extensions.dart'; export 'package:fast_immutable_collections/fast_immutable_collections.dart' show Output; @@ -16,26 +17,23 @@ export 'package:fast_immutable_collections/fast_immutable_collections.dart' part 'dht_record_pool.freezed.dart'; part 'dht_record_pool.g.dart'; part 'dht_record.dart'; +part 'dht_record_pool_private.dart'; -const int watchBackoffMultiplier = 2; -const int watchBackoffMax = 30; +/// Maximum number of concurrent DHT operations to perform on the network +const kMaxDHTConcurrency = 8; -const int? defaultWatchDurationSecs = null; // 600 -const int watchRenewalNumerator = 4; -const int watchRenewalDenominator = 5; +/// Total number of times to try in a 'VeilidAPIExceptionKeyNotFound' loop +const kDHTKeyNotFoundTries = 3; -// Maximum number of concurrent DHT operations to perform on the network -const int maxDHTConcurrency = 8; - -// DHT crypto domain -const String cryptoDomainDHT = 'dht'; +/// Total number of times to try in a 'VeilidAPIExceptionTryAgain' loop +const kDHTTryAgainTries = 3; typedef DHTRecordPoolLogger = void Function(String message); /// Record pool that managed DHTRecords and allows for tagged deletion /// String versions of keys due to IMap<> json unsupported in key @freezed -class DHTRecordPoolAllocations with _$DHTRecordPoolAllocations { +sealed class DHTRecordPoolAllocations with _$DHTRecordPoolAllocations { const factory DHTRecordPoolAllocations({ @Default(IMapConst>({})) IMap> childrenByParent, @@ -52,7 +50,7 @@ class DHTRecordPoolAllocations with _$DHTRecordPoolAllocations { /// Pointer to an owned record, with key, owner key and owner secret /// Ensure that these are only serialized encrypted @freezed -class OwnedDHTRecordPointer with _$OwnedDHTRecordPointer { +sealed class OwnedDHTRecordPointer with _$OwnedDHTRecordPointer { const factory OwnedDHTRecordPointer({ required TypedKey recordKey, required KeyPair owner, @@ -62,114 +60,19 @@ class OwnedDHTRecordPointer with _$OwnedDHTRecordPointer { _$OwnedDHTRecordPointerFromJson(json as Map); } -/// Watch state -@immutable -class WatchState extends Equatable { - const WatchState( - {required this.subkeys, - required this.expiration, - required this.count, - this.realExpiration, - this.renewalTime}); - final List? subkeys; - final Timestamp? expiration; - final int? count; - final Timestamp? realExpiration; - final Timestamp? renewalTime; - - @override - List get props => - [subkeys, expiration, count, realExpiration, renewalTime]; -} - -/// Data shared amongst all DHTRecord instances -class SharedDHTRecordData { - SharedDHTRecordData( - {required this.recordDescriptor, - required this.defaultWriter, - required this.defaultRoutingContext}); - DHTRecordDescriptor recordDescriptor; - KeyPair? defaultWriter; - VeilidRoutingContext defaultRoutingContext; - bool needsWatchStateUpdate = false; - WatchState? unionWatchState; -} - -// Per opened record data -class OpenedRecordInfo { - OpenedRecordInfo( - {required DHTRecordDescriptor recordDescriptor, - required KeyPair? defaultWriter, - required VeilidRoutingContext defaultRoutingContext}) - : shared = SharedDHTRecordData( - recordDescriptor: recordDescriptor, - defaultWriter: defaultWriter, - defaultRoutingContext: defaultRoutingContext); - SharedDHTRecordData shared; - Set records = {}; - - String get debugNames { - final r = records.toList() - ..sort((a, b) => a.key.toString().compareTo(b.key.toString())); - return '[${r.map((x) => x.debugName).join(',')}]'; - } - - String get details { - final r = records.toList() - ..sort((a, b) => a.key.toString().compareTo(b.key.toString())); - return '[${r.map((x) => "writer=${x._writer} " - "defaultSubkey=${x._defaultSubkey}").join(',')}]'; - } - - String get sharedDetails => shared.toString(); -} +////////////////////////////////////////////////////////////////////////////// +/// Allocator and management system for DHTRecord class DHTRecordPool with TableDBBackedJson { DHTRecordPool._(Veilid veilid, VeilidRoutingContext routingContext) : _state = const DHTRecordPoolAllocations(), - _mutex = Mutex(), - _opened = {}, + _mutex = Mutex(debugLockTimeout: kIsDebugMode ? 60 : null), + _recordTagLock = AsyncTagLock(), + _opened = {}, _markedForDelete = {}, _routingContext = routingContext, _veilid = veilid; - // Logger - DHTRecordPoolLogger? _logger; - - // Persistent DHT record list - DHTRecordPoolAllocations _state; - // Create/open Mutex - final Mutex _mutex; - // Which DHT records are currently open - final Map _opened; - // Which DHT records are marked for deletion - final Set _markedForDelete; - // Default routing context to use for new keys - final VeilidRoutingContext _routingContext; - // Convenience accessor - final Veilid _veilid; - // If tick is already running or not - bool _inTick = false; - // Tick counter for backoff - int _tickCount = 0; - // Backoff timer - int _watchBackoffTimer = 1; - - static DHTRecordPool? _singleton; - - ////////////////////////////////////////////////////////////// - /// AsyncTableDBBacked - @override - String tableName() => 'dht_record_pool'; - @override - String tableKeyName() => 'pool_allocations'; - @override - DHTRecordPoolAllocations valueFromJson(Object? obj) => obj != null - ? DHTRecordPoolAllocations.fromJson(obj) - : const DHTRecordPoolAllocations(); - @override - Object? valueToJson(DHTRecordPoolAllocations? val) => val?.toJson(); - ////////////////////////////////////////////////////////////// static DHTRecordPool get instance => _singleton!; @@ -190,13 +93,234 @@ class DHTRecordPool with TableDBBackedJson { } } - Veilid get veilid => _veilid; + //////////////////////////////////////////////////////////////////////////// + // Public Interface + /// Create a root DHTRecord that has no dependent records + Future createRecord({ + required String debugName, + VeilidRoutingContext? routingContext, + TypedKey? parent, + DHTSchema schema = const DHTSchema.dflt(oCnt: 1), + int defaultSubkey = 0, + VeilidCrypto? crypto, + KeyPair? writer, + }) => + _mutex.protect(() async { + final dhtctx = routingContext ?? _routingContext; + + final openedRecordInfo = await _recordCreateInner( + debugName: debugName, + dhtctx: dhtctx, + schema: schema, + writer: writer, + parent: parent); + + final rec = DHTRecord._( + debugName: debugName, + routingContext: dhtctx, + defaultSubkey: defaultSubkey, + sharedDHTRecordData: openedRecordInfo.shared, + writer: writer ?? + openedRecordInfo.shared.recordDescriptor.ownerKeyPair(), + crypto: crypto ?? + await privateCryptoFromTypedSecret(openedRecordInfo + .shared.recordDescriptor + .ownerTypedSecret()!)); + + openedRecordInfo.records.add(rec); + + return rec; + }); + + /// Open a DHTRecord readonly + Future openRecordRead(TypedKey recordKey, + {required String debugName, + VeilidRoutingContext? routingContext, + TypedKey? parent, + int defaultSubkey = 0, + VeilidCrypto? crypto}) => + _recordTagLock.protect(recordKey, closure: () async { + final dhtctx = routingContext ?? _routingContext; + + final rec = await _recordOpenCommon( + debugName: debugName, + dhtctx: dhtctx, + recordKey: recordKey, + crypto: crypto ?? const VeilidCryptoPublic(), + writer: null, + parent: parent, + defaultSubkey: defaultSubkey); + + return rec; + }); + + /// Open a DHTRecord writable + Future openRecordWrite( + TypedKey recordKey, + KeyPair writer, { + required String debugName, + VeilidRoutingContext? routingContext, + TypedKey? parent, + int defaultSubkey = 0, + VeilidCrypto? crypto, + }) => + _recordTagLock.protect(recordKey, closure: () async { + final dhtctx = routingContext ?? _routingContext; + + final rec = await _recordOpenCommon( + debugName: debugName, + dhtctx: dhtctx, + recordKey: recordKey, + crypto: crypto ?? + await privateCryptoFromTypedSecret( + TypedKey(kind: recordKey.kind, value: writer.secret)), + writer: writer, + parent: parent, + defaultSubkey: defaultSubkey, + ); + + return rec; + }); + + /// Open a DHTRecord owned + /// This is the same as writable but uses an OwnedDHTRecordPointer + /// for convenience and uses symmetric encryption on the key + /// This is primarily used for backing up private content on to the DHT + /// to synchronizing it between devices. Because it is 'owned', the correct + /// parent must be specified. + Future openRecordOwned( + OwnedDHTRecordPointer ownedDHTRecordPointer, { + required String debugName, + required TypedKey parent, + VeilidRoutingContext? routingContext, + int defaultSubkey = 0, + VeilidCrypto? crypto, + }) => + openRecordWrite( + ownedDHTRecordPointer.recordKey, + ownedDHTRecordPointer.owner, + debugName: debugName, + routingContext: routingContext, + parent: parent, + defaultSubkey: defaultSubkey, + crypto: crypto, + ); + + /// Get the parent of a DHTRecord key if it exists + Future getParentRecordKey(TypedKey child) => + _mutex.protect(() async => _getParentRecordKeyInner(child)); + + /// Check if record is allocated + Future isValidRecordKey(TypedKey key) => + _mutex.protect(() async => _isValidRecordKeyInner(key)); + + /// Check if record is marked for deletion or already gone + Future isDeletedRecordKey(TypedKey key) => + _mutex.protect(() async => _isDeletedRecordKeyInner(key)); + + /// Delete a record and its children if they are all closed + /// otherwise mark that record for deletion eventually + /// Returns true if the deletion was processed immediately + /// Returns false if the deletion was marked for later + Future deleteRecord(TypedKey recordKey) => + _mutex.protect(() => _deleteRecordInner(recordKey)); + + // If everything underneath is closed including itself, return the + // list of children (and itself) to finally actually delete + List _readyForDeleteInner(TypedKey recordKey) { + final allDeps = _collectChildrenInner(recordKey); + for (final dep in allDeps) { + if (_opened.containsKey(dep)) { + return []; + } + } + return allDeps; + } + + /// Collect all dependencies (including the record itself) + /// in reverse (bottom-up/delete order) + Future> collectChildren(TypedKey recordKey) => + _mutex.protect(() async => _collectChildrenInner(recordKey)); + + /// Print children + String debugChildren(TypedKey recordKey, {List? allDeps}) { + allDeps ??= _collectChildrenInner(recordKey); + // Debugging + // ignore: avoid_print + var out = + 'Parent: $recordKey (${_state.debugNames[recordKey.toString()]})\n'; + for (final dep in allDeps) { + if (dep != recordKey) { + // Debugging + // ignore: avoid_print + out += ' Child: $dep (${_state.debugNames[dep.toString()]})\n'; + } + } + return out; + } + + /// Handle the DHT record updates coming from Veilid + void processRemoteValueChange(VeilidUpdateValueChange updateValueChange) { + if (updateValueChange.subkeys.isNotEmpty && updateValueChange.count != 0) { + // Change + for (final kv in _opened.entries) { + if (kv.key == updateValueChange.key) { + for (final rec in kv.value.records) { + rec._addRemoteValueChange(updateValueChange); + } + break; + } + } + } + } + + /// Log the current record allocations + void debugPrintAllocations() { + final sortedAllocations = _state.debugNames.entries.asList() + ..sort((a, b) => a.key.compareTo(b.key)); + + log('DHTRecordPool Allocations: (count=${sortedAllocations.length})'); + + for (final entry in sortedAllocations) { + log(' ${entry.key}: ${entry.value}'); + } + } + + /// Log the current opened record details + void debugPrintOpened() { + final sortedOpened = _opened.entries.asList() + ..sort((a, b) => a.key.toString().compareTo(b.key.toString())); + + log('DHTRecordPool Opened Records: (count=${sortedOpened.length})'); + + for (final entry in sortedOpened) { + log(' ${entry.key}: \n' + ' debugNames=${entry.value.debugNames}\n' + ' details=${entry.value.details}\n' + ' sharedDetails=${entry.value.sharedDetails}\n'); + } + } + + /// Log the performance stats + void debugPrintStats() { + log('DHTRecordPool Stats:\n${_stats.debugString()}'); + } + + /// Public interface to DHTRecordPool logger void log(String message) { _logger?.call(message); } - Future _recordCreateInner( + /// Generate default VeilidCrypto for a writer + static Future privateCryptoFromTypedSecret( + TypedKey typedSecret) => + VeilidCryptoPrivate.fromTypedKey(typedSecret, _cryptoDomainDHT); + + //////////////////////////////////////////////////////////////////////////// + // Private Implementation + + Future<_OpenedRecordInfo> _recordCreateInner( {required String debugName, required VeilidRoutingContext dhtctx, required DHTSchema schema, @@ -215,7 +339,7 @@ class DHTRecordPool with TableDBBackedJson { if (writer != null) { await dhtctx.openDHTRecord(recordDescriptor.key, writer: writer); } - final openedRecordInfo = OpenedRecordInfo( + final openedRecordInfo = _OpenedRecordInfo( recordDescriptor: recordDescriptor, defaultWriter: writer ?? recordDescriptor.ownerKeyPair(), defaultRoutingContext: dhtctx); @@ -231,98 +355,152 @@ class DHTRecordPool with TableDBBackedJson { return openedRecordInfo; } - Future _recordOpenInner( - {required String debugName, - required VeilidRoutingContext dhtctx, - required TypedKey recordKey, - KeyPair? writer, - TypedKey? parent}) async { - if (!_mutex.isLocked) { - throw StateError('should be locked here'); - } - log('openDHTRecord: debugName=$debugName key=$recordKey'); + Future _recordOpenCommon( + {required String debugName, + required VeilidRoutingContext dhtctx, + required TypedKey recordKey, + required VeilidCrypto crypto, + required KeyPair? writer, + required TypedKey? parent, + required int defaultSubkey}) => + _stats.measure(recordKey, debugName, '_recordOpenCommon', () async { + log('openDHTRecord: debugName=$debugName key=$recordKey'); - // If we are opening a key that already exists - // make sure we are using the same parent if one was specified - _validateParentInner(parent, recordKey); + // See if this has been opened yet + final openedRecordInfo = await _mutex.protect(() async { + // If we are opening a key that already exists + // make sure we are using the same parent if one was specified + _validateParentInner(parent, recordKey); - // See if this has been opened yet - final openedRecordInfo = _opened[recordKey]; - if (openedRecordInfo == null) { - // Fresh open, just open the record - final recordDescriptor = + return _opened[recordKey]; + }); + + if (openedRecordInfo == null) { + // Fresh open, just open the record + var retry = kDHTKeyNotFoundTries; + late final DHTRecordDescriptor recordDescriptor; + while (true) { + try { + recordDescriptor = + await dhtctx.openDHTRecord(recordKey, writer: writer); + break; + } on VeilidAPIExceptionTryAgain { + throw const DHTExceptionNotAvailable(); + } on VeilidAPIExceptionKeyNotFound { + await asyncSleep(); + retry--; + if (retry == 0) { + throw const DHTExceptionNotAvailable(); + } + } + } + + final newOpenedRecordInfo = _OpenedRecordInfo( + recordDescriptor: recordDescriptor, + defaultWriter: writer, + defaultRoutingContext: dhtctx); + + final rec = DHTRecord._( + debugName: debugName, + routingContext: dhtctx, + defaultSubkey: defaultSubkey, + sharedDHTRecordData: newOpenedRecordInfo.shared, + writer: writer, + crypto: crypto); + + await _mutex.protect(() async { + // Register the opened record + _opened[recordDescriptor.key] = newOpenedRecordInfo; + + // Register the dependency + await _addDependencyInner( + parent, + recordKey, + debugName: debugName, + ); + + // Register the newly opened record + newOpenedRecordInfo.records.add(rec); + }); + + return rec; + } + + // Already opened + + // See if we need to reopen the record with a default writer and + // possibly a different routing context + if (writer != null && openedRecordInfo.shared.defaultWriter == null) { await dhtctx.openDHTRecord(recordKey, writer: writer); - final newOpenedRecordInfo = OpenedRecordInfo( - recordDescriptor: recordDescriptor, - defaultWriter: writer, - defaultRoutingContext: dhtctx); - _opened[recordDescriptor.key] = newOpenedRecordInfo; + // New writer if we didn't specify one before + openedRecordInfo.shared.defaultWriter = writer; + // New default routing context if we opened it again + openedRecordInfo.shared.defaultRoutingContext = dhtctx; + } - // Register the dependency - await _addDependencyInner( - parent, - recordKey, - debugName: debugName, - ); + final rec = DHTRecord._( + debugName: debugName, + routingContext: dhtctx, + defaultSubkey: defaultSubkey, + sharedDHTRecordData: openedRecordInfo.shared, + writer: writer, + crypto: crypto); - return newOpenedRecordInfo; - } + await _mutex.protect(() async { + // Register the dependency + await _addDependencyInner( + parent, + recordKey, + debugName: debugName, + ); - // Already opened + openedRecordInfo.records.add(rec); + }); - // See if we need to reopen the record with a default writer and possibly - // a different routing context - if (writer != null && openedRecordInfo.shared.defaultWriter == null) { - final newRecordDescriptor = - await dhtctx.openDHTRecord(recordKey, writer: writer); - openedRecordInfo.shared.defaultWriter = writer; - openedRecordInfo.shared.defaultRoutingContext = dhtctx; - if (openedRecordInfo.shared.recordDescriptor.ownerSecret == null) { - openedRecordInfo.shared.recordDescriptor = newRecordDescriptor; - } - } - - // Register the dependency - await _addDependencyInner( - parent, - recordKey, - debugName: debugName, - ); - - return openedRecordInfo; - } + return rec; + }); // Called when a DHTRecord is closed // Cleans up the opened record housekeeping and processes any late deletions Future _recordClosed(DHTRecord record) async { - await _mutex.protect(() async { - final key = record.key; + final key = record.key; + await _recordTagLock.protect(key, closure: () async { + await _mutex.protect(() async { + log('closeDHTRecord: debugName=${record.debugName} key=$key'); - log('closeDHTRecord: debugName=${record.debugName} key=$key'); - - final openedRecordInfo = _opened[key]; - if (openedRecordInfo == null || - !openedRecordInfo.records.remove(record)) { - throw StateError('record already closed'); - } - if (openedRecordInfo.records.isEmpty) { - await _routingContext.closeDHTRecord(key); + final openedRecordInfo = _opened[key]; + if (openedRecordInfo == null || + !openedRecordInfo.records.remove(record)) { + throw StateError('record already closed'); + } + if (openedRecordInfo.records.isNotEmpty) { + return; + } _opened.remove(key); - + await _routingContext.closeDHTRecord(key); await _checkForLateDeletesInner(key); - } + }); + + // This happens after the mutex is released + // because the record has already been removed from _opened + // which means that updates to the state processor won't happen + await _watchStateProcessors.remove(key); }); } // Check to see if this key can finally be deleted // If any parents are marked for deletion, try them first Future _checkForLateDeletesInner(TypedKey key) async { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } + // Get parent list in bottom up order including our own key final parents = []; TypedKey? nextParent = key; while (nextParent != null) { parents.add(nextParent); - nextParent = getParentRecordKey(nextParent); + nextParent = _getParentRecordKeyInner(nextParent); } // If any parent is ready to delete all its children do it @@ -357,28 +535,12 @@ class DHTRecordPool with TableDBBackedJson { return allDeps.reversedView; } - /// Collect all dependencies (including the record itself) - /// in reverse (bottom-up/delete order) - Future> collectChildren(TypedKey recordKey) => - _mutex.protect(() async => _collectChildrenInner(recordKey)); - - /// Print children - String debugChildren(TypedKey recordKey, {List? allDeps}) { - allDeps ??= _collectChildrenInner(recordKey); - // ignore: avoid_print - var out = - 'Parent: $recordKey (${_state.debugNames[recordKey.toString()]})\n'; - for (final dep in allDeps) { - if (dep != recordKey) { - // ignore: avoid_print - out += ' Child: $dep (${_state.debugNames[dep.toString()]})\n'; - } - } - return out; - } - // Actual delete function Future _finalizeDeleteRecordInner(TypedKey recordKey) async { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } + log('_finalizeDeleteRecordInner: key=$recordKey'); // Remove this child from parents @@ -389,6 +551,10 @@ class DHTRecordPool with TableDBBackedJson { // Deep delete mechanism inside mutex Future _deleteRecordInner(TypedKey recordKey) async { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } + final toDelete = _readyForDeleteInner(recordKey); if (toDelete.isNotEmpty) { // delete now @@ -402,25 +568,6 @@ class DHTRecordPool with TableDBBackedJson { return false; } - /// Delete a record and its children if they are all closed - /// otherwise mark that record for deletion eventually - /// Returns true if the deletion was processed immediately - /// Returns false if the deletion was marked for later - Future deleteRecord(TypedKey recordKey) async => - _mutex.protect(() async => _deleteRecordInner(recordKey)); - - // If everything underneath is closed including itself, return the - // list of children (and itself) to finally actually delete - List _readyForDeleteInner(TypedKey recordKey) { - final allDeps = _collectChildrenInner(recordKey); - for (final dep in allDeps) { - if (_opened.containsKey(dep)) { - return []; - } - } - return allDeps; - } - void _validateParentInner(TypedKey? parent, TypedKey child) { if (!_mutex.isLocked) { throw StateError('should be locked here'); @@ -507,7 +654,20 @@ class DHTRecordPool with TableDBBackedJson { } } + TypedKey? _getParentRecordKeyInner(TypedKey child) { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } + + final childJson = child.toJson(); + return _state.parentByChild[childJson]; + } + bool _isValidRecordKeyInner(TypedKey key) { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } + if (_state.rootRecords.contains(key)) { return true; } @@ -517,144 +677,31 @@ class DHTRecordPool with TableDBBackedJson { return false; } - Future isValidRecordKey(TypedKey key) => - _mutex.protect(() async => _isValidRecordKeyInner(key)); + bool _isDeletedRecordKeyInner(TypedKey key) { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } - /////////////////////////////////////////////////////////////////////// + // Is this key gone? + if (!_isValidRecordKeyInner(key)) { + return true; + } - /// Create a root DHTRecord that has no dependent records - Future createRecord({ - required String debugName, - VeilidRoutingContext? routingContext, - TypedKey? parent, - DHTSchema schema = const DHTSchema.dflt(oCnt: 1), - int defaultSubkey = 0, - VeilidCrypto? crypto, - KeyPair? writer, - }) async => - _mutex.protect(() async { - final dhtctx = routingContext ?? _routingContext; + // Is this key on its way out because it or one of its parents + // is scheduled to delete everything underneath it? + TypedKey? nextParent = key; + while (nextParent != null) { + if (_markedForDelete.contains(nextParent)) { + return true; + } + nextParent = _getParentRecordKeyInner(nextParent); + } - final openedRecordInfo = await _recordCreateInner( - debugName: debugName, - dhtctx: dhtctx, - schema: schema, - writer: writer, - parent: parent); - - final rec = DHTRecord._( - debugName: debugName, - routingContext: dhtctx, - defaultSubkey: defaultSubkey, - sharedDHTRecordData: openedRecordInfo.shared, - writer: writer ?? - openedRecordInfo.shared.recordDescriptor.ownerKeyPair(), - crypto: crypto ?? - await privateCryptoFromTypedSecret(openedRecordInfo - .shared.recordDescriptor - .ownerTypedSecret()!)); - - openedRecordInfo.records.add(rec); - - return rec; - }); - - /// Open a DHTRecord readonly - Future openRecordRead(TypedKey recordKey, - {required String debugName, - VeilidRoutingContext? routingContext, - TypedKey? parent, - int defaultSubkey = 0, - VeilidCrypto? crypto}) async => - _mutex.protect(() async { - final dhtctx = routingContext ?? _routingContext; - - final openedRecordInfo = await _recordOpenInner( - debugName: debugName, - dhtctx: dhtctx, - recordKey: recordKey, - parent: parent); - - final rec = DHTRecord._( - debugName: debugName, - routingContext: dhtctx, - defaultSubkey: defaultSubkey, - sharedDHTRecordData: openedRecordInfo.shared, - writer: null, - crypto: crypto ?? const VeilidCryptoPublic()); - - openedRecordInfo.records.add(rec); - - return rec; - }); - - /// Open a DHTRecord writable - Future openRecordWrite( - TypedKey recordKey, - KeyPair writer, { - required String debugName, - VeilidRoutingContext? routingContext, - TypedKey? parent, - int defaultSubkey = 0, - VeilidCrypto? crypto, - }) async => - _mutex.protect(() async { - final dhtctx = routingContext ?? _routingContext; - - final openedRecordInfo = await _recordOpenInner( - debugName: debugName, - dhtctx: dhtctx, - recordKey: recordKey, - parent: parent, - writer: writer); - - final rec = DHTRecord._( - debugName: debugName, - routingContext: dhtctx, - defaultSubkey: defaultSubkey, - writer: writer, - sharedDHTRecordData: openedRecordInfo.shared, - crypto: crypto ?? - await privateCryptoFromTypedSecret( - TypedKey(kind: recordKey.kind, value: writer.secret))); - - openedRecordInfo.records.add(rec); - - return rec; - }); - - /// Open a DHTRecord owned - /// This is the same as writable but uses an OwnedDHTRecordPointer - /// for convenience and uses symmetric encryption on the key - /// This is primarily used for backing up private content on to the DHT - /// to synchronizing it between devices. Because it is 'owned', the correct - /// parent must be specified. - Future openRecordOwned( - OwnedDHTRecordPointer ownedDHTRecordPointer, { - required String debugName, - required TypedKey parent, - VeilidRoutingContext? routingContext, - int defaultSubkey = 0, - VeilidCrypto? crypto, - }) => - openRecordWrite( - ownedDHTRecordPointer.recordKey, - ownedDHTRecordPointer.owner, - debugName: debugName, - routingContext: routingContext, - parent: parent, - defaultSubkey: defaultSubkey, - crypto: crypto, - ); - - /// Get the parent of a DHTRecord key if it exists - TypedKey? getParentRecordKey(TypedKey child) { - final childJson = child.toJson(); - return _state.parentByChild[childJson]; + return false; } /// Handle the DHT record updates coming from internal to this app - void processLocalValueChange(TypedKey key, Uint8List data, int subkey) { + void _processLocalValueChange(TypedKey key, Uint8List data, int subkey) { // Change for (final kv in _opened.entries) { if (kv.key == key) { @@ -666,64 +713,18 @@ class DHTRecordPool with TableDBBackedJson { } } - /// Generate default VeilidCrypto for a writer - static Future privateCryptoFromTypedSecret( - TypedKey typedSecret) async => - VeilidCryptoPrivate.fromTypedKey(typedSecret, cryptoDomainDHT); - - /// Handle the DHT record updates coming from Veilid - void processRemoteValueChange(VeilidUpdateValueChange updateValueChange) { - if (updateValueChange.subkeys.isNotEmpty) { - // Change - for (final kv in _opened.entries) { - if (kv.key == updateValueChange.key) { - for (final rec in kv.value.records) { - rec._addRemoteValueChange(updateValueChange); - } - break; - } - } - } else { - final now = Veilid.instance.now().value; - // Expired, process renewal if desired - for (final entry in _opened.entries) { - final openedKey = entry.key; - final openedRecordInfo = entry.value; - - if (openedKey == updateValueChange.key) { - // Renew watch state for each opened recrod - for (final rec in openedRecordInfo.records) { - // See if the watch had an expiration and if it has expired - // otherwise the renewal will keep the same parameters - final watchState = rec.watchState; - if (watchState != null) { - final exp = watchState.expiration; - if (exp != null && exp.value < now) { - // Has expiration, and it has expired, clear watch state - rec.watchState = null; - } - } - } - openedRecordInfo.shared.needsWatchStateUpdate = true; - break; - } - } - } - } - - WatchState? _collectUnionWatchState(Iterable records) { + static _WatchState? _collectUnionWatchState(Iterable records) { // Collect union of opened record watch states int? totalCount; Timestamp? maxExpiration; List? allSubkeys; - Timestamp? earliestRenewalTime; var noExpiration = false; var everySubkey = false; var cancelWatch = true; for (final rec in records) { - final ws = rec.watchState; + final ws = rec._watchState; if (ws != null) { cancelWatch = false; final wsCount = ws.count; @@ -749,15 +750,6 @@ class DHTRecordPool with TableDBBackedJson { } else { everySubkey = true; } - final wsRenewalTime = ws.renewalTime; - if (wsRenewalTime != null) { - earliestRenewalTime = earliestRenewalTime == null - ? wsRenewalTime - : Timestamp( - value: (wsRenewalTime.value < earliestRenewalTime.value - ? wsRenewalTime.value - : earliestRenewalTime.value)); - } } } if (noExpiration) { @@ -770,176 +762,198 @@ class DHTRecordPool with TableDBBackedJson { return null; } - return WatchState( - subkeys: allSubkeys, - expiration: maxExpiration, - count: totalCount, - renewalTime: earliestRenewalTime); + return _WatchState( + subkeys: allSubkeys, + expiration: maxExpiration, + count: totalCount, + ); } - void _updateWatchRealExpirations(Iterable records, - Timestamp realExpiration, Timestamp renewalTime) { - for (final rec in records) { - final ws = rec.watchState; - if (ws != null) { - rec.watchState = WatchState( - subkeys: ws.subkeys, - expiration: ws.expiration, - count: ws.count, - realExpiration: realExpiration, - renewalTime: renewalTime); - } + Future _watchStateChange( + TypedKey openedRecordKey, _WatchState? unionWatchState) async { + // Get the current state for this watch + final openedRecordInfo = _opened[openedRecordKey]; + if (openedRecordInfo == null) { + // Record is gone, nothing to do + return; } + final currentWatchState = openedRecordInfo.shared.unionWatchState; + final dhtctx = openedRecordInfo.shared.defaultRoutingContext; + + // If it's the same as our desired state there is nothing to do here + if (currentWatchState == unionWatchState) { + return; + } + + // Apply watch changes for record + if (unionWatchState == null) { + // Record needs watch cancel + // Only try this once, if it doesn't succeed then it can just expire + // on its own. + try { + final stillActive = await dhtctx.cancelDHTWatch(openedRecordKey); + + log('cancelDHTWatch: key=$openedRecordKey, stillActive=$stillActive, ' + 'debugNames=${openedRecordInfo.debugNames}'); + + openedRecordInfo.shared.unionWatchState = null; + openedRecordInfo.shared.needsWatchStateUpdate = false; + } on VeilidAPIExceptionTimeout { + log('Timeout in watch cancel for key=$openedRecordKey'); + } on VeilidAPIException catch (e) { + // Failed to cancel DHT watch, try again next tick + log('VeilidAPIException in watch cancel for key=$openedRecordKey: $e'); + } catch (e) { + log('Unhandled exception in watch cancel for key=$openedRecordKey: $e'); + rethrow; + } + + return; + } + + // Record needs new watch + try { + final subkeys = unionWatchState.subkeys?.toList(); + final count = unionWatchState.count; + final expiration = unionWatchState.expiration; + + final active = await dhtctx.watchDHTValues(openedRecordKey, + subkeys: unionWatchState.subkeys?.toList(), + count: unionWatchState.count, + expiration: unionWatchState.expiration); + + log('watchDHTValues(active=$active): ' + 'key=$openedRecordKey, subkeys=$subkeys, ' + 'count=$count, expiration=$expiration, ' + 'debugNames=${openedRecordInfo.debugNames}'); + + // Update watch states with real expiration + if (active) { + openedRecordInfo.shared.unionWatchState = unionWatchState; + openedRecordInfo.shared.needsWatchStateUpdate = false; + } + } on VeilidAPIExceptionTimeout { + log('Timeout in watch update for key=$openedRecordKey'); + } on VeilidAPIException catch (e) { + // Failed to update DHT watch, try again next tick + log('VeilidAPIException in watch update for key=$openedRecordKey: $e'); + } catch (e) { + log('Unhandled exception in watch update for key=$openedRecordKey: $e'); + rethrow; + } + + // If we still need a state update after this then do a poll instead + if (openedRecordInfo.shared.needsWatchStateUpdate) { + _pollWatch(openedRecordKey, openedRecordInfo, unionWatchState); + } + } + + // In lieu of a completed watch, set off a polling operation + // on the first value of the watched range, which, due to current + // veilid limitations can only be one subkey at a time right now + void _pollWatch(TypedKey openedRecordKey, _OpenedRecordInfo openedRecordInfo, + _WatchState unionWatchState) { + singleFuture((this, _sfPollWatch, openedRecordKey), () async { + await _stats.measure( + openedRecordKey, openedRecordInfo.debugNames, '_pollWatch', () async { + final dhtctx = openedRecordInfo.shared.defaultRoutingContext; + + final currentReport = await dhtctx.inspectDHTRecord(openedRecordKey, + subkeys: unionWatchState.subkeys, scope: DHTReportScope.syncGet); + + final fsc = currentReport.firstSeqChange; + if (fsc == null) { + return null; + } + final newerSubkeys = currentReport.newerOnlineSubkeys; + + final valueData = await dhtctx.getDHTValue(openedRecordKey, fsc.subkey, + forceRefresh: true); + if (valueData == null) { + return; + } + + if (valueData.seq < fsc.newSeq) { + log('inspect returned a newer seq than get: ${valueData.seq} < $fsc'); + } + + if (fsc.oldSeq == null || valueData.seq > fsc.oldSeq!) { + processRemoteValueChange(VeilidUpdateValueChange( + key: openedRecordKey, + subkeys: newerSubkeys, + count: 0xFFFFFFFF, + value: valueData)); + } + }); + }); } /// Ticker to check watch state change requests - Future tick() async { - if (_tickCount < _watchBackoffTimer) { - _tickCount++; - return; - } - if (_inTick) { - return; - } - _inTick = true; - _tickCount = 0; - final now = veilid.now(); - - try { - final allSuccess = await _mutex.protect(() async { + Future tick() => _mutex.protect(() async { // See if any opened records need watch state changes - final unord = Function()>[]; - for (final kv in _opened.entries) { final openedRecordKey = kv.key; final openedRecordInfo = kv.value; - final dhtctx = openedRecordInfo.shared.defaultRoutingContext; - var wantsWatchStateUpdate = + final wantsWatchStateUpdate = openedRecordInfo.shared.needsWatchStateUpdate; - // Check if we have reached renewal time for the watch - if (openedRecordInfo.shared.unionWatchState != null && - openedRecordInfo.shared.unionWatchState!.renewalTime != null && - now.value > - openedRecordInfo.shared.unionWatchState!.renewalTime!.value) { - wantsWatchStateUpdate = true; - } - if (wantsWatchStateUpdate) { // Update union watch state - final unionWatchState = openedRecordInfo.shared.unionWatchState = + final unionWatchState = _collectUnionWatchState(openedRecordInfo.records); - // Apply watch changes for record - if (unionWatchState == null) { - unord.add(() async { - // Record needs watch cancel - var success = false; - try { - success = await dhtctx.cancelDHTWatch(openedRecordKey); - - log('cancelDHTWatch: key=$openedRecordKey, success=$success, ' - 'debugNames=${openedRecordInfo.debugNames}'); - - openedRecordInfo.shared.needsWatchStateUpdate = false; - } on VeilidAPIException catch (e) { - // Failed to cancel DHT watch, try again next tick - log('Exception in watch cancel: $e'); - } - return success; - }); - } else { - unord.add(() async { - // Record needs new watch - var success = false; - try { - final subkeys = unionWatchState.subkeys?.toList(); - final count = unionWatchState.count; - final expiration = unionWatchState.expiration; - final now = veilid.now(); - - final realExpiration = await dhtctx.watchDHTValues( - openedRecordKey, - subkeys: unionWatchState.subkeys?.toList(), - count: unionWatchState.count, - expiration: unionWatchState.expiration ?? - (defaultWatchDurationSecs == null - ? null - : veilid.now().offset( - TimestampDuration.fromMillis( - defaultWatchDurationSecs! * 1000)))); - - final expirationDuration = realExpiration.diff(now); - final renewalTime = now.offset(TimestampDuration( - value: expirationDuration.value * - BigInt.from(watchRenewalNumerator) ~/ - BigInt.from(watchRenewalDenominator))); - - log('watchDHTValues: key=$openedRecordKey, subkeys=$subkeys, ' - 'count=$count, expiration=$expiration, ' - 'realExpiration=$realExpiration, ' - 'renewalTime=$renewalTime, ' - 'debugNames=${openedRecordInfo.debugNames}'); - - // Update watch states with real expiration - if (realExpiration.value != BigInt.zero) { - openedRecordInfo.shared.needsWatchStateUpdate = false; - _updateWatchRealExpirations( - openedRecordInfo.records, realExpiration, renewalTime); - success = true; - } - } on VeilidAPIException catch (e) { - // Failed to cancel DHT watch, try again next tick - log('Exception in watch update: $e'); - } - return success; - }); - } + _watchStateProcessors.updateState( + openedRecordKey, + unionWatchState, + (newState) => _stats.measure( + openedRecordKey, + openedRecordInfo.debugNames, + '_watchStateChange', + () => _watchStateChange(openedRecordKey, unionWatchState))); } } - - // Process all watch changes - return unord.isEmpty || - (await unord.map((f) => f()).wait).reduce((a, b) => a && b); }); - // If any watched did not success, back off the attempts to - // update the watches for a bit + ////////////////////////////////////////////////////////////// + // AsyncTableDBBacked + @override + String tableName() => 'dht_record_pool'; + @override + String tableKeyName() => 'pool_allocations'; + @override + DHTRecordPoolAllocations valueFromJson(Object? obj) => obj != null + ? DHTRecordPoolAllocations.fromJson(obj) + : const DHTRecordPoolAllocations(); + @override + Object? valueToJson(DHTRecordPoolAllocations? val) => val?.toJson(); - if (!allSuccess) { - _watchBackoffTimer *= watchBackoffMultiplier; - _watchBackoffTimer = min(_watchBackoffTimer, watchBackoffMax); - } else { - _watchBackoffTimer = 1; - } - } finally { - _inTick = false; - } - } + //////////////////////////////////////////////////////////////////////////// + // Fields - void debugPrintAllocations() { - final sortedAllocations = _state.debugNames.entries.asList() - ..sort((a, b) => a.key.compareTo(b.key)); + // Logger + DHTRecordPoolLogger? _logger; - log('DHTRecordPool Allocations: (count=${sortedAllocations.length})'); + // Persistent DHT record list + DHTRecordPoolAllocations _state; + // Create/open Mutex + final Mutex _mutex; + // Record key tag lock + final AsyncTagLock _recordTagLock; + // Which DHT records are currently open + final Map _opened; + // Which DHT records are marked for deletion + final Set _markedForDelete; + // Default routing context to use for new keys + final VeilidRoutingContext _routingContext; + // Convenience accessor + final Veilid _veilid; + Veilid get veilid => _veilid; + // Watch state processors + final _watchStateProcessors = + SingleStateProcessorMap(); + // Statistics + final _stats = DHTStats(); - for (final entry in sortedAllocations) { - log(' ${entry.key}: ${entry.value}'); - } - } - - void debugPrintOpened() { - final sortedOpened = _opened.entries.asList() - ..sort((a, b) => a.key.toString().compareTo(b.key.toString())); - - log('DHTRecordPool Opened Records: (count=${sortedOpened.length})'); - - for (final entry in sortedOpened) { - log(' ${entry.key}: \n' - ' debugNames=${entry.value.debugNames}\n' - ' details=${entry.value.details}\n' - ' sharedDetails=${entry.value.sharedDetails}\n'); - } - } + static DHTRecordPool? _singleton; } diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.freezed.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.freezed.dart index e09fc0c..48372bb 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.freezed.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.freezed.dart @@ -1,3 +1,4 @@ +// dart format width=80 // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint @@ -9,93 +10,58 @@ part of 'dht_record_pool.dart'; // FreezedGenerator // ************************************************************************** +// dart format off T _$identity(T value) => value; -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -DHTRecordPoolAllocations _$DHTRecordPoolAllocationsFromJson( - Map json) { - return _DHTRecordPoolAllocations.fromJson(json); -} - /// @nodoc mixin _$DHTRecordPoolAllocations { - IMap>> get childrenByParent => - throw _privateConstructorUsedError; - IMap> get parentByChild => - throw _privateConstructorUsedError; - ISet> get rootRecords => - throw _privateConstructorUsedError; - IMap get debugNames => throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $DHTRecordPoolAllocationsCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $DHTRecordPoolAllocationsCopyWith<$Res> { - factory $DHTRecordPoolAllocationsCopyWith(DHTRecordPoolAllocations value, - $Res Function(DHTRecordPoolAllocations) then) = - _$DHTRecordPoolAllocationsCopyWithImpl<$Res, DHTRecordPoolAllocations>; - @useResult - $Res call( - {IMap>> childrenByParent, - IMap> parentByChild, - ISet> rootRecords, - IMap debugNames}); -} - -/// @nodoc -class _$DHTRecordPoolAllocationsCopyWithImpl<$Res, - $Val extends DHTRecordPoolAllocations> - implements $DHTRecordPoolAllocationsCopyWith<$Res> { - _$DHTRecordPoolAllocationsCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; + IMap> get childrenByParent; + IMap get parentByChild; + ISet get rootRecords; + IMap get debugNames; + /// Create a copy of DHTRecordPoolAllocations + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') + $DHTRecordPoolAllocationsCopyWith get copyWith => + _$DHTRecordPoolAllocationsCopyWithImpl( + this as DHTRecordPoolAllocations, _$identity); + + /// Serializes this DHTRecordPoolAllocations to a JSON map. + Map toJson(); + @override - $Res call({ - Object? childrenByParent = null, - Object? parentByChild = null, - Object? rootRecords = null, - Object? debugNames = null, - }) { - return _then(_value.copyWith( - childrenByParent: null == childrenByParent - ? _value.childrenByParent - : childrenByParent // ignore: cast_nullable_to_non_nullable - as IMap>>, - parentByChild: null == parentByChild - ? _value.parentByChild - : parentByChild // ignore: cast_nullable_to_non_nullable - as IMap>, - rootRecords: null == rootRecords - ? _value.rootRecords - : rootRecords // ignore: cast_nullable_to_non_nullable - as ISet>, - debugNames: null == debugNames - ? _value.debugNames - : debugNames // ignore: cast_nullable_to_non_nullable - as IMap, - ) as $Val); + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is DHTRecordPoolAllocations && + (identical(other.childrenByParent, childrenByParent) || + other.childrenByParent == childrenByParent) && + (identical(other.parentByChild, parentByChild) || + other.parentByChild == parentByChild) && + const DeepCollectionEquality() + .equals(other.rootRecords, rootRecords) && + (identical(other.debugNames, debugNames) || + other.debugNames == debugNames)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, childrenByParent, parentByChild, + const DeepCollectionEquality().hash(rootRecords), debugNames); + + @override + String toString() { + return 'DHTRecordPoolAllocations(childrenByParent: $childrenByParent, parentByChild: $parentByChild, rootRecords: $rootRecords, debugNames: $debugNames)'; } } /// @nodoc -abstract class _$$DHTRecordPoolAllocationsImplCopyWith<$Res> - implements $DHTRecordPoolAllocationsCopyWith<$Res> { - factory _$$DHTRecordPoolAllocationsImplCopyWith( - _$DHTRecordPoolAllocationsImpl value, - $Res Function(_$DHTRecordPoolAllocationsImpl) then) = - __$$DHTRecordPoolAllocationsImplCopyWithImpl<$Res>; - @override +abstract mixin class $DHTRecordPoolAllocationsCopyWith<$Res> { + factory $DHTRecordPoolAllocationsCopyWith(DHTRecordPoolAllocations value, + $Res Function(DHTRecordPoolAllocations) _then) = + _$DHTRecordPoolAllocationsCopyWithImpl; @useResult $Res call( {IMap>> childrenByParent, @@ -105,15 +71,15 @@ abstract class _$$DHTRecordPoolAllocationsImplCopyWith<$Res> } /// @nodoc -class __$$DHTRecordPoolAllocationsImplCopyWithImpl<$Res> - extends _$DHTRecordPoolAllocationsCopyWithImpl<$Res, - _$DHTRecordPoolAllocationsImpl> - implements _$$DHTRecordPoolAllocationsImplCopyWith<$Res> { - __$$DHTRecordPoolAllocationsImplCopyWithImpl( - _$DHTRecordPoolAllocationsImpl _value, - $Res Function(_$DHTRecordPoolAllocationsImpl) _then) - : super(_value, _then); +class _$DHTRecordPoolAllocationsCopyWithImpl<$Res> + implements $DHTRecordPoolAllocationsCopyWith<$Res> { + _$DHTRecordPoolAllocationsCopyWithImpl(this._self, this._then); + final DHTRecordPoolAllocations _self; + final $Res Function(DHTRecordPoolAllocations) _then; + + /// Create a copy of DHTRecordPoolAllocations + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -122,21 +88,21 @@ class __$$DHTRecordPoolAllocationsImplCopyWithImpl<$Res> Object? rootRecords = null, Object? debugNames = null, }) { - return _then(_$DHTRecordPoolAllocationsImpl( + return _then(_self.copyWith( childrenByParent: null == childrenByParent - ? _value.childrenByParent + ? _self.childrenByParent! : childrenByParent // ignore: cast_nullable_to_non_nullable as IMap>>, parentByChild: null == parentByChild - ? _value.parentByChild + ? _self.parentByChild! : parentByChild // ignore: cast_nullable_to_non_nullable as IMap>, rootRecords: null == rootRecords - ? _value.rootRecords + ? _self.rootRecords! : rootRecords // ignore: cast_nullable_to_non_nullable as ISet>, debugNames: null == debugNames - ? _value.debugNames + ? _self.debugNames : debugNames // ignore: cast_nullable_to_non_nullable as IMap, )); @@ -145,15 +111,14 @@ class __$$DHTRecordPoolAllocationsImplCopyWithImpl<$Res> /// @nodoc @JsonSerializable() -class _$DHTRecordPoolAllocationsImpl implements _DHTRecordPoolAllocations { - const _$DHTRecordPoolAllocationsImpl( +class _DHTRecordPoolAllocations implements DHTRecordPoolAllocations { + const _DHTRecordPoolAllocations( {this.childrenByParent = const IMapConst>({}), this.parentByChild = const IMapConst({}), this.rootRecords = const ISetConst({}), this.debugNames = const IMapConst({})}); - - factory _$DHTRecordPoolAllocationsImpl.fromJson(Map json) => - _$$DHTRecordPoolAllocationsImplFromJson(json); + factory _DHTRecordPoolAllocations.fromJson(Map json) => + _$DHTRecordPoolAllocationsFromJson(json); @override @JsonKey() @@ -168,16 +133,27 @@ class _$DHTRecordPoolAllocationsImpl implements _DHTRecordPoolAllocations { @JsonKey() final IMap debugNames; + /// Create a copy of DHTRecordPoolAllocations + /// with the given fields replaced by the non-null parameter values. @override - String toString() { - return 'DHTRecordPoolAllocations(childrenByParent: $childrenByParent, parentByChild: $parentByChild, rootRecords: $rootRecords, debugNames: $debugNames)'; + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$DHTRecordPoolAllocationsCopyWith<_DHTRecordPoolAllocations> get copyWith => + __$DHTRecordPoolAllocationsCopyWithImpl<_DHTRecordPoolAllocations>( + this, _$identity); + + @override + Map toJson() { + return _$DHTRecordPoolAllocationsToJson( + this, + ); } @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$DHTRecordPoolAllocationsImpl && + other is _DHTRecordPoolAllocations && (identical(other.childrenByParent, childrenByParent) || other.childrenByParent == childrenByParent) && (identical(other.parentByChild, parentByChild) || @@ -188,140 +164,139 @@ class _$DHTRecordPoolAllocationsImpl implements _DHTRecordPoolAllocations { other.debugNames == debugNames)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType, childrenByParent, parentByChild, const DeepCollectionEquality().hash(rootRecords), debugNames); - @JsonKey(ignore: true) @override - @pragma('vm:prefer-inline') - _$$DHTRecordPoolAllocationsImplCopyWith<_$DHTRecordPoolAllocationsImpl> - get copyWith => __$$DHTRecordPoolAllocationsImplCopyWithImpl< - _$DHTRecordPoolAllocationsImpl>(this, _$identity); - - @override - Map toJson() { - return _$$DHTRecordPoolAllocationsImplToJson( - this, - ); + String toString() { + return 'DHTRecordPoolAllocations(childrenByParent: $childrenByParent, parentByChild: $parentByChild, rootRecords: $rootRecords, debugNames: $debugNames)'; } } -abstract class _DHTRecordPoolAllocations implements DHTRecordPoolAllocations { - const factory _DHTRecordPoolAllocations( - {final IMap>> childrenByParent, - final IMap> parentByChild, - final ISet> rootRecords, - final IMap debugNames}) = _$DHTRecordPoolAllocationsImpl; - - factory _DHTRecordPoolAllocations.fromJson(Map json) = - _$DHTRecordPoolAllocationsImpl.fromJson; - +/// @nodoc +abstract mixin class _$DHTRecordPoolAllocationsCopyWith<$Res> + implements $DHTRecordPoolAllocationsCopyWith<$Res> { + factory _$DHTRecordPoolAllocationsCopyWith(_DHTRecordPoolAllocations value, + $Res Function(_DHTRecordPoolAllocations) _then) = + __$DHTRecordPoolAllocationsCopyWithImpl; @override - IMap>> get childrenByParent; - @override - IMap> get parentByChild; - @override - ISet> get rootRecords; - @override - IMap get debugNames; - @override - @JsonKey(ignore: true) - _$$DHTRecordPoolAllocationsImplCopyWith<_$DHTRecordPoolAllocationsImpl> - get copyWith => throw _privateConstructorUsedError; + @useResult + $Res call( + {IMap>> childrenByParent, + IMap> parentByChild, + ISet> rootRecords, + IMap debugNames}); } -OwnedDHTRecordPointer _$OwnedDHTRecordPointerFromJson( - Map json) { - return _OwnedDHTRecordPointer.fromJson(json); +/// @nodoc +class __$DHTRecordPoolAllocationsCopyWithImpl<$Res> + implements _$DHTRecordPoolAllocationsCopyWith<$Res> { + __$DHTRecordPoolAllocationsCopyWithImpl(this._self, this._then); + + final _DHTRecordPoolAllocations _self; + final $Res Function(_DHTRecordPoolAllocations) _then; + + /// Create a copy of DHTRecordPoolAllocations + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? childrenByParent = null, + Object? parentByChild = null, + Object? rootRecords = null, + Object? debugNames = null, + }) { + return _then(_DHTRecordPoolAllocations( + childrenByParent: null == childrenByParent + ? _self.childrenByParent + : childrenByParent // ignore: cast_nullable_to_non_nullable + as IMap>>, + parentByChild: null == parentByChild + ? _self.parentByChild + : parentByChild // ignore: cast_nullable_to_non_nullable + as IMap>, + rootRecords: null == rootRecords + ? _self.rootRecords + : rootRecords // ignore: cast_nullable_to_non_nullable + as ISet>, + debugNames: null == debugNames + ? _self.debugNames + : debugNames // ignore: cast_nullable_to_non_nullable + as IMap, + )); + } } /// @nodoc mixin _$OwnedDHTRecordPointer { - Typed get recordKey => - throw _privateConstructorUsedError; - KeyPair get owner => throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $OwnedDHTRecordPointerCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $OwnedDHTRecordPointerCopyWith<$Res> { - factory $OwnedDHTRecordPointerCopyWith(OwnedDHTRecordPointer value, - $Res Function(OwnedDHTRecordPointer) then) = - _$OwnedDHTRecordPointerCopyWithImpl<$Res, OwnedDHTRecordPointer>; - @useResult - $Res call({Typed recordKey, KeyPair owner}); -} - -/// @nodoc -class _$OwnedDHTRecordPointerCopyWithImpl<$Res, - $Val extends OwnedDHTRecordPointer> - implements $OwnedDHTRecordPointerCopyWith<$Res> { - _$OwnedDHTRecordPointerCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; + TypedKey get recordKey; + KeyPair get owner; + /// Create a copy of OwnedDHTRecordPointer + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') + $OwnedDHTRecordPointerCopyWith get copyWith => + _$OwnedDHTRecordPointerCopyWithImpl( + this as OwnedDHTRecordPointer, _$identity); + + /// Serializes this OwnedDHTRecordPointer to a JSON map. + Map toJson(); + @override - $Res call({ - Object? recordKey = null, - Object? owner = null, - }) { - return _then(_value.copyWith( - recordKey: null == recordKey - ? _value.recordKey - : recordKey // ignore: cast_nullable_to_non_nullable - as Typed, - owner: null == owner - ? _value.owner - : owner // ignore: cast_nullable_to_non_nullable - as KeyPair, - ) as $Val); + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is OwnedDHTRecordPointer && + (identical(other.recordKey, recordKey) || + other.recordKey == recordKey) && + (identical(other.owner, owner) || other.owner == owner)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, recordKey, owner); + + @override + String toString() { + return 'OwnedDHTRecordPointer(recordKey: $recordKey, owner: $owner)'; } } /// @nodoc -abstract class _$$OwnedDHTRecordPointerImplCopyWith<$Res> - implements $OwnedDHTRecordPointerCopyWith<$Res> { - factory _$$OwnedDHTRecordPointerImplCopyWith( - _$OwnedDHTRecordPointerImpl value, - $Res Function(_$OwnedDHTRecordPointerImpl) then) = - __$$OwnedDHTRecordPointerImplCopyWithImpl<$Res>; - @override +abstract mixin class $OwnedDHTRecordPointerCopyWith<$Res> { + factory $OwnedDHTRecordPointerCopyWith(OwnedDHTRecordPointer value, + $Res Function(OwnedDHTRecordPointer) _then) = + _$OwnedDHTRecordPointerCopyWithImpl; @useResult $Res call({Typed recordKey, KeyPair owner}); } /// @nodoc -class __$$OwnedDHTRecordPointerImplCopyWithImpl<$Res> - extends _$OwnedDHTRecordPointerCopyWithImpl<$Res, - _$OwnedDHTRecordPointerImpl> - implements _$$OwnedDHTRecordPointerImplCopyWith<$Res> { - __$$OwnedDHTRecordPointerImplCopyWithImpl(_$OwnedDHTRecordPointerImpl _value, - $Res Function(_$OwnedDHTRecordPointerImpl) _then) - : super(_value, _then); +class _$OwnedDHTRecordPointerCopyWithImpl<$Res> + implements $OwnedDHTRecordPointerCopyWith<$Res> { + _$OwnedDHTRecordPointerCopyWithImpl(this._self, this._then); + final OwnedDHTRecordPointer _self; + final $Res Function(OwnedDHTRecordPointer) _then; + + /// Create a copy of OwnedDHTRecordPointer + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ Object? recordKey = null, Object? owner = null, }) { - return _then(_$OwnedDHTRecordPointerImpl( + return _then(_self.copyWith( recordKey: null == recordKey - ? _value.recordKey + ? _self.recordKey! : recordKey // ignore: cast_nullable_to_non_nullable as Typed, owner: null == owner - ? _value.owner + ? _self.owner : owner // ignore: cast_nullable_to_non_nullable as KeyPair, )); @@ -330,66 +305,90 @@ class __$$OwnedDHTRecordPointerImplCopyWithImpl<$Res> /// @nodoc @JsonSerializable() -class _$OwnedDHTRecordPointerImpl implements _OwnedDHTRecordPointer { - const _$OwnedDHTRecordPointerImpl( - {required this.recordKey, required this.owner}); - - factory _$OwnedDHTRecordPointerImpl.fromJson(Map json) => - _$$OwnedDHTRecordPointerImplFromJson(json); +class _OwnedDHTRecordPointer implements OwnedDHTRecordPointer { + const _OwnedDHTRecordPointer({required this.recordKey, required this.owner}); + factory _OwnedDHTRecordPointer.fromJson(Map json) => + _$OwnedDHTRecordPointerFromJson(json); @override final Typed recordKey; @override final KeyPair owner; + /// Create a copy of OwnedDHTRecordPointer + /// with the given fields replaced by the non-null parameter values. @override - String toString() { - return 'OwnedDHTRecordPointer(recordKey: $recordKey, owner: $owner)'; + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$OwnedDHTRecordPointerCopyWith<_OwnedDHTRecordPointer> get copyWith => + __$OwnedDHTRecordPointerCopyWithImpl<_OwnedDHTRecordPointer>( + this, _$identity); + + @override + Map toJson() { + return _$OwnedDHTRecordPointerToJson( + this, + ); } @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$OwnedDHTRecordPointerImpl && + other is _OwnedDHTRecordPointer && (identical(other.recordKey, recordKey) || other.recordKey == recordKey) && (identical(other.owner, owner) || other.owner == owner)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType, recordKey, owner); - @JsonKey(ignore: true) @override - @pragma('vm:prefer-inline') - _$$OwnedDHTRecordPointerImplCopyWith<_$OwnedDHTRecordPointerImpl> - get copyWith => __$$OwnedDHTRecordPointerImplCopyWithImpl< - _$OwnedDHTRecordPointerImpl>(this, _$identity); - - @override - Map toJson() { - return _$$OwnedDHTRecordPointerImplToJson( - this, - ); + String toString() { + return 'OwnedDHTRecordPointer(recordKey: $recordKey, owner: $owner)'; } } -abstract class _OwnedDHTRecordPointer implements OwnedDHTRecordPointer { - const factory _OwnedDHTRecordPointer( - {required final Typed recordKey, - required final KeyPair owner}) = _$OwnedDHTRecordPointerImpl; - - factory _OwnedDHTRecordPointer.fromJson(Map json) = - _$OwnedDHTRecordPointerImpl.fromJson; - +/// @nodoc +abstract mixin class _$OwnedDHTRecordPointerCopyWith<$Res> + implements $OwnedDHTRecordPointerCopyWith<$Res> { + factory _$OwnedDHTRecordPointerCopyWith(_OwnedDHTRecordPointer value, + $Res Function(_OwnedDHTRecordPointer) _then) = + __$OwnedDHTRecordPointerCopyWithImpl; @override - Typed get recordKey; - @override - KeyPair get owner; - @override - @JsonKey(ignore: true) - _$$OwnedDHTRecordPointerImplCopyWith<_$OwnedDHTRecordPointerImpl> - get copyWith => throw _privateConstructorUsedError; + @useResult + $Res call({Typed recordKey, KeyPair owner}); } + +/// @nodoc +class __$OwnedDHTRecordPointerCopyWithImpl<$Res> + implements _$OwnedDHTRecordPointerCopyWith<$Res> { + __$OwnedDHTRecordPointerCopyWithImpl(this._self, this._then); + + final _OwnedDHTRecordPointer _self; + final $Res Function(_OwnedDHTRecordPointer) _then; + + /// Create a copy of OwnedDHTRecordPointer + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? recordKey = null, + Object? owner = null, + }) { + return _then(_OwnedDHTRecordPointer( + recordKey: null == recordKey + ? _self.recordKey + : recordKey // ignore: cast_nullable_to_non_nullable + as Typed, + owner: null == owner + ? _self.owner + : owner // ignore: cast_nullable_to_non_nullable + as KeyPair, + )); + } +} + +// dart format on diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.g.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.g.dart index 12b3a1e..c2c031f 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.g.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.g.dart @@ -6,9 +6,9 @@ part of 'dht_record_pool.dart'; // JsonSerializableGenerator // ************************************************************************** -_$DHTRecordPoolAllocationsImpl _$$DHTRecordPoolAllocationsImplFromJson( +_DHTRecordPoolAllocations _$DHTRecordPoolAllocationsFromJson( Map json) => - _$DHTRecordPoolAllocationsImpl( + _DHTRecordPoolAllocations( childrenByParent: json['children_by_parent'] == null ? const IMapConst>({}) : IMap>>.fromJson( @@ -34,8 +34,8 @@ _$DHTRecordPoolAllocationsImpl _$$DHTRecordPoolAllocationsImplFromJson( (value) => value as String), ); -Map _$$DHTRecordPoolAllocationsImplToJson( - _$DHTRecordPoolAllocationsImpl instance) => +Map _$DHTRecordPoolAllocationsToJson( + _DHTRecordPoolAllocations instance) => { 'children_by_parent': instance.childrenByParent.toJson( (value) => value, @@ -56,15 +56,15 @@ Map _$$DHTRecordPoolAllocationsImplToJson( ), }; -_$OwnedDHTRecordPointerImpl _$$OwnedDHTRecordPointerImplFromJson( +_OwnedDHTRecordPointer _$OwnedDHTRecordPointerFromJson( Map json) => - _$OwnedDHTRecordPointerImpl( + _OwnedDHTRecordPointer( recordKey: Typed.fromJson(json['record_key']), owner: KeyPair.fromJson(json['owner']), ); -Map _$$OwnedDHTRecordPointerImplToJson( - _$OwnedDHTRecordPointerImpl instance) => +Map _$OwnedDHTRecordPointerToJson( + _OwnedDHTRecordPointer instance) => { 'record_key': instance.recordKey.toJson(), 'owner': instance.owner.toJson(), diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool_private.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool_private.dart new file mode 100644 index 0000000..2474c87 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool_private.dart @@ -0,0 +1,68 @@ +part of 'dht_record_pool.dart'; + +// DHT crypto domain +const _cryptoDomainDHT = 'dht'; + +// Singlefuture keys +const _sfPollWatch = '_pollWatch'; +const _sfListen = 'listen'; + +/// Watch state +@immutable +class _WatchState extends Equatable { + const _WatchState({ + required this.subkeys, + required this.expiration, + required this.count, + }); + final List? subkeys; + final Timestamp? expiration; + final int? count; + + @override + List get props => [subkeys, expiration, count]; +} + +/// Data shared amongst all DHTRecord instances +class _SharedDHTRecordData { + _SharedDHTRecordData( + {required this.recordDescriptor, + required this.defaultWriter, + required this.defaultRoutingContext}); + DHTRecordDescriptor recordDescriptor; + KeyPair? defaultWriter; + VeilidRoutingContext defaultRoutingContext; + // lint conflict + // ignore: omit_obvious_property_types + bool needsWatchStateUpdate = false; + _WatchState? unionWatchState; +} + +// Per opened record data +class _OpenedRecordInfo { + _OpenedRecordInfo( + {required DHTRecordDescriptor recordDescriptor, + required KeyPair? defaultWriter, + required VeilidRoutingContext defaultRoutingContext}) + : shared = _SharedDHTRecordData( + recordDescriptor: recordDescriptor, + defaultWriter: defaultWriter, + defaultRoutingContext: defaultRoutingContext); + _SharedDHTRecordData shared; + Set records = {}; + + String get debugNames { + final r = records.toList() + ..sort((a, b) => a.key.toString().compareTo(b.key.toString())); + return '[${r.map((x) => x.debugName).join(',')}]'; + } + + String get details { + final r = records.toList() + ..sort((a, b) => a.key.toString().compareTo(b.key.toString())); + return '[${r.map((x) => "writer=${x._writer} " + "defaultSubkey=${x._defaultSubkey}").join(',')}]'; + } + + String get sharedDetails => shared.toString(); +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/extensions.dart b/packages/veilid_support/lib/dht_support/src/dht_record/extensions.dart new file mode 100644 index 0000000..b0da9e3 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_record/extensions.dart @@ -0,0 +1,61 @@ +import 'package:veilid/veilid.dart'; + +class DHTSeqChange { + const DHTSeqChange(this.subkey, this.oldSeq, this.newSeq); + final int subkey; + final int? oldSeq; + final int newSeq; +} + +extension DHTReportReportExt on DHTRecordReport { + List get newerOnlineSubkeys { + if (networkSeqs.isEmpty || localSeqs.isEmpty || subkeys.isEmpty) { + return []; + } + + final currentSubkeys = []; + + var i = 0; + for (final skr in subkeys) { + for (var sk = skr.low; sk <= skr.high; sk++) { + final nseq = networkSeqs[i]; + final lseq = localSeqs[i]; + + if (nseq != null && (lseq == null || nseq > lseq)) { + if (currentSubkeys.isNotEmpty && + currentSubkeys.last.high == (sk - 1)) { + currentSubkeys.add(ValueSubkeyRange( + low: currentSubkeys.removeLast().low, high: sk)); + } else { + currentSubkeys.add(ValueSubkeyRange.single(sk)); + } + } + + i++; + } + } + + return currentSubkeys; + } + + DHTSeqChange? get firstSeqChange { + if (networkSeqs.isEmpty || localSeqs.isEmpty || subkeys.isEmpty) { + return null; + } + + var i = 0; + for (final skr in subkeys) { + for (var sk = skr.low; sk <= skr.high; sk++) { + final nseq = networkSeqs[i]; + final lseq = localSeqs[i]; + + if (nseq != null && (lseq == null || nseq > lseq)) { + return DHTSeqChange(sk, lseq, nseq); + } + i++; + } + } + + return null; + } +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/stats.dart b/packages/veilid_support/lib/dht_support/src/dht_record/stats.dart new file mode 100644 index 0000000..22b517b --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_record/stats.dart @@ -0,0 +1,180 @@ +import 'package:collection/collection.dart'; +import 'package:indent/indent.dart'; + +import '../../../veilid_support.dart'; + +const maxLatencySamples = 100; +const timeoutDuration = 10; + +extension LatencyStatsExt on LatencyStats { + String debugString() => 'fast($fastest)/avg($average)/slow($slowest)/' + 'tm90($tm90)/tm75($tm75)/p90($p90)/p75($p75)'; +} + +class LatencyStatsAccounting { + LatencyStatsAccounting({required this.maxSamples}); + + LatencyStats record(TimestampDuration dur) { + _samples.add(dur); + if (_samples.length > maxSamples) { + _samples.removeAt(0); + } + + final sortedList = _samples.sorted(); + + final fastest = sortedList.first; + final slowest = sortedList.last; + final average = TimestampDuration( + value: sortedList.fold(BigInt.zero, (acc, x) => acc + x.value) ~/ + BigInt.from(sortedList.length)); + + final tm90len = (sortedList.length * 90 + 99) ~/ 100; + final tm75len = (sortedList.length * 75 + 99) ~/ 100; + final tm90 = TimestampDuration( + value: sortedList + .sublist(0, tm90len) + .fold(BigInt.zero, (acc, x) => acc + x.value) ~/ + BigInt.from(tm90len)); + final tm75 = TimestampDuration( + value: sortedList + .sublist(0, tm75len) + .fold(BigInt.zero, (acc, x) => acc + x.value) ~/ + BigInt.from(tm90len)); + final p90 = sortedList[tm90len - 1]; + final p75 = sortedList[tm75len - 1]; + + final ls = LatencyStats( + fastest: fastest, + slowest: slowest, + average: average, + tm90: tm90, + tm75: tm75, + p90: p90, + p75: p75); + + return ls; + } + + ///////////////////////////// + final int maxSamples; + final _samples = []; +} + +class DHTCallStats { + void record(TimestampDuration dur, Exception? exc) { + final wasTimeout = + exc is VeilidAPIExceptionTimeout || dur.toSecs() >= timeoutDuration; + + calls++; + if (wasTimeout) { + timeouts++; + } else { + successLatency = successLatencyAcct.record(dur); + } + latency = latencyAcct.record(dur); + } + + String debugString() => + ' timeouts/calls: $timeouts/$calls (${(timeouts * 100 / calls).toStringAsFixed(3)}%)\n' + 'success latency: ${successLatency?.debugString()}\n' + ' all latency: ${latency?.debugString()}\n'; + + ///////////////////////////// + + // lint conflict + // ignore: omit_obvious_property_types + int calls = 0; + // lint conflict + // ignore: omit_obvious_property_types + int timeouts = 0; + LatencyStats? latency; + LatencyStats? successLatency; + final latencyAcct = LatencyStatsAccounting(maxSamples: maxLatencySamples); + final successLatencyAcct = + LatencyStatsAccounting(maxSamples: maxLatencySamples); +} + +class DHTPerKeyStats { + DHTPerKeyStats(this.debugName); + + void record(String func, TimestampDuration dur, Exception? exc) { + final keyFuncStats = _perFuncStats.putIfAbsent(func, DHTCallStats.new); + + _stats.record(dur, exc); + keyFuncStats.record(dur, exc); + } + + String debugString() { + // + final out = StringBuffer() + ..write('Name: $debugName\n') + ..write(_stats.debugString().indent(4)) + ..writeln('Per-Function:'); + for (final entry in _perFuncStats.entries) { + final funcName = entry.key; + final funcStats = entry.value.debugString().indent(4); + out.write('$funcName:\n$funcStats'.indent(4)); + } + + return out.toString(); + } + + ////////////////////////////// + + final String debugName; + final _stats = DHTCallStats(); + final _perFuncStats = {}; +} + +class DHTStats { + DHTStats(); + + Future measure(TypedKey key, String debugName, String func, + Future Function() closure) async { + // + final start = Veilid.instance.now(); + final keyStats = + _statsPerKey.putIfAbsent(key, () => DHTPerKeyStats(debugName)); + final funcStats = _statsPerFunc.putIfAbsent(func, DHTCallStats.new); + + VeilidAPIException? exc; + + try { + final res = await closure(); + + return res; + } on VeilidAPIException catch (e) { + exc = e; + rethrow; + } finally { + final end = Veilid.instance.now(); + final dur = end.diff(start); + + keyStats.record(func, dur, exc); + funcStats.record(dur, exc); + } + } + + String debugString() { + // + final out = StringBuffer()..writeln('Per-Function:'); + for (final entry in _statsPerFunc.entries) { + final funcName = entry.key; + final funcStats = entry.value.debugString().indent(4); + out.write('$funcName:\n$funcStats\n'.indent(4)); + } + out.writeln('Per-Key:'); + for (final entry in _statsPerKey.entries) { + final keyName = entry.key; + final keyStats = entry.value.debugString().indent(4); + out.write('$keyName:\n$keyStats\n'.indent(4)); + } + + return out.toString(); + } + + ////////////////////////////// + + final _statsPerKey = {}; + final _statsPerFunc = {}; +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array.dart b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array.dart index fe291ca..4a03cd9 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:async_tools/async_tools.dart'; import 'package:collection/collection.dart'; +import '../../../src/veilid_log.dart'; import '../../../veilid_support.dart'; import '../../proto/proto.dart' as proto; @@ -68,7 +69,7 @@ class DHTShortArray implements DHTDeleteable { } }); return dhtShortArray; - } on Exception catch (_) { + } on Exception { await dhtRecord.close(); await pool.deleteRecord(dhtRecord.key); rethrow; @@ -89,7 +90,7 @@ class DHTShortArray implements DHTDeleteable { final dhtShortArray = DHTShortArray._(headRecord: dhtRecord); await dhtShortArray._head.operate((head) => head._loadHead()); return dhtShortArray; - } on Exception catch (_) { + } on Exception { await dhtRecord.close(); rethrow; } @@ -113,7 +114,7 @@ class DHTShortArray implements DHTDeleteable { final dhtShortArray = DHTShortArray._(headRecord: dhtRecord); await dhtShortArray._head.operate((head) => head._loadHead()); return dhtShortArray; - } on Exception catch (_) { + } on Exception { await dhtRecord.close(); rethrow; } @@ -148,33 +149,32 @@ class DHTShortArray implements DHTDeleteable { /// Add a reference to this shortarray @override - Future ref() async => _mutex.protect(() async { - _openCount++; - }); + void ref() { + _openCount++; + } /// Free all resources for the DHTShortArray @override - Future close() async => _mutex.protect(() async { - if (_openCount == 0) { - throw StateError('already closed'); - } - _openCount--; - if (_openCount != 0) { - return false; - } + Future close() async { + if (_openCount == 0) { + throw StateError('already closed'); + } + _openCount--; + if (_openCount != 0) { + return false; + } - await _watchController?.close(); - _watchController = null; - await _head.close(); - return true; - }); + await _watchController?.close(); + _watchController = null; + await _head.close(); + return true; + } /// Free all resources for the DHTShortArray and delete it from the DHT - /// Will wait until the short array is closed to delete it + /// Returns true if the deletion was processed immediately + /// Returns false if the deletion was marked for later @override - Future delete() async { - await _head.delete(); - } + Future delete() => _head.delete(); //////////////////////////////////////////////////////////////////////////// // Public API @@ -201,12 +201,12 @@ class DHTShortArray implements DHTDeleteable { /// Runs a closure allowing read-only access to the shortarray Future operate( - Future Function(DHTShortArrayReadOperations) closure) async { + Future Function(DHTShortArrayReadOperations) closure) { if (!isOpen) { throw StateError('short array is not open"'); } - return _head.operate((head) async { + return _head.operate((head) { final reader = _DHTShortArrayRead._(head); return closure(reader); }); @@ -218,12 +218,12 @@ class DHTShortArray implements DHTDeleteable { /// Throws DHTOperateException if the write could not be performed /// at this time Future operateWrite( - Future Function(DHTShortArrayWriteOperations) closure) async { + Future Function(DHTShortArrayWriteOperations) closure) { if (!isOpen) { throw StateError('short array is not open"'); } - return _head.operateWrite((head) async { + return _head.operateWrite((head) { final writer = _DHTShortArrayWrite._(head); return closure(writer); }); @@ -237,12 +237,12 @@ class DHTShortArray implements DHTDeleteable { /// eventual consistency pass. Future operateWriteEventual( Future Function(DHTShortArrayWriteOperations) closure, - {Duration? timeout}) async { + {Duration? timeout}) { if (!isOpen) { throw StateError('short array is not open"'); } - return _head.operateWriteEventual((head) async { + return _head.operateWriteEventual((head) { final writer = _DHTShortArrayWrite._(head); return closure(writer); }, timeout: timeout); @@ -289,10 +289,9 @@ class DHTShortArray implements DHTDeleteable { // Openable int _openCount; - final _mutex = Mutex(); // Watch mutex to ensure we keep the representation valid - final Mutex _listenMutex = Mutex(); + final _listenMutex = Mutex(debugLockTimeout: kIsDebugMode ? 60 : null); // Stream of external changes StreamController? _watchController; } diff --git a/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_cubit.dart b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_cubit.dart index d9e1e57..246a990 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_cubit.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_cubit.dart @@ -3,44 +3,49 @@ import 'dart:async'; import 'package:async_tools/async_tools.dart'; import 'package:bloc/bloc.dart'; import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; -import 'package:equatable/equatable.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:meta/meta.dart'; import '../../../veilid_support.dart'; -@immutable -class DHTShortArrayElementState extends Equatable { - const DHTShortArrayElementState( - {required this.value, required this.isOffline}); - final T value; - final bool isOffline; +typedef DHTShortArrayState = AsyncValue>>; +typedef DHTShortArrayCubitState = BlocBusyState>; - @override - List get props => [value, isOffline]; -} - -typedef DHTShortArrayState = AsyncValue>>; -typedef DHTShortArrayBusyState = BlocBusyState>; - -class DHTShortArrayCubit extends Cubit> - with BlocBusyWrapper> { +class DHTShortArrayCubit extends Cubit> + with BlocBusyWrapper>, RefreshableCubit { DHTShortArrayCubit({ required Future Function() open, required T Function(List data) decodeElement, }) : _decodeElement = decodeElement, super(const BlocBusyState(AsyncValue.loading())) { - _initWait.add(() async { - // Open DHT record - _shortArray = await open(); - _wantsCloseRecord = true; + _initWait.add((cancel) async { + try { + // Do record open/create + while (!cancel.isCompleted) { + try { + // Open DHT record + _shortArray = await open(); + _wantsCloseRecord = true; + break; + } on DHTExceptionNotAvailable { + // Wait for a bit + await asyncSleep(); + } + } + } on Exception catch (e, st) { + addError(e, st); + emit(DHTShortArrayCubitState(AsyncValue.error(e, st))); + return; + } - // Make initial state update - await _refreshNoWait(); + // Kick off initial update + _update(); + + // Subscribe to changes _subscription = await _shortArray.listen(_update); }); } + @override Future refresh({bool forceRefresh = false}) async { await _initWait(); await _refreshNoWait(forceRefresh: forceRefresh); @@ -57,32 +62,35 @@ class DHTShortArrayCubit extends Cubit> Set? offlinePositions; if (_shortArray.writer != null) { offlinePositions = await reader.getOfflinePositions(); - if (offlinePositions == null) { - return null; - } } // Get the items final allItems = (await reader.getRange(0, forceRefresh: forceRefresh)) ?.indexed - .map((x) => DHTShortArrayElementState( + .map((x) => OnlineElementState( value: _decodeElement(x.$2), isOffline: offlinePositions?.contains(x.$1) ?? false)) .toIList(); return allItems; }); - if (newState != null) { - emit(AsyncValue.data(newState)); + if (newState == null) { + // Mark us as needing refresh + setWantsRefresh(); + return; } - } on Exception catch (e) { - emit(AsyncValue.error(e)); + emit(AsyncValue.data(newState)); + setRefreshed(); + } on Exception catch (e, st) { + addError(e, st); + emit(AsyncValue.error(e, st)); } } void _update() { // Run at most one background update process // Because this is async, we could get an update while we're - // still processing the last one. Only called after init future has run + // still processing the last one. + // Only called after init future has run, or during it // so we dont have to wait for that here. _sspUpdate.busyUpdate>( busy, (emit) async => _refreshInner(emit)); @@ -90,7 +98,7 @@ class DHTShortArrayCubit extends Cubit> @override Future close() async { - await _initWait(); + await _initWait(cancelValue: true); await _subscription?.cancel(); _subscription = null; if (_wantsCloseRecord) { @@ -118,7 +126,7 @@ class DHTShortArrayCubit extends Cubit> return _shortArray.operateWriteEventual(closure, timeout: timeout); } - final WaitSet _initWait = WaitSet(); + final WaitSet _initWait = WaitSet(); late final DHTShortArray _shortArray; final T Function(List data) _decodeElement; StreamSubscription? _subscription; diff --git a/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_head.dart b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_head.dart index 45c4e71..5b224cb 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_head.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_head.dart @@ -5,7 +5,7 @@ class DHTShortArrayHeadLookup { {required this.record, required this.recordSubkey, required this.seq}); final DHTRecord record; final int recordSubkey; - final int seq; + final int? seq; } class _DHTShortArrayHead { @@ -41,7 +41,7 @@ class _DHTShortArrayHead { final head = proto.DHTShortArray(); head.keys.addAll(_linkedRecords.map((lr) => lr.key.toProto())); head.index = List.of(_index); - head.seqs.addAll(_seqs); + head.seqs.addAll(_seqs.map((x) => x ?? 0xFFFFFFFF)); // Do not serialize free list, it gets recreated // Do not serialize local seqs, they are only locally relevant return head; @@ -65,18 +65,12 @@ class _DHTShortArrayHead { }); } - Future delete() async { - await _headMutex.protect(() async { - // Will deep delete all linked records as they are children - await _headRecord.delete(); - }); - } + /// Returns true if the deletion was processed immediately + /// Returns false if the deletion was marked for later + Future delete() => _headMutex.protect(_headRecord.delete); Future operate(Future Function(_DHTShortArrayHead) closure) async => - // ignore: prefer_expression_function_bodies - _headMutex.protect(() async { - return closure(this); - }); + _headMutex.protect(() async => closure(this)); Future operateWrite( Future Function(_DHTShortArrayHead) closure) async => @@ -91,7 +85,7 @@ class _DHTShortArrayHead { if (!await _writeHead()) { // Failed to write head means head got overwritten so write should // be considered failed - throw DHTExceptionTryAgain(); + throw const DHTExceptionOutdated(); } onUpdatedHead?.call(); @@ -118,7 +112,7 @@ class _DHTShortArrayHead { late List oldLinkedRecords; late List oldIndex; late List oldFree; - late List oldSeqs; + late List oldSeqs; late T out; try { @@ -143,7 +137,7 @@ class _DHTShortArrayHead { try { out = await closure(this); break; - } on DHTExceptionTryAgain { + } on DHTExceptionOutdated { // Failed to write in closure resets state _linkedRecords = List.of(oldLinkedRecords); _index = List.of(oldIndex); @@ -200,7 +194,8 @@ class _DHTShortArrayHead { // Get the set of new linked keys and validate it final updatedLinkedKeys = head.keys.map((p) => p.toVeilid()).toList(); final updatedIndex = List.of(head.index); - final updatedSeqs = List.of(head.seqs); + final updatedSeqs = + List.of(head.seqs.map((x) => x == 0xFFFFFFFF ? null : x)); final updatedFree = _makeFreeList(updatedLinkedKeys, updatedIndex); // See which records are actually new @@ -336,7 +331,7 @@ class _DHTShortArrayHead { } Future lookupIndex(int idx, bool allowCreate) async { - final seq = idx < _seqs.length ? _seqs[idx] : 0xFFFFFFFF; + final seq = idx < _seqs.length ? _seqs[idx] : null; final recordNumber = idx ~/ _stride; final record = await _getOrCreateLinkedRecord(recordNumber, allowCreate); final recordSubkey = (idx % _stride) + ((recordNumber == 0) ? 1 : 0); @@ -386,6 +381,24 @@ class _DHTShortArrayHead { // xxx: free list optimization here? } + /// Truncate index to a particular length + void truncate(int newLength) { + if (newLength >= _index.length) { + return; + } else if (newLength == 0) { + clearIndex(); + return; + } else if (newLength < 0) { + throw StateError('can not truncate to negative length'); + } + + final newIndex = _index.sublist(0, newLength); + final freed = _index.sublist(newLength); + + _index = newIndex; + _free.addAll(freed); + } + /// Validate the head from the DHT is properly formatted /// and calculate the free list from it while we're here List _makeFreeList( @@ -430,18 +443,18 @@ class _DHTShortArrayHead { // If our local sequence number is unknown or hasnt been written yet // then a normal DHT operation is going to pull from the network anyway - if (_localSeqs.length < idx || _localSeqs[idx] == 0xFFFFFFFF) { + if (_localSeqs.length < idx || _localSeqs[idx] == null) { return false; } // If the remote sequence number record is unknown or hasnt been written // at this index yet, then we also do not refresh at this time as it // is the first time the index is being written to - if (_seqs.length < idx || _seqs[idx] == 0xFFFFFFFF) { + if (_seqs.length < idx || _seqs[idx] == null) { return false; } - return _localSeqs[idx] < _seqs[idx]; + return _localSeqs[idx]! < _seqs[idx]!; } /// Update the sequence number for a particular index in @@ -451,12 +464,12 @@ class _DHTShortArrayHead { final idx = _index[pos]; while (_localSeqs.length <= idx) { - _localSeqs.add(0xFFFFFFFF); + _localSeqs.add(null); } _localSeqs[idx] = newSeq; if (write) { while (_seqs.length <= idx) { - _seqs.add(0xFFFFFFFF); + _seqs.add(null); } _seqs[idx] = newSeq; } @@ -469,13 +482,13 @@ class _DHTShortArrayHead { Future watch() async { // This will update any existing watches if necessary try { - await _headRecord.watch(subkeys: [ValueSubkeyRange.single(0)]); - // Update changes to the head record // Don't watch for local changes because this class already handles // notifying listeners and knows when it makes local changes _subscription ??= await _headRecord.listen(localChanges: false, _onHeadValueChanged); + + await _headRecord.watch(subkeys: [ValueSubkeyRange.single(0)]); } on Exception { // If anything fails, try to cancel the watches await cancelWatch(); @@ -518,7 +531,7 @@ class _DHTShortArrayHead { //////////////////////////////////////////////////////////////////////////// // Head/element mutex to ensure we keep the representation valid - final Mutex _headMutex = Mutex(); + final _headMutex = Mutex(debugLockTimeout: kIsDebugMode ? 60 : null); // Subscription to head record internal changes StreamSubscription? _subscription; // Notify closure for external head changes @@ -540,7 +553,7 @@ class _DHTShortArrayHead { // The sequence numbers of each subkey. // Index is by subkey number not by element index. // (n-1 for head record and then the next n for linked records) - List _seqs; + List _seqs; // The local sequence numbers for each subkey. - List _localSeqs; + List _localSeqs; } diff --git a/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_read.dart b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_read.dart index abe7198..eeb9648 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_read.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_read.dart @@ -17,21 +17,25 @@ class _DHTShortArrayRead implements DHTShortArrayReadOperations { throw IndexError.withLength(pos, length); } - final lookup = await _head.lookupPosition(pos, false); + try { + final lookup = await _head.lookupPosition(pos, false); - final refresh = forceRefresh || _head.positionNeedsRefresh(pos); - final outSeqNum = Output(); - final out = lookup.record.get( - subkey: lookup.recordSubkey, - refreshMode: refresh - ? DHTRecordRefreshMode.network - : DHTRecordRefreshMode.cached, - outSeqNum: outSeqNum); - if (outSeqNum.value != null) { - _head.updatePositionSeq(pos, false, outSeqNum.value!); + final refresh = forceRefresh || _head.positionNeedsRefresh(pos); + final outSeqNum = Output(); + final out = await lookup.record.get( + subkey: lookup.recordSubkey, + refreshMode: refresh + ? DHTRecordRefreshMode.network + : DHTRecordRefreshMode.cached, + outSeqNum: outSeqNum); + if (outSeqNum.value != null) { + _head.updatePositionSeq(pos, false, outSeqNum.value!); + } + return out; + } on DHTExceptionNotAvailable { + // If any element is not available, return null + return null; } - - return out; } (int, int) _clampStartLen(int start, int? len) { @@ -54,16 +58,34 @@ class _DHTShortArrayRead implements DHTShortArrayReadOperations { final out = []; (start, length) = _clampStartLen(start, length); - final chunks = Iterable.generate(length).slices(maxDHTConcurrency).map( - (chunk) => - chunk.map((pos) => get(pos + start, forceRefresh: forceRefresh))); + final chunks = Iterable.generate(length) + .slices(kMaxDHTConcurrency) + .map((chunk) => chunk.map((pos) async { + try { + return await get(pos + start, forceRefresh: forceRefresh); + // Need some way to debug ParallelWaitError + // ignore: avoid_catches_without_on_clauses + } catch (e, st) { + veilidLoggy.error('$e\n$st\n'); + rethrow; + } + })); for (final chunk in chunks) { - final elems = await chunk.wait; - if (elems.contains(null)) { - return null; + var elems = await chunk.wait; + + // Return only the first contiguous range, anything else is garbage + // due to a representational error in the head or shortarray legnth + final nullPos = elems.indexOf(null); + if (nullPos != -1) { + elems = elems.sublist(0, nullPos); } + out.addAll(elems.cast()); + + if (nullPos != -1) { + break; + } } return out; diff --git a/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_write.dart b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_write.dart index 665ea00..bd3431d 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_write.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_write.dart @@ -6,9 +6,11 @@ part of 'dht_short_array.dart'; abstract class DHTShortArrayWriteOperations implements DHTRandomRead, + DHTRandomSwap, DHTRandomWrite, DHTInsertRemove, DHTAdd, + DHTTruncate, DHTClear {} class _DHTShortArrayWrite extends _DHTShortArrayRead @@ -40,7 +42,7 @@ class _DHTShortArrayWrite extends _DHTShortArrayRead } } if (!success) { - throw DHTExceptionTryAgain(); + throw const DHTExceptionOutdated(); } } @@ -66,21 +68,27 @@ class _DHTShortArrayWrite extends _DHTShortArrayRead } // Write items in parallel - final dws = DelayedWaitSet(); + final dws = DelayedWaitSet(); for (var i = 0; i < values.length; i++) { final lookup = lookups[i]; final value = values[i]; final outSeqNum = outSeqNums[i]; - dws.add(() async { - final outValue = await lookup.record.tryWriteBytes(value, - subkey: lookup.recordSubkey, outSeqNum: outSeqNum); - if (outValue != null) { - success = false; + dws.add((_) async { + try { + final outValue = await lookup.record.tryWriteBytes(value, + subkey: lookup.recordSubkey, outSeqNum: outSeqNum); + if (outValue != null) { + success = false; + } + // Need some way to debug ParallelWaitError + // ignore: avoid_catches_without_on_clauses + } catch (e, st) { + veilidLoggy.error('$e\n$st\n'); } }); } - await dws(chunkSize: maxDHTConcurrency, onChunkDone: (_) => success); + await dws(chunkSize: kMaxDHTConcurrency, onChunkDone: (_) => success); } finally { // Update sequence numbers for (var i = 0; i < values.length; i++) { @@ -97,7 +105,7 @@ class _DHTShortArrayWrite extends _DHTShortArrayRead } } if (!success) { - throw DHTExceptionTryAgain(); + throw const DHTExceptionOutdated(); } } @@ -122,7 +130,7 @@ class _DHTShortArrayWrite extends _DHTShortArrayRead final outSeqNum = Output(); - final result = lookup.seq == 0xFFFFFFFF + final result = lookup.seq == null ? null : await lookup.record.get(subkey: lookup.recordSubkey); @@ -142,6 +150,11 @@ class _DHTShortArrayWrite extends _DHTShortArrayRead _head.clearIndex(); } + @override + Future truncate(int newLength) async { + _head.truncate(newLength); + } + @override Future tryWriteItem(int pos, Uint8List newValue, {Output? output}) async { @@ -151,7 +164,7 @@ class _DHTShortArrayWrite extends _DHTShortArrayRead final lookup = await _head.lookupPosition(pos, true); final outSeqNumRead = Output(); - final oldValue = lookup.seq == 0xFFFFFFFF + final oldValue = lookup.seq == null ? null : await lookup.record .get(subkey: lookup.recordSubkey, outSeqNum: outSeqNumRead); diff --git a/packages/veilid_support/lib/dht_support/src/interfaces/dht_add.dart b/packages/veilid_support/lib/dht_support/src/interfaces/dht_add.dart index dc79350..28d2fbb 100644 --- a/packages/veilid_support/lib/dht_support/src/interfaces/dht_add.dart +++ b/packages/veilid_support/lib/dht_support/src/interfaces/dht_add.dart @@ -15,27 +15,37 @@ abstract class DHTAdd { Future add(Uint8List value); /// Try to add a list of items to the DHT container. - /// Return if the elements were successfully added. - /// Throws DHTExceptionTryAgain if the state changed before the elements could + /// Return the number of elements successfully added. + /// Throws DHTExceptionTryAgain if the state changed before any elements could /// be added or a newer value was found on the network. + /// Throws DHTConcurrencyLimit if the number values in the list was too large + /// at this time /// Throws a StateError if the container exceeds its maximum size. Future addAll(List values); } extension DHTAddExt on DHTAdd { /// Convenience function: - /// Like tryAddItem but also encodes the input value as JSON and parses the - /// returned element as JSON + /// Like add but also encodes the input value as JSON Future addJson( T newValue, ) => add(jsonEncodeBytes(newValue)); /// Convenience function: - /// Like tryAddItem but also encodes the input value as a protobuf object - /// and parses the returned element as a protobuf object + /// Like add but also encodes the input value as a protobuf object Future addProtobuf( T newValue, ) => add(newValue.writeToBuffer()); + + /// Convenience function: + /// Like addAll but also encodes the input values as JSON + Future addAllJson(List values) => + addAll(values.map(jsonEncodeBytes).toList()); + + /// Convenience function: + /// Like addAll but also encodes the input values as protobuf objects + Future addAllProtobuf(List values) => + addAll(values.map((x) => x.writeToBuffer()).toList()); } diff --git a/packages/veilid_support/lib/dht_support/src/interfaces/dht_closeable.dart b/packages/veilid_support/lib/dht_support/src/interfaces/dht_closeable.dart index c913340..bda4afb 100644 --- a/packages/veilid_support/lib/dht_support/src/interfaces/dht_closeable.dart +++ b/packages/veilid_support/lib/dht_support/src/interfaces/dht_closeable.dart @@ -4,7 +4,7 @@ import 'package:meta/meta.dart'; abstract class DHTCloseable { // Public interface - Future ref(); + void ref(); Future close(); // Internal implementation @@ -15,7 +15,9 @@ abstract class DHTCloseable { } abstract class DHTDeleteable extends DHTCloseable { - Future delete(); + /// Returns true if the deletion was processed immediately + /// Returns false if the deletion was marked for later + Future delete(); } extension DHTCloseableExt on DHTCloseable { @@ -54,7 +56,7 @@ extension DHTDeletableExt on DHTDeleteable { /// Scopes a closure that conditionally deletes the DHTCloseable on exit Future maybeDeleteScope( - bool delete, Future Function(D) scopeFunction) async { + bool delete, Future Function(D) scopeFunction) { if (delete) { return deleteScope(scopeFunction); } diff --git a/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_read.dart b/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_read.dart index 0547332..d361757 100644 --- a/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_read.dart +++ b/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_read.dart @@ -14,19 +14,22 @@ abstract class DHTRandomRead { /// is specified, the network will always be checked for newer values /// rather than returning the existing locally stored copy of the elements. /// Throws an IndexError if the 'pos' is not within the length - /// of the container. + /// of the container. May return null if the item is not available at this + /// time. Future get(int pos, {bool forceRefresh = false}); /// Return a list of a range of items in the DHTArray. If 'forceRefresh' /// is specified, the network will always be checked for newer values /// rather than returning the existing locally stored copy of the elements. /// Throws an IndexError if either 'start' or '(start+length)' is not within - /// the length of the container. + /// the length of the container. May return fewer items than the length + /// expected if the requested items are not available, but will always + /// return a contiguous range starting at 'start'. Future?> getRange(int start, {int? length, bool forceRefresh = false}); /// Get a list of the positions that were written offline and not flushed yet - Future?> getOfflinePositions(); + Future> getOfflinePositions(); } extension DHTRandomReadExt on DHTRandomRead { diff --git a/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_swap.dart b/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_swap.dart new file mode 100644 index 0000000..8aa4dc1 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_swap.dart @@ -0,0 +1,9 @@ +//////////////////////////////////////////////////////////////////////////// +// Writer interface +// ignore: one_member_abstracts +abstract class DHTRandomSwap { + /// Swap items at position 'aPos' and 'bPos' in the DHTArray. + /// Throws an IndexError if either of the positions swapped exceeds the length + /// of the container + Future swap(int aPos, int bPos); +} diff --git a/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_write.dart b/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_write.dart index 5b3f032..0d8f3ac 100644 --- a/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_write.dart +++ b/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_write.dart @@ -23,11 +23,6 @@ abstract class DHTRandomWrite { /// of the container. Future tryWriteItem(int pos, Uint8List newValue, {Output? output}); - - /// Swap items at position 'aPos' and 'bPos' in the DHTArray. - /// Throws an IndexError if either of the positions swapped exceeds the length - /// of the container - Future swap(int aPos, int bPos); } extension DHTRandomWriteExt on DHTRandomWrite { diff --git a/packages/veilid_support/lib/dht_support/src/interfaces/exceptions.dart b/packages/veilid_support/lib/dht_support/src/interfaces/exceptions.dart index 529c308..01354f0 100644 --- a/packages/veilid_support/lib/dht_support/src/interfaces/exceptions.dart +++ b/packages/veilid_support/lib/dht_support/src/interfaces/exceptions.dart @@ -1,10 +1,44 @@ -class DHTExceptionTryAgain implements Exception { - DHTExceptionTryAgain( - [this.cause = 'operation failed due to newer dht value']); - String cause; +class DHTExceptionOutdated implements Exception { + const DHTExceptionOutdated( + {this.cause = 'operation failed due to newer dht value'}); + final String cause; + + @override + String toString() => 'DHTExceptionOutdated: $cause'; +} + +class DHTConcurrencyLimit implements Exception { + const DHTConcurrencyLimit( + {required this.limit, + this.cause = 'failed due to maximum parallel operation limit'}); + final String cause; + final int limit; + + @override + String toString() => 'DHTConcurrencyLimit: $cause (limit=$limit)'; } class DHTExceptionInvalidData implements Exception { - DHTExceptionInvalidData([this.cause = 'dht data structure is corrupt']); - String cause; + const DHTExceptionInvalidData({this.cause = 'data was invalid'}); + final String cause; + + @override + String toString() => 'DHTExceptionInvalidData: $cause'; +} + +class DHTExceptionCancelled implements Exception { + const DHTExceptionCancelled({this.cause = 'operation was cancelled'}); + final String cause; + + @override + String toString() => 'DHTExceptionCancelled: $cause'; +} + +class DHTExceptionNotAvailable implements Exception { + const DHTExceptionNotAvailable( + {this.cause = 'request could not be completed at this time'}); + final String cause; + + @override + String toString() => 'DHTExceptionNotAvailable: $cause'; } diff --git a/packages/veilid_support/lib/dht_support/src/interfaces/interfaces.dart b/packages/veilid_support/lib/dht_support/src/interfaces/interfaces.dart index 57d0979..8a019dd 100644 --- a/packages/veilid_support/lib/dht_support/src/interfaces/interfaces.dart +++ b/packages/veilid_support/lib/dht_support/src/interfaces/interfaces.dart @@ -3,6 +3,8 @@ export 'dht_clear.dart'; export 'dht_closeable.dart'; export 'dht_insert_remove.dart'; export 'dht_random_read.dart'; +export 'dht_random_swap.dart'; export 'dht_random_write.dart'; export 'dht_truncate.dart'; export 'exceptions.dart'; +export 'refreshable_cubit.dart'; diff --git a/packages/veilid_support/lib/dht_support/src/interfaces/refreshable_cubit.dart b/packages/veilid_support/lib/dht_support/src/interfaces/refreshable_cubit.dart new file mode 100644 index 0000000..d987b38 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/interfaces/refreshable_cubit.dart @@ -0,0 +1,16 @@ +abstract mixin class RefreshableCubit { + Future refresh({bool forceRefresh = false}); + + void setWantsRefresh() { + _wantsRefresh = true; + } + + void setRefreshed() { + _wantsRefresh = false; + } + + bool get wantsRefresh => _wantsRefresh; + + //////////////////////////////////////////////////////////////////////////// + var _wantsRefresh = false; +} diff --git a/packages/veilid_support/lib/identity_support/account_record_info.dart b/packages/veilid_support/lib/identity_support/account_record_info.dart index 60accf9..c74baac 100644 --- a/packages/veilid_support/lib/identity_support/account_record_info.dart +++ b/packages/veilid_support/lib/identity_support/account_record_info.dart @@ -8,7 +8,7 @@ part 'account_record_info.g.dart'; /// AccountRecordInfo is the key and owner info for the account dht record that /// is stored in the identity instance record @freezed -class AccountRecordInfo with _$AccountRecordInfo { +sealed class AccountRecordInfo with _$AccountRecordInfo { const factory AccountRecordInfo({ // Top level account keys and secrets required OwnedDHTRecordPointer accountRecord, diff --git a/packages/veilid_support/lib/identity_support/account_record_info.freezed.dart b/packages/veilid_support/lib/identity_support/account_record_info.freezed.dart index 0d5b327..b1796f6 100644 --- a/packages/veilid_support/lib/identity_support/account_record_info.freezed.dart +++ b/packages/veilid_support/lib/identity_support/account_record_info.freezed.dart @@ -1,3 +1,4 @@ +// dart format width=80 // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint @@ -9,31 +10,49 @@ part of 'account_record_info.dart'; // FreezedGenerator // ************************************************************************** +// dart format off T _$identity(T value) => value; -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -AccountRecordInfo _$AccountRecordInfoFromJson(Map json) { - return _AccountRecordInfo.fromJson(json); -} - /// @nodoc mixin _$AccountRecordInfo { // Top level account keys and secrets - OwnedDHTRecordPointer get accountRecord => throw _privateConstructorUsedError; + OwnedDHTRecordPointer get accountRecord; - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) + /// Create a copy of AccountRecordInfo + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') $AccountRecordInfoCopyWith get copyWith => - throw _privateConstructorUsedError; + _$AccountRecordInfoCopyWithImpl( + this as AccountRecordInfo, _$identity); + + /// Serializes this AccountRecordInfo to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is AccountRecordInfo && + (identical(other.accountRecord, accountRecord) || + other.accountRecord == accountRecord)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, accountRecord); + + @override + String toString() { + return 'AccountRecordInfo(accountRecord: $accountRecord)'; + } } /// @nodoc -abstract class $AccountRecordInfoCopyWith<$Res> { +abstract mixin class $AccountRecordInfoCopyWith<$Res> { factory $AccountRecordInfoCopyWith( - AccountRecordInfo value, $Res Function(AccountRecordInfo) then) = - _$AccountRecordInfoCopyWithImpl<$Res, AccountRecordInfo>; + AccountRecordInfo value, $Res Function(AccountRecordInfo) _then) = + _$AccountRecordInfoCopyWithImpl; @useResult $Res call({OwnedDHTRecordPointer accountRecord}); @@ -41,130 +60,130 @@ abstract class $AccountRecordInfoCopyWith<$Res> { } /// @nodoc -class _$AccountRecordInfoCopyWithImpl<$Res, $Val extends AccountRecordInfo> +class _$AccountRecordInfoCopyWithImpl<$Res> implements $AccountRecordInfoCopyWith<$Res> { - _$AccountRecordInfoCopyWithImpl(this._value, this._then); + _$AccountRecordInfoCopyWithImpl(this._self, this._then); - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; + final AccountRecordInfo _self; + final $Res Function(AccountRecordInfo) _then; + /// Create a copy of AccountRecordInfo + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ Object? accountRecord = null, }) { - return _then(_value.copyWith( + return _then(_self.copyWith( accountRecord: null == accountRecord - ? _value.accountRecord + ? _self.accountRecord : accountRecord // ignore: cast_nullable_to_non_nullable as OwnedDHTRecordPointer, - ) as $Val); + )); } + /// Create a copy of AccountRecordInfo + /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') $OwnedDHTRecordPointerCopyWith<$Res> get accountRecord { - return $OwnedDHTRecordPointerCopyWith<$Res>(_value.accountRecord, (value) { - return _then(_value.copyWith(accountRecord: value) as $Val); + return $OwnedDHTRecordPointerCopyWith<$Res>(_self.accountRecord, (value) { + return _then(_self.copyWith(accountRecord: value)); }); } } -/// @nodoc -abstract class _$$AccountRecordInfoImplCopyWith<$Res> - implements $AccountRecordInfoCopyWith<$Res> { - factory _$$AccountRecordInfoImplCopyWith(_$AccountRecordInfoImpl value, - $Res Function(_$AccountRecordInfoImpl) then) = - __$$AccountRecordInfoImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({OwnedDHTRecordPointer accountRecord}); - - @override - $OwnedDHTRecordPointerCopyWith<$Res> get accountRecord; -} - -/// @nodoc -class __$$AccountRecordInfoImplCopyWithImpl<$Res> - extends _$AccountRecordInfoCopyWithImpl<$Res, _$AccountRecordInfoImpl> - implements _$$AccountRecordInfoImplCopyWith<$Res> { - __$$AccountRecordInfoImplCopyWithImpl(_$AccountRecordInfoImpl _value, - $Res Function(_$AccountRecordInfoImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? accountRecord = null, - }) { - return _then(_$AccountRecordInfoImpl( - accountRecord: null == accountRecord - ? _value.accountRecord - : accountRecord // ignore: cast_nullable_to_non_nullable - as OwnedDHTRecordPointer, - )); - } -} - /// @nodoc @JsonSerializable() -class _$AccountRecordInfoImpl implements _AccountRecordInfo { - const _$AccountRecordInfoImpl({required this.accountRecord}); - - factory _$AccountRecordInfoImpl.fromJson(Map json) => - _$$AccountRecordInfoImplFromJson(json); +class _AccountRecordInfo implements AccountRecordInfo { + const _AccountRecordInfo({required this.accountRecord}); + factory _AccountRecordInfo.fromJson(Map json) => + _$AccountRecordInfoFromJson(json); // Top level account keys and secrets @override final OwnedDHTRecordPointer accountRecord; + /// Create a copy of AccountRecordInfo + /// with the given fields replaced by the non-null parameter values. @override - String toString() { - return 'AccountRecordInfo(accountRecord: $accountRecord)'; + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$AccountRecordInfoCopyWith<_AccountRecordInfo> get copyWith => + __$AccountRecordInfoCopyWithImpl<_AccountRecordInfo>(this, _$identity); + + @override + Map toJson() { + return _$AccountRecordInfoToJson( + this, + ); } @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$AccountRecordInfoImpl && + other is _AccountRecordInfo && (identical(other.accountRecord, accountRecord) || other.accountRecord == accountRecord)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType, accountRecord); - @JsonKey(ignore: true) @override - @pragma('vm:prefer-inline') - _$$AccountRecordInfoImplCopyWith<_$AccountRecordInfoImpl> get copyWith => - __$$AccountRecordInfoImplCopyWithImpl<_$AccountRecordInfoImpl>( - this, _$identity); - - @override - Map toJson() { - return _$$AccountRecordInfoImplToJson( - this, - ); + String toString() { + return 'AccountRecordInfo(accountRecord: $accountRecord)'; } } -abstract class _AccountRecordInfo implements AccountRecordInfo { - const factory _AccountRecordInfo( - {required final OwnedDHTRecordPointer accountRecord}) = - _$AccountRecordInfoImpl; - - factory _AccountRecordInfo.fromJson(Map json) = - _$AccountRecordInfoImpl.fromJson; - - @override // Top level account keys and secrets - OwnedDHTRecordPointer get accountRecord; +/// @nodoc +abstract mixin class _$AccountRecordInfoCopyWith<$Res> + implements $AccountRecordInfoCopyWith<$Res> { + factory _$AccountRecordInfoCopyWith( + _AccountRecordInfo value, $Res Function(_AccountRecordInfo) _then) = + __$AccountRecordInfoCopyWithImpl; @override - @JsonKey(ignore: true) - _$$AccountRecordInfoImplCopyWith<_$AccountRecordInfoImpl> get copyWith => - throw _privateConstructorUsedError; + @useResult + $Res call({OwnedDHTRecordPointer accountRecord}); + + @override + $OwnedDHTRecordPointerCopyWith<$Res> get accountRecord; } + +/// @nodoc +class __$AccountRecordInfoCopyWithImpl<$Res> + implements _$AccountRecordInfoCopyWith<$Res> { + __$AccountRecordInfoCopyWithImpl(this._self, this._then); + + final _AccountRecordInfo _self; + final $Res Function(_AccountRecordInfo) _then; + + /// Create a copy of AccountRecordInfo + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? accountRecord = null, + }) { + return _then(_AccountRecordInfo( + accountRecord: null == accountRecord + ? _self.accountRecord + : accountRecord // ignore: cast_nullable_to_non_nullable + as OwnedDHTRecordPointer, + )); + } + + /// Create a copy of AccountRecordInfo + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $OwnedDHTRecordPointerCopyWith<$Res> get accountRecord { + return $OwnedDHTRecordPointerCopyWith<$Res>(_self.accountRecord, (value) { + return _then(_self.copyWith(accountRecord: value)); + }); + } +} + +// dart format on diff --git a/packages/veilid_support/lib/identity_support/account_record_info.g.dart b/packages/veilid_support/lib/identity_support/account_record_info.g.dart index ad9318c..429f9d0 100644 --- a/packages/veilid_support/lib/identity_support/account_record_info.g.dart +++ b/packages/veilid_support/lib/identity_support/account_record_info.g.dart @@ -6,14 +6,12 @@ part of 'account_record_info.dart'; // JsonSerializableGenerator // ************************************************************************** -_$AccountRecordInfoImpl _$$AccountRecordInfoImplFromJson( - Map json) => - _$AccountRecordInfoImpl( +_AccountRecordInfo _$AccountRecordInfoFromJson(Map json) => + _AccountRecordInfo( accountRecord: OwnedDHTRecordPointer.fromJson(json['account_record']), ); -Map _$$AccountRecordInfoImplToJson( - _$AccountRecordInfoImpl instance) => +Map _$AccountRecordInfoToJson(_AccountRecordInfo instance) => { 'account_record': instance.accountRecord.toJson(), }; diff --git a/packages/veilid_support/lib/identity_support/exceptions.dart b/packages/veilid_support/lib/identity_support/exceptions.dart index f0774b4..280f468 100644 --- a/packages/veilid_support/lib/identity_support/exceptions.dart +++ b/packages/veilid_support/lib/identity_support/exceptions.dart @@ -3,7 +3,8 @@ enum IdentityException implements Exception { readError('identity could not be read'), noAccount('no account record info'), limitExceeded('too many items for the limit'), - invalid('identity is corrupted or secret is invalid'); + invalid('identity is corrupted or secret is invalid'), + cancelled('account operation cancelled'); const IdentityException(this.message); final String message; diff --git a/packages/veilid_support/lib/identity_support/identity.dart b/packages/veilid_support/lib/identity_support/identity.dart index ea9c38c..c1c7113 100644 --- a/packages/veilid_support/lib/identity_support/identity.dart +++ b/packages/veilid_support/lib/identity_support/identity.dart @@ -14,7 +14,7 @@ part 'identity.g.dart'; /// DHT Secret: IdentityInstance Secret Key (stored encrypted with unlock code /// in local table store) @freezed -class Identity with _$Identity { +sealed class Identity with _$Identity { const factory Identity({ // Top level account keys and secrets required IMap> accountRecords, diff --git a/packages/veilid_support/lib/identity_support/identity.freezed.dart b/packages/veilid_support/lib/identity_support/identity.freezed.dart index 5977a26..d9f08f9 100644 --- a/packages/veilid_support/lib/identity_support/identity.freezed.dart +++ b/packages/veilid_support/lib/identity_support/identity.freezed.dart @@ -1,3 +1,4 @@ +// dart format width=80 // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint @@ -9,86 +10,68 @@ part of 'identity.dart'; // FreezedGenerator // ************************************************************************** +// dart format off T _$identity(T value) => value; -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -Identity _$IdentityFromJson(Map json) { - return _Identity.fromJson(json); -} - /// @nodoc mixin _$Identity { // Top level account keys and secrets - IMap> get accountRecords => - throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $IdentityCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $IdentityCopyWith<$Res> { - factory $IdentityCopyWith(Identity value, $Res Function(Identity) then) = - _$IdentityCopyWithImpl<$Res, Identity>; - @useResult - $Res call({IMap> accountRecords}); -} - -/// @nodoc -class _$IdentityCopyWithImpl<$Res, $Val extends Identity> - implements $IdentityCopyWith<$Res> { - _$IdentityCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; + IMap> get accountRecords; + /// Create a copy of Identity + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') + $IdentityCopyWith get copyWith => + _$IdentityCopyWithImpl(this as Identity, _$identity); + + /// Serializes this Identity to a JSON map. + Map toJson(); + @override - $Res call({ - Object? accountRecords = null, - }) { - return _then(_value.copyWith( - accountRecords: null == accountRecords - ? _value.accountRecords - : accountRecords // ignore: cast_nullable_to_non_nullable - as IMap>, - ) as $Val); + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is Identity && + (identical(other.accountRecords, accountRecords) || + other.accountRecords == accountRecords)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, accountRecords); + + @override + String toString() { + return 'Identity(accountRecords: $accountRecords)'; } } /// @nodoc -abstract class _$$IdentityImplCopyWith<$Res> - implements $IdentityCopyWith<$Res> { - factory _$$IdentityImplCopyWith( - _$IdentityImpl value, $Res Function(_$IdentityImpl) then) = - __$$IdentityImplCopyWithImpl<$Res>; - @override +abstract mixin class $IdentityCopyWith<$Res> { + factory $IdentityCopyWith(Identity value, $Res Function(Identity) _then) = + _$IdentityCopyWithImpl; @useResult $Res call({IMap> accountRecords}); } /// @nodoc -class __$$IdentityImplCopyWithImpl<$Res> - extends _$IdentityCopyWithImpl<$Res, _$IdentityImpl> - implements _$$IdentityImplCopyWith<$Res> { - __$$IdentityImplCopyWithImpl( - _$IdentityImpl _value, $Res Function(_$IdentityImpl) _then) - : super(_value, _then); +class _$IdentityCopyWithImpl<$Res> implements $IdentityCopyWith<$Res> { + _$IdentityCopyWithImpl(this._self, this._then); + final Identity _self; + final $Res Function(Identity) _then; + + /// Create a copy of Identity + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ Object? accountRecords = null, }) { - return _then(_$IdentityImpl( + return _then(_self.copyWith( accountRecords: null == accountRecords - ? _value.accountRecords + ? _self.accountRecords : accountRecords // ignore: cast_nullable_to_non_nullable as IMap>, )); @@ -97,60 +80,80 @@ class __$$IdentityImplCopyWithImpl<$Res> /// @nodoc @JsonSerializable() -class _$IdentityImpl implements _Identity { - const _$IdentityImpl({required this.accountRecords}); - - factory _$IdentityImpl.fromJson(Map json) => - _$$IdentityImplFromJson(json); +class _Identity implements Identity { + const _Identity({required this.accountRecords}); + factory _Identity.fromJson(Map json) => + _$IdentityFromJson(json); // Top level account keys and secrets @override final IMap> accountRecords; + /// Create a copy of Identity + /// with the given fields replaced by the non-null parameter values. @override - String toString() { - return 'Identity(accountRecords: $accountRecords)'; + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$IdentityCopyWith<_Identity> get copyWith => + __$IdentityCopyWithImpl<_Identity>(this, _$identity); + + @override + Map toJson() { + return _$IdentityToJson( + this, + ); } @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$IdentityImpl && + other is _Identity && (identical(other.accountRecords, accountRecords) || other.accountRecords == accountRecords)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType, accountRecords); - @JsonKey(ignore: true) @override - @pragma('vm:prefer-inline') - _$$IdentityImplCopyWith<_$IdentityImpl> get copyWith => - __$$IdentityImplCopyWithImpl<_$IdentityImpl>(this, _$identity); - - @override - Map toJson() { - return _$$IdentityImplToJson( - this, - ); + String toString() { + return 'Identity(accountRecords: $accountRecords)'; } } -abstract class _Identity implements Identity { - const factory _Identity( - {required final IMap> - accountRecords}) = _$IdentityImpl; - - factory _Identity.fromJson(Map json) = - _$IdentityImpl.fromJson; - - @override // Top level account keys and secrets - IMap> get accountRecords; +/// @nodoc +abstract mixin class _$IdentityCopyWith<$Res> + implements $IdentityCopyWith<$Res> { + factory _$IdentityCopyWith(_Identity value, $Res Function(_Identity) _then) = + __$IdentityCopyWithImpl; @override - @JsonKey(ignore: true) - _$$IdentityImplCopyWith<_$IdentityImpl> get copyWith => - throw _privateConstructorUsedError; + @useResult + $Res call({IMap> accountRecords}); } + +/// @nodoc +class __$IdentityCopyWithImpl<$Res> implements _$IdentityCopyWith<$Res> { + __$IdentityCopyWithImpl(this._self, this._then); + + final _Identity _self; + final $Res Function(_Identity) _then; + + /// Create a copy of Identity + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? accountRecords = null, + }) { + return _then(_Identity( + accountRecords: null == accountRecords + ? _self.accountRecords + : accountRecords // ignore: cast_nullable_to_non_nullable + as IMap>, + )); + } +} + +// dart format on diff --git a/packages/veilid_support/lib/identity_support/identity.g.dart b/packages/veilid_support/lib/identity_support/identity.g.dart index afc9088..1ee10b8 100644 --- a/packages/veilid_support/lib/identity_support/identity.g.dart +++ b/packages/veilid_support/lib/identity_support/identity.g.dart @@ -6,8 +6,7 @@ part of 'identity.dart'; // JsonSerializableGenerator // ************************************************************************** -_$IdentityImpl _$$IdentityImplFromJson(Map json) => - _$IdentityImpl( +_Identity _$IdentityFromJson(Map json) => _Identity( accountRecords: IMap>.fromJson( json['account_records'] as Map, (value) => value as String, @@ -15,8 +14,7 @@ _$IdentityImpl _$$IdentityImplFromJson(Map json) => value, (value) => AccountRecordInfo.fromJson(value))), ); -Map _$$IdentityImplToJson(_$IdentityImpl instance) => - { +Map _$IdentityToJson(_Identity instance) => { 'account_records': instance.accountRecords.toJson( (value) => value, (value) => value.toJson( diff --git a/packages/veilid_support/lib/identity_support/identity_instance.dart b/packages/veilid_support/lib/identity_support/identity_instance.dart index b11e223..d2bc323 100644 --- a/packages/veilid_support/lib/identity_support/identity_instance.dart +++ b/packages/veilid_support/lib/identity_support/identity_instance.dart @@ -5,13 +5,12 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import '../src/veilid_log.dart'; import '../veilid_support.dart'; -import 'exceptions.dart'; part 'identity_instance.freezed.dart'; part 'identity_instance.g.dart'; @freezed -class IdentityInstance with _$IdentityInstance { +sealed class IdentityInstance with _$IdentityInstance { const factory IdentityInstance({ // Private DHT record storing identity account mapping required TypedKey recordKey, @@ -20,7 +19,9 @@ class IdentityInstance with _$IdentityInstance { required PublicKey publicKey, // Secret key of identity instance - // Encrypted with DH(publicKey, SuperIdentity.secret) with appended salt + // Encrypted with appended salt, key is DeriveSharedSecret( + // password = SuperIdentity.secret, + // salt = publicKey) // Used to recover accounts without generating a new instance @Uint8ListJsonConverter() required Uint8List encryptedSecretKey, @@ -69,12 +70,12 @@ class IdentityInstance with _$IdentityInstance { return cs; } - /// Read the account record info for a specific accountKey from the identity - /// instance record using the identity instance secret key to decrypt + /// Read the account record info for a specific applicationId from the + /// identity instance record using the identity instance secret key to decrypt Future> readAccount( {required TypedKey superRecordKey, required SecretKey secretKey, - required String accountKey}) async { + required String applicationId}) async { // Read the identity key to get the account keys final pool = DHTRecordPool.instance; @@ -92,7 +93,7 @@ class IdentityInstance with _$IdentityInstance { throw IdentityException.readError; } final accountRecords = IMapOfSets.from(identity.accountRecords); - final vcAccounts = accountRecords.get(accountKey); + final vcAccounts = accountRecords.get(applicationId); accountRecordInfo = vcAccounts.toList(); }); @@ -105,7 +106,7 @@ class IdentityInstance with _$IdentityInstance { Future addAccount({ required TypedKey superRecordKey, required SecretKey secretKey, - required String accountKey, + required String applicationId, required Future Function(TypedKey parent) createAccountCallback, int maxAccounts = 1, }) async { @@ -144,11 +145,12 @@ class IdentityInstance with _$IdentityInstance { } final oldAccountRecords = IMapOfSets.from(oldIdentity.accountRecords); - if (oldAccountRecords.get(accountKey).length >= maxAccounts) { + if (oldAccountRecords.get(applicationId).length >= maxAccounts) { throw IdentityException.limitExceeded; } - final accountRecords = - oldAccountRecords.add(accountKey, newAccountRecordInfo).asIMap(); + final accountRecords = oldAccountRecords + .add(applicationId, newAccountRecordInfo) + .asIMap(); return oldIdentity.copyWith(accountRecords: accountRecords); }); @@ -157,6 +159,61 @@ class IdentityInstance with _$IdentityInstance { }); } + /// Removes an Account associated with super identity from the identity + /// instance record. 'removeAccountCallback' returns the account to be + /// removed from the list passed to it. + Future removeAccount({ + required TypedKey superRecordKey, + required SecretKey secretKey, + required String applicationId, + required Future Function( + List accountRecordInfos) + removeAccountCallback, + }) async { + final pool = DHTRecordPool.instance; + + /////// Add account with profile to DHT + + // Open identity key for writing + veilidLoggy.debug('Opening identity record'); + return (await pool.openRecordWrite(recordKey, writer(secretKey), + debugName: 'IdentityInstance::addAccount::IdentityRecord', + parent: superRecordKey)) + .scope((identityRec) async { + try { + // Update identity key to remove account + veilidLoggy.debug('Updating identity to remove account'); + await identityRec.eventualUpdateJson(Identity.fromJson, + (oldIdentity) async { + if (oldIdentity == null) { + throw IdentityException.readError; + } + final oldAccountRecords = IMapOfSets.from(oldIdentity.accountRecords); + + // Get list of accounts associated with the application + final vcAccounts = oldAccountRecords.get(applicationId); + final accountRecordInfos = vcAccounts.toList(); + + // Call the callback to return what account to remove + final toRemove = await removeAccountCallback(accountRecordInfos); + if (toRemove == null) { + throw IdentityException.cancelled; + } + final newAccountRecords = + oldAccountRecords.remove(applicationId, toRemove).asIMap(); + + return oldIdentity.copyWith(accountRecords: newAccountRecords); + }); + } on IdentityException catch (e) { + if (e == IdentityException.cancelled) { + return false; + } + rethrow; + } + return true; + }); + } + //////////////////////////////////////////////////////////////////////////// // Internal implementation diff --git a/packages/veilid_support/lib/identity_support/identity_instance.freezed.dart b/packages/veilid_support/lib/identity_support/identity_instance.freezed.dart index 4d6c4ad..42522d4 100644 --- a/packages/veilid_support/lib/identity_support/identity_instance.freezed.dart +++ b/packages/veilid_support/lib/identity_support/identity_instance.freezed.dart @@ -1,3 +1,4 @@ +// dart format width=80 // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint @@ -9,104 +10,76 @@ part of 'identity_instance.dart'; // FreezedGenerator // ************************************************************************** +// dart format off T _$identity(T value) => value; -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -IdentityInstance _$IdentityInstanceFromJson(Map json) { - return _IdentityInstance.fromJson(json); -} - /// @nodoc mixin _$IdentityInstance { // Private DHT record storing identity account mapping - Typed get recordKey => - throw _privateConstructorUsedError; // Public key of identity instance - FixedEncodedString43 get publicKey => - throw _privateConstructorUsedError; // Secret key of identity instance -// Encrypted with DH(publicKey, SuperIdentity.secret) with appended salt + TypedKey get recordKey; // Public key of identity instance + PublicKey get publicKey; // Secret key of identity instance +// Encrypted with appended salt, key is DeriveSharedSecret( +// password = SuperIdentity.secret, +// salt = publicKey) // Used to recover accounts without generating a new instance @Uint8ListJsonConverter() - Uint8List get encryptedSecretKey => - throw _privateConstructorUsedError; // Signature of SuperInstance recordKey and SuperInstance publicKey + Uint8List + get encryptedSecretKey; // Signature of SuperInstance recordKey and SuperInstance publicKey // by publicKey - FixedEncodedString86 get superSignature => - throw _privateConstructorUsedError; // Signature of recordKey, publicKey, encryptedSecretKey, and superSignature + Signature + get superSignature; // Signature of recordKey, publicKey, encryptedSecretKey, and superSignature // by SuperIdentity publicKey - FixedEncodedString86 get signature => throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $IdentityInstanceCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $IdentityInstanceCopyWith<$Res> { - factory $IdentityInstanceCopyWith( - IdentityInstance value, $Res Function(IdentityInstance) then) = - _$IdentityInstanceCopyWithImpl<$Res, IdentityInstance>; - @useResult - $Res call( - {Typed recordKey, - FixedEncodedString43 publicKey, - @Uint8ListJsonConverter() Uint8List encryptedSecretKey, - FixedEncodedString86 superSignature, - FixedEncodedString86 signature}); -} - -/// @nodoc -class _$IdentityInstanceCopyWithImpl<$Res, $Val extends IdentityInstance> - implements $IdentityInstanceCopyWith<$Res> { - _$IdentityInstanceCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; + Signature get signature; + /// Create a copy of IdentityInstance + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') + $IdentityInstanceCopyWith get copyWith => + _$IdentityInstanceCopyWithImpl( + this as IdentityInstance, _$identity); + + /// Serializes this IdentityInstance to a JSON map. + Map toJson(); + @override - $Res call({ - Object? recordKey = null, - Object? publicKey = null, - Object? encryptedSecretKey = null, - Object? superSignature = null, - Object? signature = null, - }) { - return _then(_value.copyWith( - recordKey: null == recordKey - ? _value.recordKey - : recordKey // ignore: cast_nullable_to_non_nullable - as Typed, - publicKey: null == publicKey - ? _value.publicKey - : publicKey // ignore: cast_nullable_to_non_nullable - as FixedEncodedString43, - encryptedSecretKey: null == encryptedSecretKey - ? _value.encryptedSecretKey - : encryptedSecretKey // ignore: cast_nullable_to_non_nullable - as Uint8List, - superSignature: null == superSignature - ? _value.superSignature - : superSignature // ignore: cast_nullable_to_non_nullable - as FixedEncodedString86, - signature: null == signature - ? _value.signature - : signature // ignore: cast_nullable_to_non_nullable - as FixedEncodedString86, - ) as $Val); + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is IdentityInstance && + (identical(other.recordKey, recordKey) || + other.recordKey == recordKey) && + (identical(other.publicKey, publicKey) || + other.publicKey == publicKey) && + const DeepCollectionEquality() + .equals(other.encryptedSecretKey, encryptedSecretKey) && + (identical(other.superSignature, superSignature) || + other.superSignature == superSignature) && + (identical(other.signature, signature) || + other.signature == signature)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + recordKey, + publicKey, + const DeepCollectionEquality().hash(encryptedSecretKey), + superSignature, + signature); + + @override + String toString() { + return 'IdentityInstance(recordKey: $recordKey, publicKey: $publicKey, encryptedSecretKey: $encryptedSecretKey, superSignature: $superSignature, signature: $signature)'; } } /// @nodoc -abstract class _$$IdentityInstanceImplCopyWith<$Res> - implements $IdentityInstanceCopyWith<$Res> { - factory _$$IdentityInstanceImplCopyWith(_$IdentityInstanceImpl value, - $Res Function(_$IdentityInstanceImpl) then) = - __$$IdentityInstanceImplCopyWithImpl<$Res>; - @override +abstract mixin class $IdentityInstanceCopyWith<$Res> { + factory $IdentityInstanceCopyWith( + IdentityInstance value, $Res Function(IdentityInstance) _then) = + _$IdentityInstanceCopyWithImpl; @useResult $Res call( {Typed recordKey, @@ -117,13 +90,15 @@ abstract class _$$IdentityInstanceImplCopyWith<$Res> } /// @nodoc -class __$$IdentityInstanceImplCopyWithImpl<$Res> - extends _$IdentityInstanceCopyWithImpl<$Res, _$IdentityInstanceImpl> - implements _$$IdentityInstanceImplCopyWith<$Res> { - __$$IdentityInstanceImplCopyWithImpl(_$IdentityInstanceImpl _value, - $Res Function(_$IdentityInstanceImpl) _then) - : super(_value, _then); +class _$IdentityInstanceCopyWithImpl<$Res> + implements $IdentityInstanceCopyWith<$Res> { + _$IdentityInstanceCopyWithImpl(this._self, this._then); + final IdentityInstance _self; + final $Res Function(IdentityInstance) _then; + + /// Create a copy of IdentityInstance + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -133,25 +108,25 @@ class __$$IdentityInstanceImplCopyWithImpl<$Res> Object? superSignature = null, Object? signature = null, }) { - return _then(_$IdentityInstanceImpl( + return _then(_self.copyWith( recordKey: null == recordKey - ? _value.recordKey + ? _self.recordKey! : recordKey // ignore: cast_nullable_to_non_nullable as Typed, publicKey: null == publicKey - ? _value.publicKey + ? _self.publicKey! : publicKey // ignore: cast_nullable_to_non_nullable as FixedEncodedString43, encryptedSecretKey: null == encryptedSecretKey - ? _value.encryptedSecretKey + ? _self.encryptedSecretKey : encryptedSecretKey // ignore: cast_nullable_to_non_nullable as Uint8List, superSignature: null == superSignature - ? _value.superSignature + ? _self.superSignature! : superSignature // ignore: cast_nullable_to_non_nullable as FixedEncodedString86, signature: null == signature - ? _value.signature + ? _self.signature! : signature // ignore: cast_nullable_to_non_nullable as FixedEncodedString86, )); @@ -160,17 +135,16 @@ class __$$IdentityInstanceImplCopyWithImpl<$Res> /// @nodoc @JsonSerializable() -class _$IdentityInstanceImpl extends _IdentityInstance { - const _$IdentityInstanceImpl( +class _IdentityInstance extends IdentityInstance { + const _IdentityInstance( {required this.recordKey, required this.publicKey, @Uint8ListJsonConverter() required this.encryptedSecretKey, required this.superSignature, required this.signature}) : super._(); - - factory _$IdentityInstanceImpl.fromJson(Map json) => - _$$IdentityInstanceImplFromJson(json); + factory _IdentityInstance.fromJson(Map json) => + _$IdentityInstanceFromJson(json); // Private DHT record storing identity account mapping @override @@ -179,7 +153,9 @@ class _$IdentityInstanceImpl extends _IdentityInstance { @override final FixedEncodedString43 publicKey; // Secret key of identity instance -// Encrypted with DH(publicKey, SuperIdentity.secret) with appended salt +// Encrypted with appended salt, key is DeriveSharedSecret( +// password = SuperIdentity.secret, +// salt = publicKey) // Used to recover accounts without generating a new instance @override @Uint8ListJsonConverter() @@ -193,16 +169,26 @@ class _$IdentityInstanceImpl extends _IdentityInstance { @override final FixedEncodedString86 signature; + /// Create a copy of IdentityInstance + /// with the given fields replaced by the non-null parameter values. @override - String toString() { - return 'IdentityInstance(recordKey: $recordKey, publicKey: $publicKey, encryptedSecretKey: $encryptedSecretKey, superSignature: $superSignature, signature: $signature)'; + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$IdentityInstanceCopyWith<_IdentityInstance> get copyWith => + __$IdentityInstanceCopyWithImpl<_IdentityInstance>(this, _$identity); + + @override + Map toJson() { + return _$IdentityInstanceToJson( + this, + ); } @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$IdentityInstanceImpl && + other is _IdentityInstance && (identical(other.recordKey, recordKey) || other.recordKey == recordKey) && (identical(other.publicKey, publicKey) || @@ -215,7 +201,7 @@ class _$IdentityInstanceImpl extends _IdentityInstance { other.signature == signature)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash( runtimeType, @@ -225,50 +211,70 @@ class _$IdentityInstanceImpl extends _IdentityInstance { superSignature, signature); - @JsonKey(ignore: true) @override - @pragma('vm:prefer-inline') - _$$IdentityInstanceImplCopyWith<_$IdentityInstanceImpl> get copyWith => - __$$IdentityInstanceImplCopyWithImpl<_$IdentityInstanceImpl>( - this, _$identity); - - @override - Map toJson() { - return _$$IdentityInstanceImplToJson( - this, - ); + String toString() { + return 'IdentityInstance(recordKey: $recordKey, publicKey: $publicKey, encryptedSecretKey: $encryptedSecretKey, superSignature: $superSignature, signature: $signature)'; } } -abstract class _IdentityInstance extends IdentityInstance { - const factory _IdentityInstance( - {required final Typed recordKey, - required final FixedEncodedString43 publicKey, - @Uint8ListJsonConverter() required final Uint8List encryptedSecretKey, - required final FixedEncodedString86 superSignature, - required final FixedEncodedString86 signature}) = _$IdentityInstanceImpl; - const _IdentityInstance._() : super._(); - - factory _IdentityInstance.fromJson(Map json) = - _$IdentityInstanceImpl.fromJson; - - @override // Private DHT record storing identity account mapping - Typed get recordKey; - @override // Public key of identity instance - FixedEncodedString43 get publicKey; - @override // Secret key of identity instance -// Encrypted with DH(publicKey, SuperIdentity.secret) with appended salt -// Used to recover accounts without generating a new instance - @Uint8ListJsonConverter() - Uint8List get encryptedSecretKey; - @override // Signature of SuperInstance recordKey and SuperInstance publicKey -// by publicKey - FixedEncodedString86 get superSignature; - @override // Signature of recordKey, publicKey, encryptedSecretKey, and superSignature -// by SuperIdentity publicKey - FixedEncodedString86 get signature; +/// @nodoc +abstract mixin class _$IdentityInstanceCopyWith<$Res> + implements $IdentityInstanceCopyWith<$Res> { + factory _$IdentityInstanceCopyWith( + _IdentityInstance value, $Res Function(_IdentityInstance) _then) = + __$IdentityInstanceCopyWithImpl; @override - @JsonKey(ignore: true) - _$$IdentityInstanceImplCopyWith<_$IdentityInstanceImpl> get copyWith => - throw _privateConstructorUsedError; + @useResult + $Res call( + {Typed recordKey, + FixedEncodedString43 publicKey, + @Uint8ListJsonConverter() Uint8List encryptedSecretKey, + FixedEncodedString86 superSignature, + FixedEncodedString86 signature}); } + +/// @nodoc +class __$IdentityInstanceCopyWithImpl<$Res> + implements _$IdentityInstanceCopyWith<$Res> { + __$IdentityInstanceCopyWithImpl(this._self, this._then); + + final _IdentityInstance _self; + final $Res Function(_IdentityInstance) _then; + + /// Create a copy of IdentityInstance + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? recordKey = null, + Object? publicKey = null, + Object? encryptedSecretKey = null, + Object? superSignature = null, + Object? signature = null, + }) { + return _then(_IdentityInstance( + recordKey: null == recordKey + ? _self.recordKey + : recordKey // ignore: cast_nullable_to_non_nullable + as Typed, + publicKey: null == publicKey + ? _self.publicKey + : publicKey // ignore: cast_nullable_to_non_nullable + as FixedEncodedString43, + encryptedSecretKey: null == encryptedSecretKey + ? _self.encryptedSecretKey + : encryptedSecretKey // ignore: cast_nullable_to_non_nullable + as Uint8List, + superSignature: null == superSignature + ? _self.superSignature + : superSignature // ignore: cast_nullable_to_non_nullable + as FixedEncodedString86, + signature: null == signature + ? _self.signature + : signature // ignore: cast_nullable_to_non_nullable + as FixedEncodedString86, + )); + } +} + +// dart format on diff --git a/packages/veilid_support/lib/identity_support/identity_instance.g.dart b/packages/veilid_support/lib/identity_support/identity_instance.g.dart index cb228e6..eddbcf6 100644 --- a/packages/veilid_support/lib/identity_support/identity_instance.g.dart +++ b/packages/veilid_support/lib/identity_support/identity_instance.g.dart @@ -6,9 +6,8 @@ part of 'identity_instance.dart'; // JsonSerializableGenerator // ************************************************************************** -_$IdentityInstanceImpl _$$IdentityInstanceImplFromJson( - Map json) => - _$IdentityInstanceImpl( +_IdentityInstance _$IdentityInstanceFromJson(Map json) => + _IdentityInstance( recordKey: Typed.fromJson(json['record_key']), publicKey: FixedEncodedString43.fromJson(json['public_key']), encryptedSecretKey: @@ -17,8 +16,7 @@ _$IdentityInstanceImpl _$$IdentityInstanceImplFromJson( signature: FixedEncodedString86.fromJson(json['signature']), ); -Map _$$IdentityInstanceImplToJson( - _$IdentityInstanceImpl instance) => +Map _$IdentityInstanceToJson(_IdentityInstance instance) => { 'record_key': instance.recordKey.toJson(), 'public_key': instance.publicKey.toJson(), diff --git a/packages/veilid_support/lib/identity_support/identity_support.dart b/packages/veilid_support/lib/identity_support/identity_support.dart index 463be9a..68723bf 100644 --- a/packages/veilid_support/lib/identity_support/identity_support.dart +++ b/packages/veilid_support/lib/identity_support/identity_support.dart @@ -3,4 +3,5 @@ export 'exceptions.dart'; export 'identity.dart'; export 'identity_instance.dart'; export 'super_identity.dart'; +export 'super_identity_cubit.dart'; export 'writable_super_identity.dart'; diff --git a/packages/veilid_support/lib/identity_support/super_identity.dart b/packages/veilid_support/lib/identity_support/super_identity.dart index e4ec8fc..008967d 100644 --- a/packages/veilid_support/lib/identity_support/super_identity.dart +++ b/packages/veilid_support/lib/identity_support/super_identity.dart @@ -22,7 +22,8 @@ part 'super_identity.g.dart'; /// DHT Owner Secret: SuperIdentity Secret Key (kept offline) /// Encryption: None @freezed -class SuperIdentity with _$SuperIdentity { +sealed class SuperIdentity with _$SuperIdentity { + @JsonSerializable() const factory SuperIdentity({ /// Public DHT record storing this structure for account recovery /// changing this can migrate/forward the SuperIdentity to a new DHT record @@ -63,49 +64,54 @@ class SuperIdentity with _$SuperIdentity { const SuperIdentity._(); - /// Opens an existing super identity and validates it - static Future open({required TypedKey superRecordKey}) async { + /// Ensure a SuperIdentity is valid + Future validate({required TypedKey superRecordKey}) async { + // Validate current IdentityInstance + if (!await currentInstance.validateIdentityInstance( + superRecordKey: superRecordKey, superPublicKey: publicKey)) { + // Invalid current IdentityInstance signature(s) + throw IdentityException.invalid; + } + + // Validate deprecated IdentityInstances + for (final deprecatedInstance in deprecatedInstances) { + if (!await deprecatedInstance.validateIdentityInstance( + superRecordKey: superRecordKey, superPublicKey: publicKey)) { + // Invalid deprecated IdentityInstance signature(s) + throw IdentityException.invalid; + } + } + + // Validate SuperIdentity + final deprecatedInstancesSignatures = + deprecatedInstances.map((x) => x.signature).toList(); + if (!await _validateSuperIdentitySignature( + recordKey: recordKey, + currentInstanceSignature: currentInstance.signature, + deprecatedInstancesSignatures: deprecatedInstancesSignatures, + deprecatedSuperRecordKeys: deprecatedSuperRecordKeys, + publicKey: publicKey, + signature: signature)) { + // Invalid SuperIdentity signature + throw IdentityException.invalid; + } + } + + /// Opens an existing super identity, validates it, and returns it + static Future open({required TypedKey superRecordKey}) async { final pool = DHTRecordPool.instance; // SuperIdentity DHT record is public/unencrypted return (await pool.openRecordRead(superRecordKey, debugName: 'SuperIdentity::openSuperIdentity::SuperIdentityRecord')) .deleteScope((superRec) async { - final superIdentity = (await superRec.getJson(SuperIdentity.fromJson, - refreshMode: DHTRecordRefreshMode.network))!; - - // Validate current IdentityInstance - if (!await superIdentity.currentInstance.validateIdentityInstance( - superRecordKey: superRecordKey, - superPublicKey: superIdentity.publicKey)) { - // Invalid current IdentityInstance signature(s) - throw IdentityException.invalid; - } - - // Validate deprecated IdentityInstances - for (final deprecatedInstance in superIdentity.deprecatedInstances) { - if (!await deprecatedInstance.validateIdentityInstance( - superRecordKey: superRecordKey, - superPublicKey: superIdentity.publicKey)) { - // Invalid deprecated IdentityInstance signature(s) - throw IdentityException.invalid; - } - } - - // Validate SuperIdentity - final deprecatedInstancesSignatures = - superIdentity.deprecatedInstances.map((x) => x.signature).toList(); - if (!await _validateSuperIdentitySignature( - recordKey: superIdentity.recordKey, - currentInstanceSignature: superIdentity.currentInstance.signature, - deprecatedInstancesSignatures: deprecatedInstancesSignatures, - deprecatedSuperRecordKeys: superIdentity.deprecatedSuperRecordKeys, - publicKey: superIdentity.publicKey, - signature: superIdentity.signature)) { - // Invalid SuperIdentity signature - throw IdentityException.invalid; + final superIdentity = await superRec.getJson(SuperIdentity.fromJson, + refreshMode: DHTRecordRefreshMode.network); + if (superIdentity == null) { + return null; } + await superIdentity.validate(superRecordKey: superRecordKey); return superIdentity; }); } diff --git a/packages/veilid_support/lib/identity_support/super_identity.freezed.dart b/packages/veilid_support/lib/identity_support/super_identity.freezed.dart index dc1c69a..3144205 100644 --- a/packages/veilid_support/lib/identity_support/super_identity.freezed.dart +++ b/packages/veilid_support/lib/identity_support/super_identity.freezed.dart @@ -1,3 +1,4 @@ +// dart format width=80 // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint @@ -9,61 +10,93 @@ part of 'super_identity.dart'; // FreezedGenerator // ************************************************************************** +// dart format off T _$identity(T value) => value; -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -SuperIdentity _$SuperIdentityFromJson(Map json) { - return _SuperIdentity.fromJson(json); -} - /// @nodoc mixin _$SuperIdentity { /// Public DHT record storing this structure for account recovery /// changing this can migrate/forward the SuperIdentity to a new DHT record /// Instances should not hash this recordKey, rather the actual record /// key used to store the superIdentity, as this may change. - Typed get recordKey => - throw _privateConstructorUsedError; + TypedKey get recordKey; /// Public key of the SuperIdentity used to sign identity keys for recovery /// This must match the owner of the superRecord DHT record and can not be /// changed without changing the record - FixedEncodedString43 get publicKey => throw _privateConstructorUsedError; + PublicKey get publicKey; /// Current identity instance /// The most recently generated identity instance for this SuperIdentity - IdentityInstance get currentInstance => throw _privateConstructorUsedError; + IdentityInstance get currentInstance; /// Deprecated identity instances /// These may be compromised and should not be considered valid for /// new signatures, but may be used to validate old signatures - List get deprecatedInstances => - throw _privateConstructorUsedError; + List get deprecatedInstances; /// Deprecated superRecords /// These may be compromised and should not be considered valid for /// new signatures, but may be used to validate old signatures - List> get deprecatedSuperRecordKeys => - throw _privateConstructorUsedError; + List get deprecatedSuperRecordKeys; /// Signature of recordKey, currentInstance signature, /// signatures of deprecatedInstances, and deprecatedSuperRecordKeys /// by publicKey - FixedEncodedString86 get signature => throw _privateConstructorUsedError; + Signature get signature; - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) + /// Create a copy of SuperIdentity + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') $SuperIdentityCopyWith get copyWith => - throw _privateConstructorUsedError; + _$SuperIdentityCopyWithImpl( + this as SuperIdentity, _$identity); + + /// Serializes this SuperIdentity to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is SuperIdentity && + (identical(other.recordKey, recordKey) || + other.recordKey == recordKey) && + (identical(other.publicKey, publicKey) || + other.publicKey == publicKey) && + (identical(other.currentInstance, currentInstance) || + other.currentInstance == currentInstance) && + const DeepCollectionEquality() + .equals(other.deprecatedInstances, deprecatedInstances) && + const DeepCollectionEquality().equals( + other.deprecatedSuperRecordKeys, deprecatedSuperRecordKeys) && + (identical(other.signature, signature) || + other.signature == signature)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + recordKey, + publicKey, + currentInstance, + const DeepCollectionEquality().hash(deprecatedInstances), + const DeepCollectionEquality().hash(deprecatedSuperRecordKeys), + signature); + + @override + String toString() { + return 'SuperIdentity(recordKey: $recordKey, publicKey: $publicKey, currentInstance: $currentInstance, deprecatedInstances: $deprecatedInstances, deprecatedSuperRecordKeys: $deprecatedSuperRecordKeys, signature: $signature)'; + } } /// @nodoc -abstract class $SuperIdentityCopyWith<$Res> { +abstract mixin class $SuperIdentityCopyWith<$Res> { factory $SuperIdentityCopyWith( - SuperIdentity value, $Res Function(SuperIdentity) then) = - _$SuperIdentityCopyWithImpl<$Res, SuperIdentity>; + SuperIdentity value, $Res Function(SuperIdentity) _then) = + _$SuperIdentityCopyWithImpl; @useResult $Res call( {Typed recordKey, @@ -77,15 +110,15 @@ abstract class $SuperIdentityCopyWith<$Res> { } /// @nodoc -class _$SuperIdentityCopyWithImpl<$Res, $Val extends SuperIdentity> +class _$SuperIdentityCopyWithImpl<$Res> implements $SuperIdentityCopyWith<$Res> { - _$SuperIdentityCopyWithImpl(this._value, this._then); + _$SuperIdentityCopyWithImpl(this._self, this._then); - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; + final SuperIdentity _self; + final $Res Function(SuperIdentity) _then; + /// Create a copy of SuperIdentity + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -96,114 +129,50 @@ class _$SuperIdentityCopyWithImpl<$Res, $Val extends SuperIdentity> Object? deprecatedSuperRecordKeys = null, Object? signature = null, }) { - return _then(_value.copyWith( + return _then(_self.copyWith( recordKey: null == recordKey - ? _value.recordKey + ? _self.recordKey! : recordKey // ignore: cast_nullable_to_non_nullable as Typed, publicKey: null == publicKey - ? _value.publicKey + ? _self.publicKey! : publicKey // ignore: cast_nullable_to_non_nullable as FixedEncodedString43, currentInstance: null == currentInstance - ? _value.currentInstance + ? _self.currentInstance : currentInstance // ignore: cast_nullable_to_non_nullable as IdentityInstance, deprecatedInstances: null == deprecatedInstances - ? _value.deprecatedInstances + ? _self.deprecatedInstances : deprecatedInstances // ignore: cast_nullable_to_non_nullable as List, deprecatedSuperRecordKeys: null == deprecatedSuperRecordKeys - ? _value.deprecatedSuperRecordKeys + ? _self.deprecatedSuperRecordKeys! : deprecatedSuperRecordKeys // ignore: cast_nullable_to_non_nullable as List>, signature: null == signature - ? _value.signature + ? _self.signature! : signature // ignore: cast_nullable_to_non_nullable as FixedEncodedString86, - ) as $Val); + )); } + /// Create a copy of SuperIdentity + /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') $IdentityInstanceCopyWith<$Res> get currentInstance { - return $IdentityInstanceCopyWith<$Res>(_value.currentInstance, (value) { - return _then(_value.copyWith(currentInstance: value) as $Val); + return $IdentityInstanceCopyWith<$Res>(_self.currentInstance, (value) { + return _then(_self.copyWith(currentInstance: value)); }); } } /// @nodoc -abstract class _$$SuperIdentityImplCopyWith<$Res> - implements $SuperIdentityCopyWith<$Res> { - factory _$$SuperIdentityImplCopyWith( - _$SuperIdentityImpl value, $Res Function(_$SuperIdentityImpl) then) = - __$$SuperIdentityImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {Typed recordKey, - FixedEncodedString43 publicKey, - IdentityInstance currentInstance, - List deprecatedInstances, - List> deprecatedSuperRecordKeys, - FixedEncodedString86 signature}); - @override - $IdentityInstanceCopyWith<$Res> get currentInstance; -} - -/// @nodoc -class __$$SuperIdentityImplCopyWithImpl<$Res> - extends _$SuperIdentityCopyWithImpl<$Res, _$SuperIdentityImpl> - implements _$$SuperIdentityImplCopyWith<$Res> { - __$$SuperIdentityImplCopyWithImpl( - _$SuperIdentityImpl _value, $Res Function(_$SuperIdentityImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? recordKey = null, - Object? publicKey = null, - Object? currentInstance = null, - Object? deprecatedInstances = null, - Object? deprecatedSuperRecordKeys = null, - Object? signature = null, - }) { - return _then(_$SuperIdentityImpl( - recordKey: null == recordKey - ? _value.recordKey - : recordKey // ignore: cast_nullable_to_non_nullable - as Typed, - publicKey: null == publicKey - ? _value.publicKey - : publicKey // ignore: cast_nullable_to_non_nullable - as FixedEncodedString43, - currentInstance: null == currentInstance - ? _value.currentInstance - : currentInstance // ignore: cast_nullable_to_non_nullable - as IdentityInstance, - deprecatedInstances: null == deprecatedInstances - ? _value._deprecatedInstances - : deprecatedInstances // ignore: cast_nullable_to_non_nullable - as List, - deprecatedSuperRecordKeys: null == deprecatedSuperRecordKeys - ? _value._deprecatedSuperRecordKeys - : deprecatedSuperRecordKeys // ignore: cast_nullable_to_non_nullable - as List>, - signature: null == signature - ? _value.signature - : signature // ignore: cast_nullable_to_non_nullable - as FixedEncodedString86, - )); - } -} - -/// @nodoc @JsonSerializable() -class _$SuperIdentityImpl extends _SuperIdentity { - const _$SuperIdentityImpl( +class _SuperIdentity extends SuperIdentity { + const _SuperIdentity( {required this.recordKey, required this.publicKey, required this.currentInstance, @@ -214,9 +183,8 @@ class _$SuperIdentityImpl extends _SuperIdentity { : _deprecatedInstances = deprecatedInstances, _deprecatedSuperRecordKeys = deprecatedSuperRecordKeys, super._(); - - factory _$SuperIdentityImpl.fromJson(Map json) => - _$$SuperIdentityImplFromJson(json); + factory _SuperIdentity.fromJson(Map json) => + _$SuperIdentityFromJson(json); /// Public DHT record storing this structure for account recovery /// changing this can migrate/forward the SuperIdentity to a new DHT record @@ -274,16 +242,26 @@ class _$SuperIdentityImpl extends _SuperIdentity { @override final FixedEncodedString86 signature; + /// Create a copy of SuperIdentity + /// with the given fields replaced by the non-null parameter values. @override - String toString() { - return 'SuperIdentity(recordKey: $recordKey, publicKey: $publicKey, currentInstance: $currentInstance, deprecatedInstances: $deprecatedInstances, deprecatedSuperRecordKeys: $deprecatedSuperRecordKeys, signature: $signature)'; + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$SuperIdentityCopyWith<_SuperIdentity> get copyWith => + __$SuperIdentityCopyWithImpl<_SuperIdentity>(this, _$identity); + + @override + Map toJson() { + return _$SuperIdentityToJson( + this, + ); } @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$SuperIdentityImpl && + other is _SuperIdentity && (identical(other.recordKey, recordKey) || other.recordKey == recordKey) && (identical(other.publicKey, publicKey) || @@ -298,7 +276,7 @@ class _$SuperIdentityImpl extends _SuperIdentity { other.signature == signature)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash( runtimeType, @@ -309,72 +287,89 @@ class _$SuperIdentityImpl extends _SuperIdentity { const DeepCollectionEquality().hash(_deprecatedSuperRecordKeys), signature); - @JsonKey(ignore: true) @override - @pragma('vm:prefer-inline') - _$$SuperIdentityImplCopyWith<_$SuperIdentityImpl> get copyWith => - __$$SuperIdentityImplCopyWithImpl<_$SuperIdentityImpl>(this, _$identity); - - @override - Map toJson() { - return _$$SuperIdentityImplToJson( - this, - ); + String toString() { + return 'SuperIdentity(recordKey: $recordKey, publicKey: $publicKey, currentInstance: $currentInstance, deprecatedInstances: $deprecatedInstances, deprecatedSuperRecordKeys: $deprecatedSuperRecordKeys, signature: $signature)'; } } -abstract class _SuperIdentity extends SuperIdentity { - const factory _SuperIdentity( - {required final Typed recordKey, - required final FixedEncodedString43 publicKey, - required final IdentityInstance currentInstance, - required final List deprecatedInstances, - required final List> - deprecatedSuperRecordKeys, - required final FixedEncodedString86 signature}) = _$SuperIdentityImpl; - const _SuperIdentity._() : super._(); - - factory _SuperIdentity.fromJson(Map json) = - _$SuperIdentityImpl.fromJson; +/// @nodoc +abstract mixin class _$SuperIdentityCopyWith<$Res> + implements $SuperIdentityCopyWith<$Res> { + factory _$SuperIdentityCopyWith( + _SuperIdentity value, $Res Function(_SuperIdentity) _then) = + __$SuperIdentityCopyWithImpl; + @override + @useResult + $Res call( + {Typed recordKey, + FixedEncodedString43 publicKey, + IdentityInstance currentInstance, + List deprecatedInstances, + List> deprecatedSuperRecordKeys, + FixedEncodedString86 signature}); @override - - /// Public DHT record storing this structure for account recovery - /// changing this can migrate/forward the SuperIdentity to a new DHT record - /// Instances should not hash this recordKey, rather the actual record - /// key used to store the superIdentity, as this may change. - Typed get recordKey; - @override - - /// Public key of the SuperIdentity used to sign identity keys for recovery - /// This must match the owner of the superRecord DHT record and can not be - /// changed without changing the record - FixedEncodedString43 get publicKey; - @override - - /// Current identity instance - /// The most recently generated identity instance for this SuperIdentity - IdentityInstance get currentInstance; - @override - - /// Deprecated identity instances - /// These may be compromised and should not be considered valid for - /// new signatures, but may be used to validate old signatures - List get deprecatedInstances; - @override - - /// Deprecated superRecords - /// These may be compromised and should not be considered valid for - /// new signatures, but may be used to validate old signatures - List> get deprecatedSuperRecordKeys; - @override - - /// Signature of recordKey, currentInstance signature, - /// signatures of deprecatedInstances, and deprecatedSuperRecordKeys - /// by publicKey - FixedEncodedString86 get signature; - @override - @JsonKey(ignore: true) - _$$SuperIdentityImplCopyWith<_$SuperIdentityImpl> get copyWith => - throw _privateConstructorUsedError; + $IdentityInstanceCopyWith<$Res> get currentInstance; } + +/// @nodoc +class __$SuperIdentityCopyWithImpl<$Res> + implements _$SuperIdentityCopyWith<$Res> { + __$SuperIdentityCopyWithImpl(this._self, this._then); + + final _SuperIdentity _self; + final $Res Function(_SuperIdentity) _then; + + /// Create a copy of SuperIdentity + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? recordKey = null, + Object? publicKey = null, + Object? currentInstance = null, + Object? deprecatedInstances = null, + Object? deprecatedSuperRecordKeys = null, + Object? signature = null, + }) { + return _then(_SuperIdentity( + recordKey: null == recordKey + ? _self.recordKey + : recordKey // ignore: cast_nullable_to_non_nullable + as Typed, + publicKey: null == publicKey + ? _self.publicKey + : publicKey // ignore: cast_nullable_to_non_nullable + as FixedEncodedString43, + currentInstance: null == currentInstance + ? _self.currentInstance + : currentInstance // ignore: cast_nullable_to_non_nullable + as IdentityInstance, + deprecatedInstances: null == deprecatedInstances + ? _self._deprecatedInstances + : deprecatedInstances // ignore: cast_nullable_to_non_nullable + as List, + deprecatedSuperRecordKeys: null == deprecatedSuperRecordKeys + ? _self._deprecatedSuperRecordKeys + : deprecatedSuperRecordKeys // ignore: cast_nullable_to_non_nullable + as List>, + signature: null == signature + ? _self.signature + : signature // ignore: cast_nullable_to_non_nullable + as FixedEncodedString86, + )); + } + + /// Create a copy of SuperIdentity + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $IdentityInstanceCopyWith<$Res> get currentInstance { + return $IdentityInstanceCopyWith<$Res>(_self.currentInstance, (value) { + return _then(_self.copyWith(currentInstance: value)); + }); + } +} + +// dart format on diff --git a/packages/veilid_support/lib/identity_support/super_identity.g.dart b/packages/veilid_support/lib/identity_support/super_identity.g.dart index 4c4f4f3..1b52492 100644 --- a/packages/veilid_support/lib/identity_support/super_identity.g.dart +++ b/packages/veilid_support/lib/identity_support/super_identity.g.dart @@ -6,8 +6,8 @@ part of 'super_identity.dart'; // JsonSerializableGenerator // ************************************************************************** -_$SuperIdentityImpl _$$SuperIdentityImplFromJson(Map json) => - _$SuperIdentityImpl( +_SuperIdentity _$SuperIdentityFromJson(Map json) => + _SuperIdentity( recordKey: Typed.fromJson(json['record_key']), publicKey: FixedEncodedString43.fromJson(json['public_key']), currentInstance: IdentityInstance.fromJson(json['current_instance']), @@ -21,7 +21,7 @@ _$SuperIdentityImpl _$$SuperIdentityImplFromJson(Map json) => signature: FixedEncodedString86.fromJson(json['signature']), ); -Map _$$SuperIdentityImplToJson(_$SuperIdentityImpl instance) => +Map _$SuperIdentityToJson(_SuperIdentity instance) => { 'record_key': instance.recordKey.toJson(), 'public_key': instance.publicKey.toJson(), diff --git a/packages/veilid_support/lib/identity_support/super_identity_cubit.dart b/packages/veilid_support/lib/identity_support/super_identity_cubit.dart new file mode 100644 index 0000000..9de55ad --- /dev/null +++ b/packages/veilid_support/lib/identity_support/super_identity_cubit.dart @@ -0,0 +1,21 @@ +import 'package:async_tools/async_tools.dart'; + +import '../veilid_support.dart'; + +typedef SuperIdentityState = AsyncValue; + +class SuperIdentityCubit extends DefaultDHTRecordCubit { + SuperIdentityCubit({required TypedKey superRecordKey}) + : super( + open: () => _open(superRecordKey: superRecordKey), + decodeState: (buf) => jsonDecodeBytes(SuperIdentity.fromJson, buf)); + + static Future _open({required TypedKey superRecordKey}) async { + final pool = DHTRecordPool.instance; + + return pool.openRecordRead( + superRecordKey, + debugName: 'SuperIdentityCubit::_open::SuperIdentityRecord', + ); + } +} diff --git a/packages/veilid_support/lib/identity_support/writable_super_identity.dart b/packages/veilid_support/lib/identity_support/writable_super_identity.dart index 3d88742..093073f 100644 --- a/packages/veilid_support/lib/identity_support/writable_super_identity.dart +++ b/packages/veilid_support/lib/identity_support/writable_super_identity.dart @@ -69,6 +69,12 @@ class WritableSuperIdentity { /// Delete a super identity with secrets Future delete() async => superIdentity.delete(); + /// Produce a recovery key for this superIdentity + Uint8List get recoveryKey => (BytesBuilder() + ..add(superIdentity.recordKey.decode()) + ..add(superSecret.decode())) + .toBytes(); + /// xxx: migration support, new identities, reveal identity secret etc //////////////////////////////////////////////////////////////////////////// @@ -113,8 +119,8 @@ class WritableSuperIdentity { // Make encrypted secret key final cs = await Veilid.instance.getCryptoSystem(identityRecordKey.kind); - final encryptionKey = await cs.generateSharedSecret( - identityPublicKey, superSecret, identityCryptoDomain); + final encryptionKey = await cs.deriveSharedSecret( + superSecret.decode(), identityPublicKey.decode()); final encryptedSecretKey = await cs.encryptNoAuthWithNonce( identitySecretKey.decode(), encryptionKey); diff --git a/packages/veilid_support/lib/proto/dht.pb.dart b/packages/veilid_support/lib/proto/dht.pb.dart index 814bd22..b1c0b47 100644 --- a/packages/veilid_support/lib/proto/dht.pb.dart +++ b/packages/veilid_support/lib/proto/dht.pb.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields // ignore_for_file: unnecessary_import, unnecessary_this, unused_import @@ -16,7 +16,27 @@ import 'package:protobuf/protobuf.dart' as $pb; import 'veilid.pb.dart' as $0; class DHTData extends $pb.GeneratedMessage { - factory DHTData() => create(); + factory DHTData({ + $core.Iterable<$0.TypedKey>? keys, + $0.TypedKey? hash, + $core.int? chunk, + $core.int? size, + }) { + final $result = create(); + if (keys != null) { + $result.keys.addAll(keys); + } + if (hash != null) { + $result.hash = hash; + } + if (chunk != null) { + $result.chunk = chunk; + } + if (size != null) { + $result.size = size; + } + return $result; + } DHTData._() : super(); factory DHTData.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory DHTData.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -50,9 +70,12 @@ class DHTData extends $pb.GeneratedMessage { static DHTData getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static DHTData? _defaultInstance; + /// Other keys to concatenate + /// Uses the same writer as this DHTList with SMPL schema @$pb.TagNumber(1) $core.List<$0.TypedKey> get keys => $_getList(0); + /// Hash of reassembled data to verify contents @$pb.TagNumber(2) $0.TypedKey get hash => $_getN(1); @$pb.TagNumber(2) @@ -64,6 +87,7 @@ class DHTData extends $pb.GeneratedMessage { @$pb.TagNumber(2) $0.TypedKey ensureHash() => $_ensure(1); + /// Chunk size per subkey @$pb.TagNumber(3) $core.int get chunk => $_getIZ(2); @$pb.TagNumber(3) @@ -73,6 +97,7 @@ class DHTData extends $pb.GeneratedMessage { @$pb.TagNumber(3) void clearChunk() => clearField(3); + /// Total data size @$pb.TagNumber(4) $core.int get size => $_getIZ(3); @$pb.TagNumber(4) @@ -83,8 +108,26 @@ class DHTData extends $pb.GeneratedMessage { void clearSize() => clearField(4); } +/// DHTLog - represents a ring buffer of many elements with append/truncate semantics +/// Header in subkey 0 of first key follows this structure class DHTLog extends $pb.GeneratedMessage { - factory DHTLog() => create(); + factory DHTLog({ + $core.int? head, + $core.int? tail, + $core.int? stride, + }) { + final $result = create(); + if (head != null) { + $result.head = head; + } + if (tail != null) { + $result.tail = tail; + } + if (stride != null) { + $result.stride = stride; + } + return $result; + } DHTLog._() : super(); factory DHTLog.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory DHTLog.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -117,6 +160,7 @@ class DHTLog extends $pb.GeneratedMessage { static DHTLog getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static DHTLog? _defaultInstance; + /// Position of the start of the log (oldest items) @$pb.TagNumber(1) $core.int get head => $_getIZ(0); @$pb.TagNumber(1) @@ -126,6 +170,7 @@ class DHTLog extends $pb.GeneratedMessage { @$pb.TagNumber(1) void clearHead() => clearField(1); + /// Position of the end of the log (newest items) @$pb.TagNumber(2) $core.int get tail => $_getIZ(1); @$pb.TagNumber(2) @@ -135,6 +180,7 @@ class DHTLog extends $pb.GeneratedMessage { @$pb.TagNumber(2) void clearTail() => clearField(2); + /// Stride of each segment of the dhtlog @$pb.TagNumber(3) $core.int get stride => $_getIZ(2); @$pb.TagNumber(3) @@ -145,8 +191,32 @@ class DHTLog extends $pb.GeneratedMessage { void clearStride() => clearField(3); } +/// DHTShortArray - represents a re-orderable collection of up to 256 individual elements +/// Header in subkey 0 of first key follows this structure +/// +/// stride = descriptor subkey count on first key - 1 +/// Subkeys 1..=stride on the first key are individual elements +/// Subkeys 0..stride on the 'keys' keys are also individual elements +/// +/// Keys must use writable schema in order to make this list mutable class DHTShortArray extends $pb.GeneratedMessage { - factory DHTShortArray() => create(); + factory DHTShortArray({ + $core.Iterable<$0.TypedKey>? keys, + $core.List<$core.int>? index, + $core.Iterable<$core.int>? seqs, + }) { + final $result = create(); + if (keys != null) { + $result.keys.addAll(keys); + } + if (index != null) { + $result.index = index; + } + if (seqs != null) { + $result.seqs.addAll(seqs); + } + return $result; + } DHTShortArray._() : super(); factory DHTShortArray.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory DHTShortArray.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -179,9 +249,16 @@ class DHTShortArray extends $pb.GeneratedMessage { static DHTShortArray getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static DHTShortArray? _defaultInstance; + /// Other keys to concatenate + /// Uses the same writer as this DHTList with SMPL schema @$pb.TagNumber(1) $core.List<$0.TypedKey> get keys => $_getList(0); + /// Item position index (uint8[256./]) + /// Actual item location is: + /// idx = index[n] + 1 (offset for header at idx 0) + /// key = idx / stride + /// subkey = idx % stride @$pb.TagNumber(2) $core.List<$core.int> get index => $_getN(1); @$pb.TagNumber(2) @@ -191,183 +268,26 @@ class DHTShortArray extends $pb.GeneratedMessage { @$pb.TagNumber(2) void clearIndex() => clearField(2); + /// Most recent sequence numbers for elements @$pb.TagNumber(3) $core.List<$core.int> get seqs => $_getList(2); } -class DHTDataReference extends $pb.GeneratedMessage { - factory DHTDataReference() => create(); - DHTDataReference._() : super(); - factory DHTDataReference.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); - factory DHTDataReference.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); - - static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DHTDataReference', package: const $pb.PackageName(_omitMessageNames ? '' : 'dht'), createEmptyInstance: create) - ..aOM<$0.TypedKey>(1, _omitFieldNames ? '' : 'dhtData', subBuilder: $0.TypedKey.create) - ..aOM<$0.TypedKey>(2, _omitFieldNames ? '' : 'hash', subBuilder: $0.TypedKey.create) - ..hasRequiredFields = false - ; - - @$core.Deprecated( - 'Using this can add significant overhead to your binary. ' - 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' - 'Will be removed in next major version') - DHTDataReference clone() => DHTDataReference()..mergeFromMessage(this); - @$core.Deprecated( - 'Using this can add significant overhead to your binary. ' - 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' - 'Will be removed in next major version') - DHTDataReference copyWith(void Function(DHTDataReference) updates) => super.copyWith((message) => updates(message as DHTDataReference)) as DHTDataReference; - - $pb.BuilderInfo get info_ => _i; - - @$core.pragma('dart2js:noInline') - static DHTDataReference create() => DHTDataReference._(); - DHTDataReference createEmptyInstance() => create(); - static $pb.PbList createRepeated() => $pb.PbList(); - @$core.pragma('dart2js:noInline') - static DHTDataReference getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); - static DHTDataReference? _defaultInstance; - - @$pb.TagNumber(1) - $0.TypedKey get dhtData => $_getN(0); - @$pb.TagNumber(1) - set dhtData($0.TypedKey v) { setField(1, v); } - @$pb.TagNumber(1) - $core.bool hasDhtData() => $_has(0); - @$pb.TagNumber(1) - void clearDhtData() => clearField(1); - @$pb.TagNumber(1) - $0.TypedKey ensureDhtData() => $_ensure(0); - - @$pb.TagNumber(2) - $0.TypedKey get hash => $_getN(1); - @$pb.TagNumber(2) - set hash($0.TypedKey v) { setField(2, v); } - @$pb.TagNumber(2) - $core.bool hasHash() => $_has(1); - @$pb.TagNumber(2) - void clearHash() => clearField(2); - @$pb.TagNumber(2) - $0.TypedKey ensureHash() => $_ensure(1); -} - -class BlockStoreDataReference extends $pb.GeneratedMessage { - factory BlockStoreDataReference() => create(); - BlockStoreDataReference._() : super(); - factory BlockStoreDataReference.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); - factory BlockStoreDataReference.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); - - static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'BlockStoreDataReference', package: const $pb.PackageName(_omitMessageNames ? '' : 'dht'), createEmptyInstance: create) - ..aOM<$0.TypedKey>(1, _omitFieldNames ? '' : 'block', subBuilder: $0.TypedKey.create) - ..hasRequiredFields = false - ; - - @$core.Deprecated( - 'Using this can add significant overhead to your binary. ' - 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' - 'Will be removed in next major version') - BlockStoreDataReference clone() => BlockStoreDataReference()..mergeFromMessage(this); - @$core.Deprecated( - 'Using this can add significant overhead to your binary. ' - 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' - 'Will be removed in next major version') - BlockStoreDataReference copyWith(void Function(BlockStoreDataReference) updates) => super.copyWith((message) => updates(message as BlockStoreDataReference)) as BlockStoreDataReference; - - $pb.BuilderInfo get info_ => _i; - - @$core.pragma('dart2js:noInline') - static BlockStoreDataReference create() => BlockStoreDataReference._(); - BlockStoreDataReference createEmptyInstance() => create(); - static $pb.PbList createRepeated() => $pb.PbList(); - @$core.pragma('dart2js:noInline') - static BlockStoreDataReference getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); - static BlockStoreDataReference? _defaultInstance; - - @$pb.TagNumber(1) - $0.TypedKey get block => $_getN(0); - @$pb.TagNumber(1) - set block($0.TypedKey v) { setField(1, v); } - @$pb.TagNumber(1) - $core.bool hasBlock() => $_has(0); - @$pb.TagNumber(1) - void clearBlock() => clearField(1); - @$pb.TagNumber(1) - $0.TypedKey ensureBlock() => $_ensure(0); -} - -enum DataReference_Kind { - dhtData, - blockStoreData, - notSet -} - -class DataReference extends $pb.GeneratedMessage { - factory DataReference() => create(); - DataReference._() : super(); - factory DataReference.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); - factory DataReference.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); - - static const $core.Map<$core.int, DataReference_Kind> _DataReference_KindByTag = { - 1 : DataReference_Kind.dhtData, - 2 : DataReference_Kind.blockStoreData, - 0 : DataReference_Kind.notSet - }; - static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DataReference', package: const $pb.PackageName(_omitMessageNames ? '' : 'dht'), createEmptyInstance: create) - ..oo(0, [1, 2]) - ..aOM(1, _omitFieldNames ? '' : 'dhtData', subBuilder: DHTDataReference.create) - ..aOM(2, _omitFieldNames ? '' : 'blockStoreData', subBuilder: BlockStoreDataReference.create) - ..hasRequiredFields = false - ; - - @$core.Deprecated( - 'Using this can add significant overhead to your binary. ' - 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' - 'Will be removed in next major version') - DataReference clone() => DataReference()..mergeFromMessage(this); - @$core.Deprecated( - 'Using this can add significant overhead to your binary. ' - 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' - 'Will be removed in next major version') - DataReference copyWith(void Function(DataReference) updates) => super.copyWith((message) => updates(message as DataReference)) as DataReference; - - $pb.BuilderInfo get info_ => _i; - - @$core.pragma('dart2js:noInline') - static DataReference create() => DataReference._(); - DataReference createEmptyInstance() => create(); - static $pb.PbList createRepeated() => $pb.PbList(); - @$core.pragma('dart2js:noInline') - static DataReference getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); - static DataReference? _defaultInstance; - - DataReference_Kind whichKind() => _DataReference_KindByTag[$_whichOneof(0)]!; - void clearKind() => clearField($_whichOneof(0)); - - @$pb.TagNumber(1) - DHTDataReference get dhtData => $_getN(0); - @$pb.TagNumber(1) - set dhtData(DHTDataReference v) { setField(1, v); } - @$pb.TagNumber(1) - $core.bool hasDhtData() => $_has(0); - @$pb.TagNumber(1) - void clearDhtData() => clearField(1); - @$pb.TagNumber(1) - DHTDataReference ensureDhtData() => $_ensure(0); - - @$pb.TagNumber(2) - BlockStoreDataReference get blockStoreData => $_getN(1); - @$pb.TagNumber(2) - set blockStoreData(BlockStoreDataReference v) { setField(2, v); } - @$pb.TagNumber(2) - $core.bool hasBlockStoreData() => $_has(1); - @$pb.TagNumber(2) - void clearBlockStoreData() => clearField(2); - @$pb.TagNumber(2) - BlockStoreDataReference ensureBlockStoreData() => $_ensure(1); -} - +/// A pointer to an child DHT record class OwnedDHTRecordPointer extends $pb.GeneratedMessage { - factory OwnedDHTRecordPointer() => create(); + factory OwnedDHTRecordPointer({ + $0.TypedKey? recordKey, + $0.KeyPair? owner, + }) { + final $result = create(); + if (recordKey != null) { + $result.recordKey = recordKey; + } + if (owner != null) { + $result.owner = owner; + } + return $result; + } OwnedDHTRecordPointer._() : super(); factory OwnedDHTRecordPointer.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory OwnedDHTRecordPointer.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -399,6 +319,7 @@ class OwnedDHTRecordPointer extends $pb.GeneratedMessage { static OwnedDHTRecordPointer getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static OwnedDHTRecordPointer? _defaultInstance; + /// DHT Record key @$pb.TagNumber(1) $0.TypedKey get recordKey => $_getN(0); @$pb.TagNumber(1) @@ -410,6 +331,7 @@ class OwnedDHTRecordPointer extends $pb.GeneratedMessage { @$pb.TagNumber(1) $0.TypedKey ensureRecordKey() => $_ensure(0); + /// DHT record owner key @$pb.TagNumber(2) $0.KeyPair get owner => $_getN(1); @$pb.TagNumber(2) diff --git a/packages/veilid_support/lib/proto/dht.pbenum.dart b/packages/veilid_support/lib/proto/dht.pbenum.dart index f76992d..7059e85 100644 --- a/packages/veilid_support/lib/proto/dht.pbenum.dart +++ b/packages/veilid_support/lib/proto/dht.pbenum.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields // ignore_for_file: unnecessary_import, unnecessary_this, unused_import diff --git a/packages/veilid_support/lib/proto/dht.pbjson.dart b/packages/veilid_support/lib/proto/dht.pbjson.dart index b8575b9..dd14566 100644 --- a/packages/veilid_support/lib/proto/dht.pbjson.dart +++ b/packages/veilid_support/lib/proto/dht.pbjson.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields // ignore_for_file: unnecessary_import, unnecessary_this, unused_import @@ -60,51 +60,6 @@ final $typed_data.Uint8List dHTShortArrayDescriptor = $convert.base64Decode( 'Cg1ESFRTaG9ydEFycmF5EiQKBGtleXMYASADKAsyEC52ZWlsaWQuVHlwZWRLZXlSBGtleXMSFA' 'oFaW5kZXgYAiABKAxSBWluZGV4EhIKBHNlcXMYAyADKA1SBHNlcXM='); -@$core.Deprecated('Use dHTDataReferenceDescriptor instead') -const DHTDataReference$json = { - '1': 'DHTDataReference', - '2': [ - {'1': 'dht_data', '3': 1, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'dhtData'}, - {'1': 'hash', '3': 2, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'hash'}, - ], -}; - -/// Descriptor for `DHTDataReference`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List dHTDataReferenceDescriptor = $convert.base64Decode( - 'ChBESFREYXRhUmVmZXJlbmNlEisKCGRodF9kYXRhGAEgASgLMhAudmVpbGlkLlR5cGVkS2V5Ug' - 'dkaHREYXRhEiQKBGhhc2gYAiABKAsyEC52ZWlsaWQuVHlwZWRLZXlSBGhhc2g='); - -@$core.Deprecated('Use blockStoreDataReferenceDescriptor instead') -const BlockStoreDataReference$json = { - '1': 'BlockStoreDataReference', - '2': [ - {'1': 'block', '3': 1, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'block'}, - ], -}; - -/// Descriptor for `BlockStoreDataReference`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List blockStoreDataReferenceDescriptor = $convert.base64Decode( - 'ChdCbG9ja1N0b3JlRGF0YVJlZmVyZW5jZRImCgVibG9jaxgBIAEoCzIQLnZlaWxpZC5UeXBlZE' - 'tleVIFYmxvY2s='); - -@$core.Deprecated('Use dataReferenceDescriptor instead') -const DataReference$json = { - '1': 'DataReference', - '2': [ - {'1': 'dht_data', '3': 1, '4': 1, '5': 11, '6': '.dht.DHTDataReference', '9': 0, '10': 'dhtData'}, - {'1': 'block_store_data', '3': 2, '4': 1, '5': 11, '6': '.dht.BlockStoreDataReference', '9': 0, '10': 'blockStoreData'}, - ], - '8': [ - {'1': 'kind'}, - ], -}; - -/// Descriptor for `DataReference`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List dataReferenceDescriptor = $convert.base64Decode( - 'Cg1EYXRhUmVmZXJlbmNlEjIKCGRodF9kYXRhGAEgASgLMhUuZGh0LkRIVERhdGFSZWZlcmVuY2' - 'VIAFIHZGh0RGF0YRJIChBibG9ja19zdG9yZV9kYXRhGAIgASgLMhwuZGh0LkJsb2NrU3RvcmVE' - 'YXRhUmVmZXJlbmNlSABSDmJsb2NrU3RvcmVEYXRhQgYKBGtpbmQ='); - @$core.Deprecated('Use ownedDHTRecordPointerDescriptor instead') const OwnedDHTRecordPointer$json = { '1': 'OwnedDHTRecordPointer', diff --git a/packages/veilid_support/lib/proto/dht.pbserver.dart b/packages/veilid_support/lib/proto/dht.pbserver.dart index ffbf990..02e8c03 100644 --- a/packages/veilid_support/lib/proto/dht.pbserver.dart +++ b/packages/veilid_support/lib/proto/dht.pbserver.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names // ignore_for_file: deprecated_member_use_from_same_package, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields diff --git a/packages/veilid_support/lib/proto/proto.dart b/packages/veilid_support/lib/proto/proto.dart index a7a70bb..936bbdf 100644 --- a/packages/veilid_support/lib/proto/proto.dart +++ b/packages/veilid_support/lib/proto/proto.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; +import '../src/dynamic_debug.dart'; import '../veilid_support.dart' as veilid; import 'veilid.pb.dart' as proto; @@ -150,3 +151,26 @@ extension ProtoKeyPair on proto.KeyPair { veilid.KeyPair toVeilid() => veilid.KeyPair(key: key.toVeilid(), secret: secret.toVeilid()); } + +void registerVeilidProtoToDebug() { + dynamic toDebug(dynamic protoObj) { + if (protoObj is proto.CryptoKey) { + return protoObj.toVeilid(); + } + if (protoObj is proto.Signature) { + return protoObj.toVeilid(); + } + if (protoObj is proto.Nonce) { + return protoObj.toVeilid(); + } + if (protoObj is proto.TypedKey) { + return protoObj.toVeilid(); + } + if (protoObj is proto.KeyPair) { + return protoObj.toVeilid(); + } + return protoObj; + } + + DynamicDebug.registerToDebug(toDebug); +} diff --git a/packages/veilid_support/lib/proto/veilid.pb.dart b/packages/veilid_support/lib/proto/veilid.pb.dart index a53133a..5431b80 100644 --- a/packages/veilid_support/lib/proto/veilid.pb.dart +++ b/packages/veilid_support/lib/proto/veilid.pb.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields // ignore_for_file: unnecessary_import, unnecessary_this, unused_import @@ -13,8 +13,45 @@ import 'dart:core' as $core; import 'package:protobuf/protobuf.dart' as $pb; +/// 32-byte value in bigendian format class CryptoKey extends $pb.GeneratedMessage { - factory CryptoKey() => create(); + factory CryptoKey({ + $core.int? u0, + $core.int? u1, + $core.int? u2, + $core.int? u3, + $core.int? u4, + $core.int? u5, + $core.int? u6, + $core.int? u7, + }) { + final $result = create(); + if (u0 != null) { + $result.u0 = u0; + } + if (u1 != null) { + $result.u1 = u1; + } + if (u2 != null) { + $result.u2 = u2; + } + if (u3 != null) { + $result.u3 = u3; + } + if (u4 != null) { + $result.u4 = u4; + } + if (u5 != null) { + $result.u5 = u5; + } + if (u6 != null) { + $result.u6 = u6; + } + if (u7 != null) { + $result.u7 = u7; + } + return $result; + } CryptoKey._() : super(); factory CryptoKey.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory CryptoKey.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -125,8 +162,77 @@ class CryptoKey extends $pb.GeneratedMessage { void clearU7() => clearField(8); } +/// 64-byte value in bigendian format class Signature extends $pb.GeneratedMessage { - factory Signature() => create(); + factory Signature({ + $core.int? u0, + $core.int? u1, + $core.int? u2, + $core.int? u3, + $core.int? u4, + $core.int? u5, + $core.int? u6, + $core.int? u7, + $core.int? u8, + $core.int? u9, + $core.int? u10, + $core.int? u11, + $core.int? u12, + $core.int? u13, + $core.int? u14, + $core.int? u15, + }) { + final $result = create(); + if (u0 != null) { + $result.u0 = u0; + } + if (u1 != null) { + $result.u1 = u1; + } + if (u2 != null) { + $result.u2 = u2; + } + if (u3 != null) { + $result.u3 = u3; + } + if (u4 != null) { + $result.u4 = u4; + } + if (u5 != null) { + $result.u5 = u5; + } + if (u6 != null) { + $result.u6 = u6; + } + if (u7 != null) { + $result.u7 = u7; + } + if (u8 != null) { + $result.u8 = u8; + } + if (u9 != null) { + $result.u9 = u9; + } + if (u10 != null) { + $result.u10 = u10; + } + if (u11 != null) { + $result.u11 = u11; + } + if (u12 != null) { + $result.u12 = u12; + } + if (u13 != null) { + $result.u13 = u13; + } + if (u14 != null) { + $result.u14 = u14; + } + if (u15 != null) { + $result.u15 = u15; + } + return $result; + } Signature._() : super(); factory Signature.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Signature.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -317,8 +423,37 @@ class Signature extends $pb.GeneratedMessage { void clearU15() => clearField(16); } +/// 24-byte value in bigendian format class Nonce extends $pb.GeneratedMessage { - factory Nonce() => create(); + factory Nonce({ + $core.int? u0, + $core.int? u1, + $core.int? u2, + $core.int? u3, + $core.int? u4, + $core.int? u5, + }) { + final $result = create(); + if (u0 != null) { + $result.u0 = u0; + } + if (u1 != null) { + $result.u1 = u1; + } + if (u2 != null) { + $result.u2 = u2; + } + if (u3 != null) { + $result.u3 = u3; + } + if (u4 != null) { + $result.u4 = u4; + } + if (u5 != null) { + $result.u5 = u5; + } + return $result; + } Nonce._() : super(); factory Nonce.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Nonce.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -409,8 +544,21 @@ class Nonce extends $pb.GeneratedMessage { void clearU5() => clearField(6); } +/// 36-byte typed crypto key class TypedKey extends $pb.GeneratedMessage { - factory TypedKey() => create(); + factory TypedKey({ + $core.int? kind, + CryptoKey? value, + }) { + final $result = create(); + if (kind != null) { + $result.kind = kind; + } + if (value != null) { + $result.value = value; + } + return $result; + } TypedKey._() : super(); factory TypedKey.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory TypedKey.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -442,6 +590,7 @@ class TypedKey extends $pb.GeneratedMessage { static TypedKey getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static TypedKey? _defaultInstance; + /// CryptoKind FourCC in bigendian format @$pb.TagNumber(1) $core.int get kind => $_getIZ(0); @$pb.TagNumber(1) @@ -451,6 +600,7 @@ class TypedKey extends $pb.GeneratedMessage { @$pb.TagNumber(1) void clearKind() => clearField(1); + /// Key value @$pb.TagNumber(2) CryptoKey get value => $_getN(1); @$pb.TagNumber(2) @@ -463,8 +613,21 @@ class TypedKey extends $pb.GeneratedMessage { CryptoKey ensureValue() => $_ensure(1); } +/// Key pair class KeyPair extends $pb.GeneratedMessage { - factory KeyPair() => create(); + factory KeyPair({ + CryptoKey? key, + CryptoKey? secret, + }) { + final $result = create(); + if (key != null) { + $result.key = key; + } + if (secret != null) { + $result.secret = secret; + } + return $result; + } KeyPair._() : super(); factory KeyPair.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory KeyPair.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -496,6 +659,7 @@ class KeyPair extends $pb.GeneratedMessage { static KeyPair getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static KeyPair? _defaultInstance; + /// Public key @$pb.TagNumber(1) CryptoKey get key => $_getN(0); @$pb.TagNumber(1) @@ -507,6 +671,7 @@ class KeyPair extends $pb.GeneratedMessage { @$pb.TagNumber(1) CryptoKey ensureKey() => $_ensure(0); + /// Private key @$pb.TagNumber(2) CryptoKey get secret => $_getN(1); @$pb.TagNumber(2) diff --git a/packages/veilid_support/lib/proto/veilid.pbenum.dart b/packages/veilid_support/lib/proto/veilid.pbenum.dart index 1ade7e9..89c0019 100644 --- a/packages/veilid_support/lib/proto/veilid.pbenum.dart +++ b/packages/veilid_support/lib/proto/veilid.pbenum.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields // ignore_for_file: unnecessary_import, unnecessary_this, unused_import diff --git a/packages/veilid_support/lib/proto/veilid.pbjson.dart b/packages/veilid_support/lib/proto/veilid.pbjson.dart index b92b4e5..db8318e 100644 --- a/packages/veilid_support/lib/proto/veilid.pbjson.dart +++ b/packages/veilid_support/lib/proto/veilid.pbjson.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields // ignore_for_file: unnecessary_import, unnecessary_this, unused_import diff --git a/packages/veilid_support/lib/proto/veilid.pbserver.dart b/packages/veilid_support/lib/proto/veilid.pbserver.dart index 2de2834..f799a3f 100644 --- a/packages/veilid_support/lib/proto/veilid.pbserver.dart +++ b/packages/veilid_support/lib/proto/veilid.pbserver.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names // ignore_for_file: deprecated_member_use_from_same_package, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields diff --git a/packages/veilid_support/lib/src/async_table_db_backed_cubit.dart b/packages/veilid_support/lib/src/async_table_db_backed_cubit.dart index 710eec4..313d3e2 100644 --- a/packages/veilid_support/lib/src/async_table_db_backed_cubit.dart +++ b/packages/veilid_support/lib/src/async_table_db_backed_cubit.dart @@ -4,6 +4,7 @@ import 'package:async_tools/async_tools.dart'; import 'package:bloc/bloc.dart'; import 'package:meta/meta.dart'; +import 'config.dart'; import 'table_db.dart'; abstract class AsyncTableDBBackedCubit extends Cubit> @@ -22,13 +23,14 @@ abstract class AsyncTableDBBackedCubit extends Cubit> await super.close(); } - Future _build() async { + Future _build(_) async { try { await _mutex.protect(() async { emit(AsyncValue.data(await load())); }); - } on Exception catch (e, stackTrace) { - emit(AsyncValue.error(e, stackTrace)); + } on Exception catch (e, st) { + addError(e, st); + emit(AsyncValue.error(e, st)); } } @@ -37,11 +39,12 @@ abstract class AsyncTableDBBackedCubit extends Cubit> await _initWait(); try { emit(AsyncValue.data(await store(newState))); - } on Exception catch (e, stackTrace) { - emit(AsyncValue.error(e, stackTrace)); + } on Exception catch (e, st) { + addError(e, st); + emit(AsyncValue.error(e, st)); } } - final WaitSet _initWait = WaitSet(); - final Mutex _mutex = Mutex(); + final WaitSet _initWait = WaitSet(); + final Mutex _mutex = Mutex(debugLockTimeout: kIsDebugMode ? 60 : null); } diff --git a/packages/veilid_support/lib/src/config.dart b/packages/veilid_support/lib/src/config.dart index 889a8fd..6902479 100644 --- a/packages/veilid_support/lib/src/config.dart +++ b/packages/veilid_support/lib/src/config.dart @@ -1,22 +1,47 @@ import 'dart:io' show Platform; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; import 'package:veilid/veilid.dart'; -Map getDefaultVeilidPlatformConfig( - bool isWeb, String appName) { +// Allowed to pull sentinel value +// ignore: do_not_use_environment +const bool kIsReleaseMode = bool.fromEnvironment('dart.vm.product'); +// Allowed to pull sentinel value +// ignore: do_not_use_environment +const bool kIsProfileMode = bool.fromEnvironment('dart.vm.profile'); +const bool kIsDebugMode = !kIsReleaseMode && !kIsProfileMode; + +Future> getDefaultVeilidPlatformConfig( + bool isWeb, String appName) async { final ignoreLogTargetsStr = + // Allowed to change settings // ignore: do_not_use_environment const String.fromEnvironment('IGNORE_LOG_TARGETS').trim(); final ignoreLogTargets = ignoreLogTargetsStr.isEmpty ? [] : ignoreLogTargetsStr.split(',').map((e) => e.trim()).toList(); + // Allowed to change settings + // ignore: do_not_use_environment + var flamePathStr = const String.fromEnvironment('FLAME').trim(); + if (flamePathStr == '1') { + flamePathStr = p.join( + (await getApplicationSupportDirectory()).absolute.path, + '$appName.folded'); + // Allowed for debugging + // ignore: avoid_print + print('Flame data logged to $flamePathStr'); + } + if (isWeb) { return VeilidWASMConfig( logging: VeilidWASMConfigLogging( performance: VeilidWASMConfigLoggingPerformance( enabled: true, - level: VeilidConfigLogLevel.debug, + level: kIsDebugMode + ? VeilidConfigLogLevel.debug + : VeilidConfigLogLevel.info, logsInTimings: true, logsInConsole: false, ignoreLogTargets: ignoreLogTargets), @@ -30,7 +55,9 @@ Map getDefaultVeilidPlatformConfig( logging: VeilidFFIConfigLogging( terminal: VeilidFFIConfigLoggingTerminal( enabled: false, - level: VeilidConfigLogLevel.debug, + level: kIsDebugMode + ? VeilidConfigLogLevel.debug + : VeilidConfigLogLevel.info, ignoreLogTargets: ignoreLogTargets), otlp: VeilidFFIConfigLoggingOtlp( enabled: false, @@ -41,7 +68,9 @@ Map getDefaultVeilidPlatformConfig( api: VeilidFFIConfigLoggingApi( enabled: true, level: VeilidConfigLogLevel.info, - ignoreLogTargets: ignoreLogTargets))) + ignoreLogTargets: ignoreLogTargets), + flame: VeilidFFIConfigLoggingFlame( + enabled: flamePathStr.isNotEmpty, path: flamePathStr))) .toJson(); } @@ -49,30 +78,37 @@ Future getVeilidConfig(bool isWeb, String programName) async { var config = await getDefaultVeilidConfig( isWeb: isWeb, programName: programName, + // Allowed to change settings // ignore: avoid_redundant_argument_values, do_not_use_environment namespace: const String.fromEnvironment('NAMESPACE'), + // Allowed to change settings // ignore: avoid_redundant_argument_values, do_not_use_environment bootstrap: const String.fromEnvironment('BOOTSTRAP'), + // Allowed to change settings // ignore: avoid_redundant_argument_values, do_not_use_environment networkKeyPassword: const String.fromEnvironment('NETWORK_KEY'), ); + // Allowed to change settings // ignore: do_not_use_environment if (const String.fromEnvironment('DELETE_TABLE_STORE') == '1') { config = config.copyWith(tableStore: config.tableStore.copyWith(delete: true)); } + // Allowed to change settings // ignore: do_not_use_environment if (const String.fromEnvironment('DELETE_PROTECTED_STORE') == '1') { config = config.copyWith( protectedStore: config.protectedStore.copyWith(delete: true)); } + // Allowed to change settings // ignore: do_not_use_environment if (const String.fromEnvironment('DELETE_BLOCK_STORE') == '1') { config = config.copyWith(blockStore: config.blockStore.copyWith(delete: true)); } + // Allowed to change settings // ignore: do_not_use_environment const envNetwork = String.fromEnvironment('NETWORK'); if (envNetwork.isNotEmpty) { @@ -87,10 +123,12 @@ Future getVeilidConfig(bool isWeb, String programName) async { return config.copyWith( capabilities: - // XXX: Remove DHTV and DHTW when we get background sync implemented + // XXX: Remove DHTV and DHTW after DHT widening (and maybe remote + // rehydration?) const VeilidConfigCapabilities(disable: ['DHTV', 'DHTW', 'TUNL']), protectedStore: // XXX: Linux often does not have a secret storage mechanism installed - config.protectedStore.copyWith(allowInsecureFallback: Platform.isLinux), + config.protectedStore + .copyWith(allowInsecureFallback: !isWeb && Platform.isLinux), ); } diff --git a/packages/veilid_support/lib/src/dynamic_debug.dart b/packages/veilid_support/lib/src/dynamic_debug.dart new file mode 100644 index 0000000..1c38d96 --- /dev/null +++ b/packages/veilid_support/lib/src/dynamic_debug.dart @@ -0,0 +1,130 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:convert/convert.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; + +import 'online_element_state.dart'; + +typedef ToDebugFunction = dynamic Function(dynamic protoObj); + +// This should be implemented to add toDebug capability +// ignore: one_member_abstracts +abstract class ToDebugMap { + Map toDebugMap(); +} + +// We explicitly want this class to avoid having a global function 'toDebug' +// ignore: avoid_classes_with_only_static_members +class DynamicDebug { + /// Add a 'toDebug' handler to the chain + static void registerToDebug(ToDebugFunction toDebugFunction) { + final _oldToDebug = _toDebug; + _toDebug = (obj) => _oldToDebug(toDebugFunction(obj)); + } + + /// Convert a type to a debug version of the same type that + /// has a better `toString` representation and possibly other extra debug + /// information + static dynamic toDebug(dynamic obj) { + try { + return _toDebug(obj); + // In this case we watch to catch everything + // because toDebug need to never fail + // ignore: avoid_catches_without_on_clauses + } catch (e) { + // Ensure this gets printed, but continue + // ignore: avoid_print + print('Exception in toDebug: $e'); + return obj.toString(); + } + } + + ////////////////////////////////////////////////////////////// + static dynamic _baseToDebug(dynamic obj) { + if (obj is AsyncValue) { + if (obj.isLoading) { + return {r'$runtimeType': obj.runtimeType, 'loading': null}; + } + if (obj.isError) { + return { + r'$runtimeType': obj.runtimeType, + 'error': toDebug(obj.asError!.error), + 'stackTrace': toDebug(obj.asError!.stackTrace), + }; + } + if (obj.isData) { + return { + r'$runtimeType': obj.runtimeType, + 'data': toDebug(obj.asData!.value), + }; + } + return obj.toString(); + } + if (obj is IMap) { + // Handled by Map + return _baseToDebug(obj.unlockView); + } + if (obj is IMapOfSets) { + // Handled by Map + return _baseToDebug(obj.unlock); + } + if (obj is ISet) { + // Handled by Iterable + return _baseToDebug(obj.unlockView); + } + if (obj is IList) { + return _baseToDebug(obj.unlockView); + } + if (obj is BlocBusyState) { + return { + r'$runtimeType': obj.runtimeType, + 'busy': obj.busy, + 'state': toDebug(obj.state), + }; + } + if (obj is OnlineElementState) { + return { + r'$runtimeType': obj.runtimeType, + 'isOffline': obj.isOffline, + 'value': toDebug(obj.value), + }; + } + if (obj is List) { + try { + // Do bytes as a hex string for brevity and clarity + return 'List: ${hex.encode(obj)}'; + // One has to be able to catch this + // ignore: avoid_catching_errors + } on RangeError { + // Otherwise directly convert as list of integers + return obj.toString(); + } + } + if (obj is Map) { + return obj.map((k, v) => MapEntry(toDebug(k), toDebug(v))); + } + if (obj is Iterable) { + return obj.map(toDebug).toList(); + } + if (obj is String || obj is bool || obj is num || obj == null) { + return obj; + } + if (obj is ToDebugMap) { + // Handled by Map + return _baseToDebug(obj.toDebugMap()); + } + + try { + // Let's try convering to a json object + // ignore: avoid_dynamic_calls + return obj.toJson(); + + // No matter how this fails, we shouldn't throw + // ignore: avoid_catches_without_on_clauses + } catch (_) {} + + return obj.toString(); + } + + static ToDebugFunction _toDebug = _baseToDebug; +} diff --git a/packages/veilid_support/lib/src/json_tools.dart b/packages/veilid_support/lib/src/json_tools.dart index c5895d0..dd0f20b 100644 --- a/packages/veilid_support/lib/src/json_tools.dart +++ b/packages/veilid_support/lib/src/json_tools.dart @@ -12,16 +12,19 @@ Uint8List jsonEncodeBytes(Object? object, Uint8List.fromList( utf8.encode(jsonEncode(object, toEncodable: toEncodable))); -Future jsonUpdateBytes(T Function(dynamic) fromJson, - Uint8List? oldBytes, Future Function(T?) update) async { +Future jsonUpdateBytes(T Function(dynamic) fromJson, + Uint8List? oldBytes, Future Function(T?) update) async { final oldObj = oldBytes == null ? null : fromJson(jsonDecode(utf8.decode(oldBytes))); final newObj = await update(oldObj); + if (newObj == null) { + return null; + } return jsonEncodeBytes(newObj); } -Future Function(Uint8List?) jsonUpdate( - T Function(dynamic) fromJson, Future Function(T?) update) => +Future Function(Uint8List?) jsonUpdate( + T Function(dynamic) fromJson, Future Function(T?) update) => (oldBytes) => jsonUpdateBytes(fromJson, oldBytes, update); T Function(Object?) genericFromJson( diff --git a/packages/veilid_support/lib/src/persistent_queue.dart b/packages/veilid_support/lib/src/persistent_queue.dart index f0cf17a..efb4c86 100644 --- a/packages/veilid_support/lib/src/persistent_queue.dart +++ b/packages/veilid_support/lib/src/persistent_queue.dart @@ -2,26 +2,32 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:async_tools/async_tools.dart'; +import 'package:buffer/buffer.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:protobuf/protobuf.dart'; +import 'config.dart'; import 'table_db.dart'; +import 'veilid_log.dart'; -class PersistentQueue - /*extends Cubit>>*/ with - TableDBBackedFromBuffer> { +const _ksfSyncAdd = 'ksfSyncAdd'; + +class PersistentQueue with TableDBBackedFromBuffer> { // PersistentQueue( {required String table, required String key, required T Function(Uint8List) fromBuffer, + required Uint8List Function(T) toBuffer, required Future Function(IList) closure, - bool deleteOnClose = true}) + bool deleteOnClose = false, + void Function(Object, StackTrace)? onError}) : _table = table, _key = key, _fromBuffer = fromBuffer, + _toBuffer = toBuffer, _closure = closure, - _deleteOnClose = deleteOnClose { + _deleteOnClose = deleteOnClose, + _onError = onError { _initWait.add(_init); } @@ -29,13 +35,14 @@ class PersistentQueue // Ensure the init finished await _initWait(); - // Close the sync add stream - await _syncAddController.close(); + // Finish all sync adds + await serialFutureClose((this, _ksfSyncAdd)); // Stop the processing trigger + await _sspQueueReady.close(); await _queueReady.close(); - // Wait for any setStates to finish + // No more queue actions await _queueMutex.acquire(); // Clean up table if desired @@ -44,34 +51,62 @@ class PersistentQueue } } - Future _init() async { + set deleteOnClose(bool d) { + _deleteOnClose = d; + } + + bool get deleteOnClose => _deleteOnClose; + + Future get waitEmpty async { + // Ensure the init finished + await _initWait(); + + if (_queue.isEmpty) { + return; + } + final completer = Completer(); + _queueDoneCompleter = completer; + await completer.future; + } + + Future _init(Completer _) async { // Start the processor - unawaited(Future.delayed(Duration.zero, () async { + _sspQueueReady.follow(_queueReady.stream, true, (more) async { await _initWait(); - await for (final _ in _queueReady.stream) { + if (more) { await _process(); } - })); - - // Start the sync add controller - unawaited(Future.delayed(Duration.zero, () async { - await _initWait(); - await for (final elem in _syncAddController.stream) { - await addAll(elem); - } - })); + }); // Load the queue if we have one - await _queueMutex.protect(() async { - _queue = await load() ?? await store(IList.empty()); - }); + try { + await _queueMutex.protect(() async { + _queue = await load() ?? await store(IList.empty()); + _sendUpdateEventsInner(); + }); + } on Exception catch (e, st) { + if (_onError != null) { + _onError(e, st); + } else { + rethrow; + } + } + } + + void _sendUpdateEventsInner() { + assert(_queueMutex.isLocked, 'must be locked'); + if (_queue.isNotEmpty) { + if (!_queueReady.isClosed) { + _queueReady.sink.add(true); + } + } else { + _queueDoneCompleter?.complete(); + } } Future _updateQueueInner(IList newQueue) async { _queue = await store(newQueue); - if (_queue.isNotEmpty) { - _queueReady.sink.add(null); - } + _sendUpdateEventsInner(); } Future add(T item) async { @@ -91,66 +126,52 @@ class PersistentQueue } void addSync(T item) { - _syncAddController.sink.add([item]); + serialFuture((this, _ksfSyncAdd), () async { + await add(item); + }); } void addAllSync(Iterable items) { - _syncAddController.sink.add(items); + serialFuture((this, _ksfSyncAdd), () async { + await addAll(items); + }); } - // Future get isEmpty async { - // await _initWait(); - // return state.asData!.value.isEmpty; - // } + Future pause() async { + await _sspQueueReady.pause(); + } - // Future get isNotEmpty async { - // await _initWait(); - // return state.asData!.value.isNotEmpty; - // } - - // Future get length async { - // await _initWait(); - // return state.asData!.value.length; - // } - - // Future pop() async { - // await _initWait(); - // return _processingMutex.protect(() async => _stateMutex.protect(() async { - // final removedItem = Output(); - // final queue = state.asData!.value.removeAt(0, removedItem); - // await _setStateInner(queue); - // return removedItem.value; - // })); - // } - - // Future> popAll() async { - // await _initWait(); - // return _processingMutex.protect(() async => _stateMutex.protect(() async { - // final queue = state.asData!.value; - // await _setStateInner(IList.empty); - // return queue; - // })); - // } + Future resume() async { + await _sspQueueReady.resume(); + } Future _process() async { - // Take a copy of the current queue - // (doesn't need queue mutex because this is a sync operation) - final toProcess = _queue; - final processCount = toProcess.length; - if (processCount == 0) { - return; + try { + // Take a copy of the current queue + // (doesn't need queue mutex because this is a sync operation) + final toProcess = _queue; + final processCount = toProcess.length; + if (processCount == 0) { + return; + } + + // Run the processing closure + await _closure(toProcess); + + // If there was no exception, remove the processed items + await _queueMutex.protect(() async { + // Get the queue from the state again as items could + // have been added during processing + final newQueue = _queue.skip(processCount).toIList(); + await _updateQueueInner(newQueue); + }); + } on Exception catch (e, sp) { + if (_onError != null) { + _onError(e, sp); + } else { + rethrow; + } } - - // Run the processing closure - await _closure(toProcess); - - // If there was no exception, remove the processed items - await _queueMutex.protect(() async { - // Get the queue from the state again as items could - // have been added during processing - final newQueue = _queue.skip(processCount).toIList(); - await _updateQueueInner(newQueue); - }); } IList get queue => _queue; @@ -164,31 +185,58 @@ class PersistentQueue @override IList valueFromBuffer(Uint8List bytes) { - final reader = CodedBufferReader(bytes); var out = IList(); - while (!reader.isAtEnd()) { - out = out.add(_fromBuffer(reader.readBytesAsView())); + try { + final reader = ByteDataReader()..add(bytes); + while (reader.remainingLength != 0) { + final count = reader.readUint32(); + final bytes = reader.read(count); + try { + final item = _fromBuffer(bytes); + out = out.add(item); + } on Exception catch (e, st) { + veilidLoggy.debug( + 'Dropping invalid item from persistent queue: $bytes\n' + 'tableName=${tableName()}:tableKeyName=${tableKeyName()}\n', + e, + st); + } + } + } on Exception catch (e, st) { + veilidLoggy.debug( + 'Dropping remainder of invalid persistent queue\n' + 'tableName=${tableName()}:tableKeyName=${tableKeyName()}\n', + e, + st); } return out; } @override Uint8List valueToBuffer(IList val) { - final writer = CodedBufferWriter(); + final writer = ByteDataWriter(); for (final elem in val) { - writer.writeRawBytes(elem.writeToBuffer()); + final bytes = _toBuffer(elem); + final count = bytes.lengthInBytes; + writer + ..writeUint32(count) + ..write(bytes); } - return writer.toBuffer(); + return writer.toBytes(); } final String _table; final String _key; final T Function(Uint8List) _fromBuffer; - final bool _deleteOnClose; - final WaitSet _initWait = WaitSet(); - final Mutex _queueMutex = Mutex(); - IList _queue = IList.empty(); - final StreamController> _syncAddController = StreamController(); - final StreamController _queueReady = StreamController(); + final Uint8List Function(T) _toBuffer; + bool _deleteOnClose; + final WaitSet _initWait = WaitSet(); + final _queueMutex = Mutex(debugLockTimeout: kIsDebugMode ? 60 : null); + var _queue = IList.empty(); final Future Function(IList) _closure; + final void Function(Object, StackTrace)? _onError; + Completer? _queueDoneCompleter; + + final StreamController _queueReady = StreamController(); + final _sspQueueReady = SingleStateProcessor(); } diff --git a/packages/veilid_support/lib/src/protobuf_tools.dart b/packages/veilid_support/lib/src/protobuf_tools.dart index 94dc6d1..0120e06 100644 --- a/packages/veilid_support/lib/src/protobuf_tools.dart +++ b/packages/veilid_support/lib/src/protobuf_tools.dart @@ -2,16 +2,19 @@ import 'dart:typed_data'; import 'package:protobuf/protobuf.dart'; -Future protobufUpdateBytes( +Future protobufUpdateBytes( T Function(List) fromBuffer, Uint8List? oldBytes, - Future Function(T?) update) async { + Future Function(T?) update) async { final oldObj = oldBytes == null ? null : fromBuffer(oldBytes); final newObj = await update(oldObj); + if (newObj == null) { + return null; + } return Uint8List.fromList(newObj.writeToBuffer()); } -Future Function(Uint8List?) +Future Function(Uint8List?) protobufUpdate( - T Function(List) fromBuffer, Future Function(T?) update) => + T Function(List) fromBuffer, Future Function(T?) update) => (oldBytes) => protobufUpdateBytes(fromBuffer, oldBytes, update); diff --git a/packages/veilid_support/lib/src/table_db.dart b/packages/veilid_support/lib/src/table_db.dart index 773309b..eb7fe99 100644 --- a/packages/veilid_support/lib/src/table_db.dart +++ b/packages/veilid_support/lib/src/table_db.dart @@ -6,6 +6,9 @@ import 'package:async_tools/async_tools.dart'; import 'package:meta/meta.dart'; import 'package:veilid/veilid.dart'; +import '../veilid_support.dart'; +import 'veilid_log.dart'; + Future tableScope( String name, Future Function(VeilidTableDB tdb) callback, {int columnCount = 1}) async { @@ -48,11 +51,20 @@ abstract mixin class TableDBBackedJson { /// Load things from storage @protected Future load() async { - final obj = await tableScope(tableName(), (tdb) async { - final objJson = await tdb.loadStringJson(0, tableKeyName()); - return valueFromJson(objJson); - }); - return obj; + try { + final obj = await tableScope(tableName(), (tdb) async { + final objJson = await tdb.loadStringJson(0, tableKeyName()); + return valueFromJson(objJson); + }); + return obj; + } on Exception catch (e, st) { + veilidLoggy.debug( + 'Unable to load data from table store: ' + '${tableName()}:${tableKeyName()}', + e, + st); + return null; + } } /// Store things to storage @@ -134,7 +146,7 @@ class TableDBValue extends TableDBBackedJson { _tableKeyName = tableKeyName, _makeInitialValue = makeInitialValue, _streamController = StreamController.broadcast() { - _initWait.add(() async { + _initWait.add((_) async { await get(); }); } @@ -172,7 +184,7 @@ class TableDBValue extends TableDBBackedJson { final T? Function(Object? obj) _valueFromJson; final Object? Function(T? obj) _valueToJson; final StreamController _streamController; - final WaitSet _initWait = WaitSet(); + final WaitSet _initWait = WaitSet(); ////////////////////////////////////////////////////////////// /// AsyncTableDBBacked diff --git a/packages/veilid_support/lib/src/table_db_array.dart b/packages/veilid_support/lib/src/table_db_array.dart index ad4c586..53adeb0 100644 --- a/packages/veilid_support/lib/src/table_db_array.dart +++ b/packages/veilid_support/lib/src/table_db_array.dart @@ -9,6 +9,7 @@ import 'package:meta/meta.dart'; import 'package:protobuf/protobuf.dart'; import '../veilid_support.dart'; +import 'veilid_log.dart'; @immutable class TableDBArrayUpdate extends Equatable { @@ -45,7 +46,7 @@ class _TableDBArrayBase { await _initWait(); } - Future _init() async { + Future _init(Completer _) async { // Load the array details await _mutex.protect(() async { _tableDB = await Veilid.instance.openTableDB(_table, 1); @@ -101,27 +102,27 @@ class _TableDBArrayBase { Future _add(Uint8List value) async { await _initWait(); - return _writeTransaction((t) async => _addInner(t, value)); + return _writeTransaction((t) => _addInner(t, value)); } Future _addAll(List values) async { await _initWait(); - return _writeTransaction((t) async => _addAllInner(t, values)); + return _writeTransaction((t) => _addAllInner(t, values)); } Future _insert(int pos, Uint8List value) async { await _initWait(); - return _writeTransaction((t) async => _insertInner(t, pos, value)); + return _writeTransaction((t) => _insertInner(t, pos, value)); } Future _insertAll(int pos, List values) async { await _initWait(); - return _writeTransaction((t) async => _insertAllInner(t, pos, values)); + return _writeTransaction((t) => _insertAllInner(t, pos, values)); } Future _get(int pos) async { await _initWait(); - return _mutex.protect(() async { + return _mutex.protect(() { if (!_open) { throw StateError('not open'); } @@ -131,7 +132,7 @@ class _TableDBArrayBase { Future> _getRange(int start, [int? end]) async { await _initWait(); - return _mutex.protect(() async { + return _mutex.protect(() { if (!_open) { throw StateError('not open'); } @@ -141,14 +142,13 @@ class _TableDBArrayBase { Future _remove(int pos, {Output? out}) async { await _initWait(); - return _writeTransaction((t) async => _removeInner(t, pos, out: out)); + return _writeTransaction((t) => _removeInner(t, pos, out: out)); } Future _removeRange(int start, int end, {Output>? out}) async { await _initWait(); - return _writeTransaction( - (t) async => _removeRangeInner(t, start, end, out: out)); + return _writeTransaction((t) => _removeRangeInner(t, start, end, out: out)); } Future clear() async { @@ -259,10 +259,19 @@ class _TableDBArrayBase { for (var pos = start; pos < end;) { var batchLen = min(batchSize, end - pos); - final dws = DelayedWaitSet(); + final dws = DelayedWaitSet(); while (batchLen > 0) { final entry = await _getIndexEntry(pos); - dws.add(() async => (await _loadEntry(entry))!); + dws.add((_) async { + try { + return (await _loadEntry(entry))!; + // Need some way to debug ParallelWaitError + // ignore: avoid_catches_without_on_clauses + } catch (e, st) { + veilidLoggy.error('$e\n$st\n'); + rethrow; + } + }); pos++; batchLen--; } @@ -321,24 +330,24 @@ class _TableDBArrayBase { //////////////////////////////////////////////////////////// // Private implementation - static final Uint8List _headKey = Uint8List.fromList([$_, $H, $E, $A, $D]); + static final _headKey = Uint8List.fromList([$_, $H, $E, $A, $D]); static Uint8List _entryKey(int k) => (ByteData(4)..setUint32(0, k)).buffer.asUint8List(); static Uint8List _chunkKey(int n) => (ByteData(2)..setUint16(0, n)).buffer.asUint8List(); Future _writeTransaction( - Future Function(VeilidTableDBTransaction) closure) async => + Future Function(VeilidTableDBTransaction) closure) => _mutex.protect(() async { if (!_open) { throw StateError('not open'); } - final _oldLength = _length; - final _oldNextFree = _nextFree; - final _oldMaxEntry = _maxEntry; - final _oldHeadDelta = _headDelta; - final _oldTailDelta = _tailDelta; + final oldLength = _length; + final oldNextFree = _nextFree; + final oldMaxEntry = _maxEntry; + final oldHeadDelta = _headDelta; + final oldTailDelta = _tailDelta; try { final out = await transactionScope(_tableDB, (t) async { final out = await closure(t); @@ -355,11 +364,11 @@ class _TableDBArrayBase { return out; } on Exception { // restore head - _length = _oldLength; - _nextFree = _oldNextFree; - _maxEntry = _oldMaxEntry; - _headDelta = _oldHeadDelta; - _tailDelta = _oldTailDelta; + _length = oldLength; + _nextFree = oldNextFree; + _maxEntry = oldMaxEntry; + _headDelta = oldHeadDelta; + _tailDelta = oldTailDelta; // invalidate caches because they could have been written to _chunkCache.clear(); _dirtyChunks.clear(); @@ -405,7 +414,7 @@ class _TableDBArrayBase { _dirtyChunks[chunkNumber] = chunk; } - Future _insertIndexEntry(int pos) async => _insertIndexEntries(pos, 1); + Future _insertIndexEntry(int pos) => _insertIndexEntries(pos, 1); Future _insertIndexEntries(int start, int length) async { if (length == 0) { @@ -464,7 +473,7 @@ class _TableDBArrayBase { _tailDelta += length; } - Future _removeIndexEntry(int pos) async => _removeIndexEntries(pos, 1); + Future _removeIndexEntry(int pos) => _removeIndexEntries(pos, 1); Future _removeIndexEntries(int start, int length) async { if (length == 0) { @@ -613,21 +622,21 @@ class _TableDBArrayBase { var _open = true; var _initDone = false; final VeilidCrypto _crypto; - final WaitSet _initWait = WaitSet(); - final Mutex _mutex = Mutex(); + final WaitSet _initWait = WaitSet(); + final _mutex = Mutex(debugLockTimeout: kIsDebugMode ? 60 : null); // Change tracking - int _headDelta = 0; - int _tailDelta = 0; + var _headDelta = 0; + var _tailDelta = 0; // Head state - int _length = 0; - int _nextFree = 0; - int _maxEntry = 0; - static const int _indexStride = 16384; + var _length = 0; + var _nextFree = 0; + var _maxEntry = 0; + static const _indexStride = 16384; final List<(int, Uint8List)> _chunkCache = []; final Map _dirtyChunks = {}; - static const int _chunkCacheLength = 3; + static const _chunkCacheLength = 3; final StreamController _changeStream = StreamController.broadcast(); @@ -701,13 +710,12 @@ class TableDBArrayJson extends _TableDBArrayBase { Future add(T value) => _add(jsonEncodeBytes(value)); - Future addAll(List values) async => + Future addAll(List values) => _addAll(values.map(jsonEncodeBytes).toList()); - Future insert(int pos, T value) async => - _insert(pos, jsonEncodeBytes(value)); + Future insert(int pos, T value) => _insert(pos, jsonEncodeBytes(value)); - Future insertAll(int pos, List values) async => + Future insertAll(int pos, List values) => _insertAll(pos, values.map(jsonEncodeBytes).toList()); Future get( @@ -764,13 +772,12 @@ class TableDBArrayProtobuf Future add(T value) => _add(value.writeToBuffer()); - Future addAll(List values) async => + Future addAll(List values) => _addAll(values.map((x) => x.writeToBuffer()).toList()); - Future insert(int pos, T value) async => - _insert(pos, value.writeToBuffer()); + Future insert(int pos, T value) => _insert(pos, value.writeToBuffer()); - Future insertAll(int pos, List values) async => + Future insertAll(int pos, List values) => _insertAll(pos, values.map((x) => x.writeToBuffer()).toList()); Future get( diff --git a/packages/veilid_support/lib/src/table_db_array_protobuf_cubit.dart b/packages/veilid_support/lib/src/table_db_array_protobuf_cubit.dart index 606ded5..92b920f 100644 --- a/packages/veilid_support/lib/src/table_db_array_protobuf_cubit.dart +++ b/packages/veilid_support/lib/src/table_db_array_protobuf_cubit.dart @@ -24,7 +24,7 @@ class TableDBArrayProtobufStateData final IList windowElements; // The length of the entire array final int length; - // One past the end of the last element + // One past the end of the last element (modulo length, can be zero) final int windowTail; // The total number of elements to try to keep in 'elements' final int windowCount; @@ -46,7 +46,7 @@ class TableDBArrayProtobufCubit TableDBArrayProtobufCubit({ required Future> Function() open, }) : super(const BlocBusyState(AsyncValue.loading())) { - _initWait.add(() async { + _initWait.add((_) async { // Open table db array _array = await open(); _wantsCloseArray = true; @@ -92,6 +92,7 @@ class TableDBArrayProtobufCubit final avElements = await _loadElements(_tail, _count); final err = avElements.asError; if (err != null) { + addError(err.error, err.stackTrace); emit(AsyncValue.error(err.error, err.stackTrace)); return; } @@ -123,6 +124,7 @@ class TableDBArrayProtobufCubit final allItems = (await _array.getRange(start, end)).toIList(); return AsyncValue.data(allItems); } on Exception catch (e, st) { + addError(e, st); return AsyncValue.error(e, st); } } @@ -180,7 +182,7 @@ class TableDBArrayProtobufCubit return closure(_array); } - final WaitSet _initWait = WaitSet(); + final WaitSet _initWait = WaitSet(); late final TableDBArrayProtobuf _array; StreamSubscription? _subscription; bool _wantsCloseArray = false; diff --git a/packages/veilid_support/lib/src/veilid_log.dart b/packages/veilid_support/lib/src/veilid_log.dart index 0007754..4c9ad0b 100644 --- a/packages/veilid_support/lib/src/veilid_log.dart +++ b/packages/veilid_support/lib/src/veilid_log.dart @@ -57,23 +57,19 @@ void processLog(VeilidLog log) { switch (log.logLevel) { case VeilidLogLevel.error: veilidLoggy.error(log.message, error, stackTrace); - break; case VeilidLogLevel.warn: veilidLoggy.warning(log.message, error, stackTrace); - break; case VeilidLogLevel.info: veilidLoggy.info(log.message, error, stackTrace); - break; case VeilidLogLevel.debug: veilidLoggy.debug(log.message, error, stackTrace); - break; case VeilidLogLevel.trace: veilidLoggy.trace(log.message, error, stackTrace); - break; } } void initVeilidLog(bool debugMode) { + // Always allow LOG_TRACE option // ignore: do_not_use_environment const isTrace = String.fromEnvironment('LOG_TRACE') != ''; LogLevel logLevel; diff --git a/packages/veilid_support/lib/veilid_support.dart b/packages/veilid_support/lib/veilid_support.dart index f48376f..2f4da90 100644 --- a/packages/veilid_support/lib/veilid_support.dart +++ b/packages/veilid_support/lib/veilid_support.dart @@ -1,13 +1,14 @@ /// Dart Veilid Support Library /// Common functionality for interfacing with Veilid -library veilid_support; +library; export 'package:veilid/veilid.dart'; export 'dht_support/dht_support.dart'; export 'identity_support/identity_support.dart'; export 'src/config.dart'; +export 'src/dynamic_debug.dart'; export 'src/json_tools.dart'; export 'src/memory_tools.dart'; export 'src/online_element_state.dart'; diff --git a/packages/veilid_support/pubspec.lock b/packages/veilid_support/pubspec.lock index f74ee28..0d2320e 100644 --- a/packages/veilid_support/pubspec.lock +++ b/packages/veilid_support/pubspec.lock @@ -5,112 +5,122 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f url: "https://pub.dev" source: hosted - version: "67.0.0" + version: "82.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0" url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "7.4.5" args: dependency: transitive description: name: args - sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.7.0" async: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.13.0" async_tools: dependency: "direct main" description: - path: "../../../dart_async_tools" - relative: true - source: path - version: "0.1.1" + name: async_tools + sha256: "9611c1efeae7e6d342721d0c2caf2e4783d91fba6a9637d7badfa2dccf8de2a2" + url: "https://pub.dev" + source: hosted + version: "0.1.10" bloc: dependency: "direct main" description: name: bloc - sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e" + sha256: "52c10575f4445c61dd9e0cafcc6356fdd827c4c64dd7945ef3c4105f6b6ac189" url: "https://pub.dev" source: hosted - version: "8.1.4" + version: "9.0.0" bloc_advanced_tools: dependency: "direct main" description: - path: "../../../bloc_advanced_tools" - relative: true - source: path - version: "0.1.1" + name: bloc_advanced_tools + sha256: "63e57000df7259e3007dbfbbfd7dae3e0eca60eb2ac93cbe0c5a3de0e77c9972" + url: "https://pub.dev" + source: hosted + version: "0.1.13" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" + buffer: + dependency: "direct main" + description: + name: buffer + sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1" + url: "https://pub.dev" + source: hosted + version: "1.2.3" build: dependency: transitive description: name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" build_config: dependency: transitive description: name: build_config - sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" build_daemon: dependency: transitive description: name: build_daemon - sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" + sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.0.4" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.4" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "1414d6d733a85d8ad2f1dfcb3ea7945759e35a123cb99ccfac75d0758f75edfa" + sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" url: "https://pub.dev" source: hosted - version: "2.4.10" + version: "2.4.15" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: "4ae8ffe5ac758da294ecf1802f2aff01558d8b1b00616aa7538ea9a8a5d50799" + sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" url: "https://pub.dev" source: hosted - version: "7.3.0" + version: "8.0.0" built_collection: dependency: transitive description: @@ -123,34 +133,34 @@ packages: dependency: transitive description: name: built_value - sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb + sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4 url: "https://pub.dev" source: hosted - version: "8.9.2" + version: "8.9.5" change_case: dependency: transitive description: name: change_case - sha256: "47c48c36f95f20c6d0ba03efabceff261d05026cca322cc2c4c01c343371b5bb" + sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003 url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.2.0" characters: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" charcode: dependency: "direct main" description: name: charcode - sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -159,94 +169,102 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" code_builder: dependency: transitive description: name: code_builder - sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" url: "https://pub.dev" source: hosted - version: "4.10.0" + version: "4.10.1" collection: dependency: "direct main" description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.1" convert: - dependency: transitive + dependency: "direct main" description: name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" coverage: dependency: transitive description: name: coverage - sha256: "3945034e86ea203af7a056d98e98e42a5518fff200d6e8e6647e1886b07e936e" + sha256: "802bd084fb82e55df091ec8ad1553a7331b61c08251eef19a508b6f3f3a9858d" url: "https://pub.dev" source: hosted - version: "1.8.0" + version: "1.13.1" crypto: dependency: transitive description: name: crypto - sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.6" dart_style: dependency: transitive description: name: dart_style - sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" + sha256: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af" url: "https://pub.dev" source: hosted - version: "2.3.6" + version: "3.1.0" equatable: dependency: "direct main" description: name: equatable - sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" url: "https://pub.dev" source: hosted - version: "2.0.5" + version: "2.0.7" fast_immutable_collections: dependency: "direct main" description: name: fast_immutable_collections - sha256: "533806a7f0c624c2e479d05d3fdce4c87109a7cd0db39b8cc3830d3a2e8dedc7" + sha256: d1aa3d7788fab06cce7f303f4969c7a16a10c865e1bd2478291a8ebcbee084e5 url: "https://pub.dev" source: hosted - version: "10.2.3" + version: "11.0.4" ffi: dependency: transitive description: name: ffi - sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" file: dependency: transitive description: name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" fixnum: dependency: transitive description: name: fixnum - sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" flutter: dependency: transitive description: flutter @@ -261,18 +279,18 @@ packages: dependency: "direct dev" description: name: freezed - sha256: a434911f643466d78462625df76fd9eb13e57348ff43fe1f77bbe909522c67a1 + sha256: "6022db4c7bfa626841b2a10f34dd1e1b68e8f8f9650db6112dcdeeca45ca793c" url: "https://pub.dev" source: hosted - version: "2.5.2" + version: "3.0.6" freezed_annotation: dependency: "direct main" description: name: freezed_annotation - sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d + sha256: c87ff004c8aa6af2d531668b46a4ea379f7191dc6dfa066acd53d506da6e044b url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "3.0.0" frontend_server_client: dependency: transitive description: @@ -285,10 +303,10 @@ packages: dependency: transitive description: name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" globbing: dependency: transitive description: @@ -301,42 +319,58 @@ packages: dependency: transitive description: name: graphs - sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" + http: + dependency: transitive + description: + name: http + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + url: "https://pub.dev" + source: hosted + version: "1.4.0" http_multi_server: dependency: transitive description: name: http_multi_server - sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" http_parser: dependency: transitive description: name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.1.2" + indent: + dependency: "direct main" + description: + name: indent + sha256: "819319a5c185f7fe412750c798953378b37a0d0d32564ce33e7c5acfd1372d2a" + url: "https://pub.dev" + source: hosted + version: "2.0.0" io: dependency: transitive description: name: io - sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.5" js: dependency: transitive description: name: js - sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "0.7.2" json_annotation: dependency: "direct main" description: @@ -349,26 +383,26 @@ packages: dependency: "direct dev" description: name: json_serializable - sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c url: "https://pub.dev" source: hosted - version: "6.8.0" + version: "6.9.5" lint_hard: dependency: "direct dev" description: name: lint_hard - sha256: "44d15ec309b1a8e1aff99069df9dcb1597f49d5f588f32811ca28fb7b38c32fe" + sha256: "2073d4e83ac4e3f2b87cc615fff41abb5c2c5618e117edcd3d71f40f2186f4d5" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "6.1.1" logging: dependency: transitive description: name: logging - sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" loggy: dependency: "direct main" description: @@ -381,34 +415,34 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: "direct main" description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.16.0" mime: dependency: transitive description: name: mime - sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "2.0.0" node_preamble: dependency: transitive description: @@ -421,42 +455,42 @@ packages: dependency: transitive description: name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.0" path: - dependency: transitive + dependency: "direct main" description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider - sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.5" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: a248d8146ee5983446bf03ed5ea8f6533129a12b11f12057ad1b4a67a2b3b41d + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 url: "https://pub.dev" source: hosted - version: "2.2.4" + version: "2.2.17" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" path_provider_linux: dependency: transitive description: @@ -477,18 +511,18 @@ packages: dependency: transitive description: name: path_provider_windows - sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.3.0" platform: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -509,34 +543,34 @@ packages: dependency: "direct main" description: name: protobuf - sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" + sha256: "579fe5557eae58e3adca2e999e38f02441d8aa908703854a9e0a0f47fa857731" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "4.1.0" pub_semver: dependency: transitive description: name: pub_semver - sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" url: "https://pub.dev" source: hosted - version: "1.2.3" + version: "1.5.0" shelf: dependency: transitive description: name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.2" shelf_packages_handler: dependency: transitive description: @@ -549,95 +583,95 @@ packages: dependency: transitive description: name: shelf_static - sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.3" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_gen: dependency: transitive description: name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "2.0.0" source_helper: dependency: transitive description: name: source_helper - sha256: "6adebc0006c37dd63fe05bca0a929b99f06402fc95aa35bf36d67f5c06de01fd" + sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" url: "https://pub.dev" source: hosted - version: "1.3.4" + version: "1.3.5" source_map_stack_trace: dependency: transitive description: name: source_map_stack_trace - sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" source_maps: dependency: transitive description: name: source_maps - sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" url: "https://pub.dev" source: hosted - version: "0.10.12" + version: "0.10.13" source_span: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.1" stack_trace: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" stream_transform: dependency: transitive description: name: stream_transform - sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" string_scanner: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.4.1" system_info2: dependency: transitive description: @@ -650,58 +684,58 @@ packages: dependency: transitive description: name: system_info_plus - sha256: b915c811c6605b802f3988859bc2bb79c95f735762a75b5451741f7a2b949d1b + sha256: df94187e95527f9cb459e6a9f6e0b1ea20c157d8029bc233de34b3c1e17e1c48 url: "https://pub.dev" source: hosted - version: "0.0.5" + version: "0.0.6" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test: dependency: "direct dev" description: name: test - sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073" + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" url: "https://pub.dev" source: hosted - version: "1.25.2" + version: "1.26.2" test_api: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.6" test_core: dependency: transitive description: name: test_core - sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4" + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "0.6.11" timing: dependency: transitive description: name: timing - sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.2" typed_data: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.4.0" vector_math: dependency: transitive description: @@ -716,39 +750,47 @@ packages: path: "../../../veilid/veilid-flutter" relative: true source: path - version: "0.3.2" + version: "0.4.6" vm_service: dependency: transitive description: name: vm_service - sha256: "7475cb4dd713d57b6f7464c0e13f06da0d535d8b2067e188962a59bac2cf280b" + sha256: "6f82e9ee8e7339f5d8b699317f6f3afc17c80a68ebef1bc0d6f52a678c14b1e6" url: "https://pub.dev" source: hosted - version: "14.2.2" + version: "15.0.1" watcher: dependency: transitive description: name: watcher - sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" web: dependency: transitive description: name: web - sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 url: "https://pub.dev" source: hosted - version: "2.4.5" + version: "3.0.3" webkit_inspection_protocol: dependency: transitive description: @@ -757,30 +799,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" - win32: - dependency: transitive - description: - name: win32 - sha256: a79dbe579cb51ecd6d30b17e0cae4e0ea15e2c0e66f69ad4198f22a6789e94f4 - url: "https://pub.dev" - source: hosted - version: "5.5.1" xdg_directories: dependency: transitive description: name: xdg_directories - sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.1.0" yaml: dependency: transitive description: name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.3" sdks: - dart: ">=3.4.0 <4.0.0" - flutter: ">=3.19.1" + dart: ">=3.7.0 <4.0.0" + flutter: ">=3.27.0" diff --git a/packages/veilid_support/pubspec.yaml b/packages/veilid_support/pubspec.yaml index 49c5325..5fdb74b 100644 --- a/packages/veilid_support/pubspec.yaml +++ b/packages/veilid_support/pubspec.yaml @@ -1,38 +1,43 @@ name: veilid_support description: Veilid Support Library -publish_to: 'none' +publish_to: "none" version: 1.0.2+0 environment: - sdk: '>=3.2.0 <4.0.0' + sdk: ">=3.2.0 <4.0.0" dependencies: - async_tools: ^0.1.2 - bloc: ^8.1.4 - bloc_advanced_tools: ^0.1.2 - charcode: ^1.3.1 - collection: ^1.18.0 - equatable: ^2.0.5 - fast_immutable_collections: ^10.2.3 - freezed_annotation: ^2.4.1 + async_tools: ^0.1.10 + bloc: ^9.0.0 + bloc_advanced_tools: ^0.1.13 + buffer: ^1.2.3 + charcode: ^1.4.0 + collection: ^1.19.1 + convert: ^3.1.2 + equatable: ^2.0.7 + fast_immutable_collections: ^11.0.3 + freezed_annotation: ^3.0.0 + indent: ^2.0.0 json_annotation: ^4.9.0 loggy: ^2.0.3 - meta: ^1.12.0 + meta: ^1.16.0 - protobuf: ^3.1.0 + path: ^1.9.1 + path_provider: ^2.1.5 + protobuf: ^4.1.0 veilid: # veilid: ^0.0.1 path: ../../../veilid/veilid-flutter # dependency_overrides: -# async_tools: +# async_tools: # path: ../../../dart_async_tools -# bloc_advanced_tools: +# bloc_advanced_tools: # path: ../../../bloc_advanced_tools dev_dependencies: - build_runner: ^2.4.10 - freezed: ^2.5.2 - json_serializable: ^6.8.0 - lint_hard: ^4.0.0 - test: ^1.25.2 + build_runner: ^2.4.15 + freezed: ^3.0.4 + json_serializable: ^6.9.4 + lint_hard: ^6.0.0 + test: ^1.25.15 diff --git a/process_flame.sh b/process_flame.sh new file mode 100755 index 0000000..8f03418 --- /dev/null +++ b/process_flame.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cat "/Users/$USER/Library/Containers/com.veilid.veilidchat/Data/Library/Application Support/com.veilid.veilidchat/VeilidChat.folded" | inferno-flamegraph -c purple --fontsize 8 --height 24 --title "VeilidChat" --factor 0.000000001 --countname secs > /tmp/veilidchat.svg +cat "/Users/$USER/Library/Containers/com.veilid.veilidchat/Data/Library/Application Support/com.veilid.veilidchat/VeilidChat.folded" | inferno-flamegraph --reverse -c aqua --fontsize 8 --height 24 --title "VeilidChat Reverse" --factor 0.000000001 --countname secs > /tmp/veilidchat-reverse.svg diff --git a/pubspec.lock b/pubspec.lock index 03260fe..95c1262 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,50 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f url: "https://pub.dev" source: hosted - version: "67.0.0" + version: "82.0.0" + accordion: + dependency: "direct main" + description: + name: accordion + sha256: "0eca3d1c619c6df63d6e384010fd2ef1164e7385d7102f88a6b56f658f160cd0" + url: "https://pub.dev" + source: hosted + version: "2.6.0" analyzer: dependency: transitive description: name: analyzer - sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0" url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "7.4.5" + animated_bottom_navigation_bar: + dependency: "direct main" + description: + name: animated_bottom_navigation_bar + sha256: "94971fdfd53acd443acd0d17ce1cb5219ad833f20c75b50c55b205e54a5d6117" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + animated_custom_dropdown: + dependency: "direct main" + description: + name: animated_custom_dropdown + sha256: "5a72dc209041bb53f6c7164bc2e366552d5197cdb032b1c9b2c36e3013024486" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + animated_switcher_transitions: + dependency: "direct main" + description: + name: animated_switcher_transitions + sha256: "0f3ef1b46ab3f0b5efe784dcff55bbeabdc75a3b9bcbefbf2315468c9cec87c3" + url: "https://pub.dev" + source: hosted + version: "1.0.0" animated_theme_switcher: dependency: "direct main" description: @@ -29,50 +61,58 @@ packages: dependency: "direct main" description: name: ansicolor - sha256: "8bf17a8ff6ea17499e40a2d2542c2f481cd7615760c6d34065cb22bfd22e6880" + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.0.3" archive: dependency: "direct main" description: name: archive - sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" url: "https://pub.dev" source: hosted - version: "3.6.1" + version: "4.0.7" args: dependency: transitive description: name: args - sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.7.0" async: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.13.0" async_tools: dependency: "direct main" description: name: async_tools - sha256: "72590010ed6c6f5cbd5d40e33834abc08a43da6a73ac3c3645517d53899b8684" + sha256: "9611c1efeae7e6d342721d0c2caf2e4783d91fba6a9637d7badfa2dccf8de2a2" url: "https://pub.dev" source: hosted - version: "0.1.2" + version: "0.1.10" + auto_size_text: + dependency: "direct main" + description: + name: auto_size_text + sha256: "3f5261cd3fb5f2a9ab4e2fc3fba84fd9fcaac8821f20a1d4e71f557521b22599" + url: "https://pub.dev" + source: hosted + version: "3.0.0" awesome_extensions: dependency: "direct main" description: name: awesome_extensions - sha256: "07e52221467e651cab9219a26286245760831c3852ea2c54883a48a54f120d7c" + sha256: "9b1693e986e4045141add298fa2d7f9aa6cdd3c125b951e2cde739a5058ed879" url: "https://pub.dev" source: hosted - version: "2.0.16" + version: "2.0.21" badges: dependency: "direct main" description: @@ -81,30 +121,46 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" basic_utils: dependency: "direct main" description: name: basic_utils - sha256: "2064b21d3c41ed7654bc82cc476fd65542e04d60059b74d5eed490a4da08fc6c" + sha256: "548047bef0b3b697be19fa62f46de54d99c9019a69fb7db92c69e19d87f633c7" url: "https://pub.dev" source: hosted - version: "5.7.0" + version: "5.8.2" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" + source: hosted + version: "2.0.13" bloc: dependency: "direct main" description: name: bloc - sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e" + sha256: "52c10575f4445c61dd9e0cafcc6356fdd827c4c64dd7945ef3c4105f6b6ac189" url: "https://pub.dev" source: hosted - version: "8.1.4" + version: "9.0.0" bloc_advanced_tools: dependency: "direct main" description: name: bloc_advanced_tools - sha256: "0cf9b3a73a67addfe22ec3f97a1ac240f6ad53870d6b21a980260f390d7901cd" + sha256: "63e57000df7259e3007dbfbbfd7dae3e0eca60eb2ac93cbe0c5a3de0e77c9972" url: "https://pub.dev" source: hosted - version: "0.1.2" + version: "0.1.13" blurry_modal_progress_hud: dependency: "direct main" description: @@ -117,58 +173,66 @@ packages: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" + buffer: + dependency: transitive + description: + name: buffer + sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1" + url: "https://pub.dev" + source: hosted + version: "1.2.3" build: dependency: transitive description: name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" build_config: dependency: transitive description: name: build_config - sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" build_daemon: dependency: transitive description: name: build_daemon - sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" + sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.0.4" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.4" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "644dc98a0f179b872f612d3eb627924b578897c629788e858157fa5e704ca0c7" + sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" url: "https://pub.dev" source: hosted - version: "2.4.11" + version: "2.4.15" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: e3c79f69a64bdfcd8a776a3c28db4eb6e3fb5356d013ae5eb2e52007706d5dbe + sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" url: "https://pub.dev" source: hosted - version: "7.3.1" + version: "8.0.0" built_collection: dependency: transitive description: @@ -181,98 +245,98 @@ packages: dependency: transitive description: name: built_value - sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb + sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4 url: "https://pub.dev" source: hosted - version: "8.9.2" + version: "8.9.5" cached_network_image: dependency: transitive description: name: cached_network_image - sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f" + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "3.4.1" cached_network_image_platform_interface: dependency: transitive description: name: cached_network_image_platform_interface - sha256: "9e90e78ae72caa874a323d78fa6301b3fb8fa7ea76a8f96dc5b5bf79f283bf2f" + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.1.1" cached_network_image_web: dependency: transitive description: name: cached_network_image_web - sha256: "205d6a9f1862de34b93184f22b9d2d94586b2f05c581d546695e3d8f6a805cd7" + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.1" camera: - dependency: transitive + dependency: "direct main" description: name: camera - sha256: dfa8fc5a1adaeb95e7a54d86a5bd56f4bb0e035515354c8ac6d262e35cec2ec8 + sha256: "413d2b34fe28496c35c69ede5b232fb9dd5ca2c3a4cb606b14efc1c7546cc8cb" url: "https://pub.dev" source: hosted - version: "0.10.6" - camera_android: + version: "0.11.1" + camera_android_camerax: dependency: transitive description: - name: camera_android - sha256: "3af7f0b55f184d392d2eec238aaa30552ebeef2915e5e094f5488bf50d6d7ca2" + name: camera_android_camerax + sha256: "9fb44e73e0fea3647a904dc26d38db24055e5b74fc68fd2b6d3abfa1bd20f536" url: "https://pub.dev" source: hosted - version: "0.10.9+3" + version: "0.6.17" camera_avfoundation: dependency: transitive description: name: camera_avfoundation - sha256: "7d021e8cd30d9b71b8b92b4ad669e80af432d722d18d6aac338572754a786c15" + sha256: ca36181194f429eef3b09de3c96280f2400693f9735025f90d1f4a27465fdd72 url: "https://pub.dev" source: hosted - version: "0.9.16" + version: "0.9.19" camera_platform_interface: dependency: transitive description: name: camera_platform_interface - sha256: a250314a48ea337b35909a4c9d5416a208d736dcb01d0b02c6af122be66660b0 + sha256: "2f757024a48696ff4814a789b0bd90f5660c0fb25f393ab4564fb483327930e2" url: "https://pub.dev" source: hosted - version: "2.7.4" + version: "2.10.0" camera_web: dependency: transitive description: name: camera_web - sha256: "9e9aba2fbab77ce2472924196ff8ac4dd8f9126c4f9a3096171cd1d870d6b26c" + sha256: "595f28c89d1fb62d77c73c633193755b781c6d2e0ebcd8dc25b763b514e6ba8f" url: "https://pub.dev" source: hosted - version: "0.3.3" + version: "0.3.5" change_case: dependency: "direct main" description: name: change_case - sha256: "99cfdf2018c627c8a3af5a23ea4c414eb69c75c31322d23b9660ebc3cf30b514" + sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.0" characters: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" charcode: dependency: "direct main" description: name: charcode - sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.4.0" charset: dependency: transitive description: @@ -309,66 +373,66 @@ packages: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" code_builder: dependency: transitive description: name: code_builder - sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" url: "https://pub.dev" source: hosted - version: "4.10.0" + version: "4.10.1" collection: dependency: transitive description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.1" convert: - dependency: transitive - description: - name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" - url: "https://pub.dev" - source: hosted - version: "3.1.1" - cool_dropdown: dependency: "direct main" description: - name: cool_dropdown - sha256: "24400f57740b4779407586121e014bef241699ad2a52c506a7e1e7616cb68653" + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "3.1.2" + cross_cache: + dependency: transitive + description: + name: cross_cache + sha256: "007d0340c19d4d201192a3335c4034f4b79eae5ea53f90b69eeb5d239d9fbd1d" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cross_file: dependency: transitive description: name: cross_file - sha256: "55d7b444feb71301ef6b8838dbc1ae02e63dd48c8773f3810ff53bb1e2945b32" + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" url: "https://pub.dev" source: hosted - version: "0.3.4+1" + version: "0.3.4+2" crypto: dependency: transitive description: name: crypto - sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.6" csslib: dependency: transitive description: name: csslib - sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb" + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -381,10 +445,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" + sha256: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af" url: "https://pub.dev" source: hosted - version: "2.3.6" + version: "3.1.0" diffutil_dart: dependency: transitive description: @@ -393,46 +457,78 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.1" + dio: + dependency: transitive + description: + name: dio + sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9" + url: "https://pub.dev" + source: hosted + version: "5.8.0+1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" equatable: dependency: "direct main" description: name: equatable - sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" url: "https://pub.dev" source: hosted - version: "2.0.5" + version: "2.0.7" + expansion_tile_group: + dependency: "direct main" + description: + name: expansion_tile_group + sha256: "894c5088d94dda5d1ddde50463881935ff41b15850fe674605b9003d09716c8e" + url: "https://pub.dev" + source: hosted + version: "2.3.0" fast_immutable_collections: dependency: "direct main" description: name: fast_immutable_collections - sha256: c3c73f4f989d3302066e4ec94e6ec73b5dc872592d02194f49f1352d64126b8c + sha256: d1aa3d7788fab06cce7f303f4969c7a16a10c865e1bd2478291a8ebcbee084e5 url: "https://pub.dev" source: hosted - version: "10.2.4" + version: "11.0.4" ffi: dependency: transitive description: name: ffi - sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" file: dependency: transitive description: name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" + file_saver: + dependency: "direct main" + description: + name: file_saver + sha256: "017a127de686af2d2fbbd64afea97052d95f2a0f87d19d25b87e097407bf9c1e" + url: "https://pub.dev" + source: hosted + version: "0.2.14" fixnum: dependency: "direct main" description: name: fixnum - sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -442,75 +538,58 @@ packages: dependency: "direct main" description: name: flutter_animate - sha256: "7c8a6594a9252dad30cc2ef16e33270b6248c4dedc3b3d06c86c4f3f4dc05ae5" + sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5" url: "https://pub.dev" source: hosted - version: "4.5.0" + version: "4.5.2" flutter_bloc: dependency: "direct main" description: name: flutter_bloc - sha256: f0ecf6e6eb955193ca60af2d5ca39565a86b8a142452c5b24d96fb477428f4d2 + sha256: cf51747952201a455a1c840f8171d273be009b932c75093020f9af64f2123e38 url: "https://pub.dev" source: hosted - version: "8.1.5" + version: "9.1.1" flutter_cache_manager: dependency: transitive description: name: flutter_cache_manager - sha256: "395d6b7831f21f3b989ebedbb785545932adb9afe2622c1ffacf7f4b53a7e544" + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" url: "https://pub.dev" source: hosted - version: "3.3.2" - flutter_chat_types: + version: "3.4.1" + flutter_chat_core: dependency: "direct main" description: - name: flutter_chat_types - sha256: e285b588f6d19d907feb1f6d912deaf22e223656769c34093b64e1c59b094fb9 + name: flutter_chat_core + sha256: "7875785bc4aa0b1dce56a76d2a8bd65841c130a3deb2c527878ebfdf8c54f971" url: "https://pub.dev" source: hosted - version: "3.6.2" + version: "2.3.0" flutter_chat_ui: dependency: "direct main" description: - path: "." - ref: main - resolved-ref: "6712d897e25041de38fb53ec06dc7a12cc6bff7d" - url: "https://gitlab.com/veilid/flutter-chat-ui.git" - source: git - version: "1.6.13" + name: flutter_chat_ui + sha256: "012aa0d9cc2898b8f89b48f66adb106de9547e466ba21ad54ccef25515f68dcc" + url: "https://pub.dev" + source: hosted + version: "2.3.0" flutter_form_builder: dependency: "direct main" description: name: flutter_form_builder - sha256: "447f8808f68070f7df968e8063aada3c9d2e90e789b5b70f3b44e4b315212656" + sha256: aa3901466c70b69ae6c7f3d03fcbccaec5fde179d3fded0b10203144b546ad28 url: "https://pub.dev" source: hosted - version: "9.3.0" + version: "10.0.1" flutter_hooks: dependency: "direct main" description: name: flutter_hooks - sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70 + sha256: b772e710d16d7a20c0740c4f855095026b31c7eb5ba3ab67d2bd52021cd9461d url: "https://pub.dev" source: hosted - version: "0.20.5" - flutter_link_previewer: - dependency: transitive - description: - name: flutter_link_previewer - sha256: "007069e60f42419fb59872beb7a3cc3ea21e9f1bdff5d40239f376fa62ca9f20" - url: "https://pub.dev" - source: hosted - version: "3.2.2" - flutter_linkify: - dependency: transitive - description: - name: flutter_linkify - sha256: "74669e06a8f358fee4512b4320c0b80e51cffc496607931de68d28f099254073" - url: "https://pub.dev" - source: hosted - version: "6.0.0" + version: "0.21.2" flutter_localizations: dependency: "direct main" description: flutter @@ -520,42 +599,34 @@ packages: dependency: "direct main" description: name: flutter_native_splash - sha256: edf39bcf4d74aca1eb2c1e43c3e445fd9f494013df7f0da752fefe72020eedc0 + sha256: "8321a6d11a8d13977fa780c89de8d257cce3d841eecfb7a4cadffcc4f12d82dc" url: "https://pub.dev" source: hosted - version: "2.4.0" - flutter_parsed_text: - dependency: transitive - description: - name: flutter_parsed_text - sha256: "529cf5793b7acdf16ee0f97b158d0d4ba0bf06e7121ef180abe1a5b59e32c1e2" - url: "https://pub.dev" - source: hosted - version: "2.2.1" + version: "2.4.6" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: c6b0b4c05c458e1c01ad9bcc14041dd7b1f6783d487be4386f793f47a8a4d03e + sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e url: "https://pub.dev" source: hosted - version: "2.0.20" + version: "2.0.28" flutter_shaders: dependency: transitive description: name: flutter_shaders - sha256: "02750b545c01ff4d8e9bbe8f27a7731aa3778402506c67daa1de7f5fc3f4befe" + sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2" url: "https://pub.dev" source: hosted - version: "0.1.2" + version: "0.1.3" flutter_slidable: dependency: "direct main" description: name: flutter_slidable - sha256: "673403d2eeef1f9e8483bd6d8d92aae73b1d8bd71f382bc3930f699c731bc27c" + sha256: ab7dbb16f783307c9d7762ede2593ce32c220ba2ba0fd540a3db8e9a3acba71a url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "4.0.0" flutter_spinkit: dependency: "direct main" description: @@ -564,14 +635,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.2.1" + flutter_sticky_header: + dependency: "direct main" + description: + name: flutter_sticky_header + sha256: fb4fda6164ef3e5fc7ab73aba34aad253c17b7c6ecf738fa26f1a905b7d2d1e2 + url: "https://pub.dev" + source: hosted + version: "0.8.0" flutter_svg: dependency: "direct main" description: name: flutter_svg - sha256: "7b4ca6cf3304575fe9c8ec64813c8d02ee41d2afe60bcfe0678bcb5375d596a2" + sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1 url: "https://pub.dev" source: hosted - version: "2.0.10+1" + version: "2.1.0" flutter_translate: dependency: "direct main" description: @@ -585,30 +664,38 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_zoom_drawer: + dependency: "direct main" + description: + name: flutter_zoom_drawer + sha256: "5a3708548868463fb36e0e3171761ab7cb513df88d2f14053802812d2e855060" + url: "https://pub.dev" + source: hosted + version: "3.2.0" form_builder_validators: dependency: "direct main" description: name: form_builder_validators - sha256: "475853a177bfc832ec12551f752fd0001278358a6d42d2364681ff15f48f67cf" + sha256: cd617fa346250293ff3e2709961d0faf7b80e6e4f0ff7b500126b28d7422dd67 url: "https://pub.dev" source: hosted - version: "10.0.1" + version: "11.1.2" freezed: dependency: "direct dev" description: name: freezed - sha256: a434911f643466d78462625df76fd9eb13e57348ff43fe1f77bbe909522c67a1 + sha256: "6022db4c7bfa626841b2a10f34dd1e1b68e8f8f9650db6112dcdeeca45ca793c" url: "https://pub.dev" source: hosted - version: "2.5.2" + version: "3.0.6" freezed_annotation: dependency: "direct main" description: name: freezed_annotation - sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d + sha256: c87ff004c8aa6af2d531668b46a4ea379f7191dc6dfa066acd53d506da6e044b url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "3.0.0" frontend_server_client: dependency: transitive description: @@ -617,14 +704,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + get: + dependency: transitive + description: + name: get + sha256: c79eeb4339f1f3deffd9ec912f8a923834bec55f7b49c9e882b8fef2c139d425 + url: "https://pub.dev" + source: hosted + version: "4.7.2" glob: dependency: transitive description: name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" globbing: dependency: transitive description: @@ -637,106 +732,114 @@ packages: dependency: "direct main" description: name: go_router - sha256: abec47eb8c8c36ebf41d0a4c64dbbe7f956e39a012b3aafc530e951bdc12fe3f + sha256: "0b1e06223bee260dee31a171fb1153e306907563a0b0225e8c1733211911429a" url: "https://pub.dev" source: hosted - version: "14.1.4" + version: "15.1.2" graphs: dependency: transitive description: name: graphs - sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" url: "https://pub.dev" source: hosted - version: "2.3.1" - hive: - dependency: transitive - description: - name: hive - sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" - url: "https://pub.dev" - source: hosted - version: "2.2.3" + version: "2.3.2" html: dependency: transitive description: name: html - sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" url: "https://pub.dev" source: hosted - version: "0.15.4" + version: "0.15.6" http: dependency: transitive description: name: http - sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938" + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.4.0" http_multi_server: dependency: transitive description: name: http_multi_server - sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" http_parser: dependency: transitive description: name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "4.0.2" - hydrated_bloc: - dependency: "direct main" - description: - name: hydrated_bloc - sha256: af35b357739fe41728df10bec03aad422cdc725a1e702e03af9d2a41ea05160c - url: "https://pub.dev" - source: hosted - version: "9.1.5" + version: "4.1.2" icons_launcher: dependency: "direct dev" description: name: icons_launcher - sha256: "9b514ffed6ed69b232fd2bf34c44878c8526be71fc74129a658f35c04c9d4a9d" + sha256: "2949eef3d336028d89133f69ef221d877e09deed04ebd8e738ab4a427850a7a2" url: "https://pub.dev" source: hosted - version: "2.1.7" + version: "3.0.1" + iconsax_flutter: + dependency: transitive + description: + name: iconsax_flutter + sha256: "95b65699da8ea98f87c5d232f06b0debaaf1ec1332b697e4d90969ec9a93037d" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + idb_shim: + dependency: transitive + description: + name: idb_shim + sha256: "40e872276d79a1a97cc2c1ea0ecf046b8e34d788f16a8ea8f0da3e9b337d42da" + url: "https://pub.dev" + source: hosted + version: "2.6.6+1" image: dependency: "direct main" description: name: image - sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8" + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "4.5.4" + indent: + dependency: transitive + description: + name: indent + sha256: "819319a5c185f7fe412750c798953378b37a0d0d32564ce33e7c5acfd1372d2a" + url: "https://pub.dev" + source: hosted + version: "2.0.0" intl: dependency: "direct main" description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.20.2" io: dependency: transitive description: name: io - sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.5" js: dependency: transitive description: name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" url: "https://pub.dev" source: hosted - version: "0.6.7" + version: "0.7.2" json_annotation: dependency: "direct main" description: @@ -749,34 +852,26 @@ packages: dependency: "direct dev" description: name: json_serializable - sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c url: "https://pub.dev" source: hosted - version: "6.8.0" - linkify: - dependency: transitive - description: - name: linkify - sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" - url: "https://pub.dev" - source: hosted - version: "5.0.0" + version: "6.9.5" lint_hard: dependency: "direct dev" description: name: lint_hard - sha256: "44d15ec309b1a8e1aff99069df9dcb1597f49d5f588f32811ca28fb7b38c32fe" + sha256: "2073d4e83ac4e3f2b87cc615fff41abb5c2c5618e117edcd3d71f40f2186f4d5" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "6.1.1" logging: dependency: transitive description: name: logging - sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" loggy: dependency: "direct main" description: @@ -789,50 +884,34 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: "direct main" description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.16.0" mime: dependency: transitive description: name: mime - sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" url: "https://pub.dev" source: hosted - version: "1.0.5" - mobile_scanner: - dependency: "direct main" - description: - name: mobile_scanner - sha256: b8c0e9afcfd52534f85ec666f3d52156f560b5e6c25b1e3d4fe2087763607926 - url: "https://pub.dev" - source: hosted - version: "5.1.1" - motion_toast: - dependency: "direct main" - description: - name: motion_toast - sha256: "8dc8af93c606d0a08f2592591164f4a761099c5470e589f25689de6c601f124e" - url: "https://pub.dev" - source: hosted - version: "2.10.0" + version: "2.0.0" nested: dependency: transitive description: @@ -845,66 +924,82 @@ packages: dependency: transitive description: name: octo_image - sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d" + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" package_config: dependency: transitive description: name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191" + url: "https://pub.dev" + source: hosted + version: "8.3.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c" + url: "https://pub.dev" + source: hosted + version: "3.2.0" pasteboard: dependency: "direct main" description: name: pasteboard - sha256: "1c8b6a8b3f1d12e55d4e9404433cda1b4abe66db6b17bc2d2fb5965772c04674" + sha256: "9ff73ada33f79a59ff91f6c01881fd4ed0a0031cfc4ae2d86c0384471525fca1" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.4.0" path: dependency: "direct main" description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" path_parsing: dependency: transitive description: name: path_parsing - sha256: e3e67b1629e6f7e8100b367d3db6ba6af4b1f0bb80f64db18ef1fbabd2fa9ccf + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.1.0" path_provider: dependency: "direct main" description: name: path_provider - sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.5" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "9c96da072b421e98183f9ea7464898428e764bc0ce5567f27ec8693442e72514" + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 url: "https://pub.dev" source: hosted - version: "2.2.5" + version: "2.2.17" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" path_provider_linux: dependency: transitive description: @@ -925,42 +1020,58 @@ packages: dependency: transitive description: name: path_provider_windows - sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.3.0" + pausable_timer: + dependency: transitive + description: + name: pausable_timer + sha256: "6ef1a95441ec3439de6fb63f39a011b67e693198e7dae14e20675c3c00e86074" + url: "https://pub.dev" + source: hosted + version: "3.1.0+3" + pdf: + dependency: "direct main" + description: + name: pdf + sha256: "28eacad99bffcce2e05bba24e50153890ad0255294f4dd78a17075a2ba5c8416" + url: "https://pub.dev" + source: hosted + version: "3.11.3" + pdf_widget_wrapper: + dependency: transitive + description: + name: pdf_widget_wrapper + sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5 + url: "https://pub.dev" + source: hosted + version: "1.0.4" petitparser: dependency: transitive description: name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" url: "https://pub.dev" source: hosted - version: "6.0.2" - photo_view: - dependency: transitive - description: - name: photo_view - sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e" - url: "https://pub.dev" - source: hosted - version: "0.15.0" + version: "6.1.0" pinput: dependency: "direct main" description: name: pinput - sha256: "6d571e38a484f7515a52e89024ef416f11fa6171ac6f32303701374ab9890efa" + sha256: "8a73be426a91fefec90a7f130763ca39772d547e92f19a827cf4aa02e323d35a" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "5.0.1" platform: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -973,10 +1084,10 @@ packages: dependency: transitive description: name: pointycastle - sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" url: "https://pub.dev" source: hosted - version: "3.9.1" + version: "4.0.0" pool: dependency: transitive description: @@ -985,6 +1096,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + posix: + dependency: transitive + description: + name: posix + sha256: f0d7856b6ca1887cfa6d1d394056a296ae33489db914e365e2044fdada449e62 + url: "https://pub.dev" + source: hosted + version: "6.0.2" preload_page_view: dependency: "direct main" description: @@ -993,54 +1112,70 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + printing: + dependency: "direct main" + description: + name: printing + sha256: "482cd5a5196008f984bb43ed0e47cbfdca7373490b62f3b27b3299275bf22a93" + url: "https://pub.dev" + source: hosted + version: "5.14.2" protobuf: dependency: "direct main" description: name: protobuf - sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" + sha256: "579fe5557eae58e3adca2e999e38f02441d8aa908703854a9e0a0f47fa857731" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "4.1.0" provider: dependency: "direct main" description: name: provider - sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" url: "https://pub.dev" source: hosted - version: "6.1.2" + version: "6.1.5" pub_semver: dependency: transitive description: name: pub_semver - sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.5.0" qr: dependency: transitive description: name: qr - sha256: "64957a3930367bf97cc211a5af99551d630f2f4625e38af10edd6b19131b64b3" + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" + qr_code_dart_decoder: + dependency: transitive + description: + name: qr_code_dart_decoder + sha256: "6da7eda27726d504bed3c30eabf78ddca3eb9265e1c8dc49b30ef5974b9c267f" + url: "https://pub.dev" + source: hosted + version: "0.0.5" qr_code_dart_scan: dependency: "direct main" description: name: qr_code_dart_scan - sha256: "948271f8dc39ab3798341783f0ab7bfdb723054fdc9ea0928c0a5be8503ee01c" + sha256: bc4fc6f400b4350c6946d123c7871e156459703a61f8fa57d7144df9bbb46610 url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.10.0" qr_flutter: dependency: "direct main" description: @@ -1049,22 +1184,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" - quickalert: - dependency: "direct main" - description: - name: quickalert - sha256: b5d62b1e20b08cc0ff5f40b6da519bdc7a5de6082f13d90572cf4e72eea56c5e - url: "https://pub.dev" - source: hosted - version: "1.1.0" quiver: dependency: transitive description: name: quiver - sha256: b1c1ac5ce6688d77f65f3375a9abb9319b3cb32486bdc7a1e0fdf004d7ba4e47 + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" radix_colors: dependency: "direct main" description: @@ -1081,22 +1208,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.10" + rflutter_alert: + dependency: "direct main" + description: + name: rflutter_alert + sha256: "8ff35e3f9712ba24c746499cfa95bf320385edf38901a1a4eab0fe555867f66c" + url: "https://pub.dev" + source: hosted + version: "2.0.7" rxdart: dependency: transitive description: name: rxdart - sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" url: "https://pub.dev" source: hosted - version: "0.27.7" + version: "0.28.0" screen_retriever: dependency: transitive description: name: screen_retriever - sha256: "6ee02c8a1158e6dae7ca430da79436e3b1c9563c8cf02f524af997c201ac2b90" + sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" url: "https://pub.dev" source: hosted - version: "0.1.9" + version: "0.2.0" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screenshot: + dependency: "direct main" + description: + name: screenshot + sha256: "63817697a7835e6ce82add4228e15d233b74d42975c143ad8cfe07009fab866b" + url: "https://pub.dev" + source: hosted + version: "3.0.0" scroll_to_index: dependency: "direct main" description: @@ -1105,102 +1280,119 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + scrollview_observer: + dependency: transitive + description: + name: scrollview_observer + sha256: "174d4efe7b79459a07662175c4db42c9862dcf78d3978e6e9c2d6c0d8137f4ca" + url: "https://pub.dev" + source: hosted + version: "1.26.1" searchable_listview: dependency: "direct main" description: - name: searchable_listview - sha256: "5e547f2a3f88f99a798cfe64882cfce51496d8f3177bea4829dd7bcf3fa8910d" + path: "." + ref: main + resolved-ref: f367c2f713dcc0c965a4f7af5952d94b2f699998 + url: "https://gitlab.com/veilid/Searchable-Listview.git" + source: git + version: "2.16.0" + sembast: + dependency: transitive + description: + name: sembast + sha256: d3f0d0ba501a5f1fd7d6c8532ee01385977c8a069c334635dae390d059ae3d6d url: "https://pub.dev" source: hosted - version: "2.14.0" + version: "3.8.5" share_plus: dependency: "direct main" description: name: share_plus - sha256: ef3489a969683c4f3d0239010cc8b7a2a46543a8d139e111c06c558875083544 + sha256: b2961506569e28948d75ec346c28775bb111986bb69dc6a20754a457e3d97fa0 url: "https://pub.dev" source: hosted - version: "9.0.0" + version: "11.0.0" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: "0f9e4418835d1b2c3ae78fdb918251959106cefdbc4dd43526e182f80e82f6d4" + sha256: "1032d392bc5d2095a77447a805aa3f804d2ae6a4d5eef5e6ebb3bd94c1bc19ef" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "6.0.0" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.5.3" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "93d0ec9dd902d85f326068e6a899487d1f65ffcd5798721a95330b26c8131577" + sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.4.10" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "0a8a893bf4fd1152f93fec03a415d11c27c74454d96e2318a7ac38dd18683ab7" + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.5.4" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux - sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.1" shared_preferences_platform_interface: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "22e2ecac9419b4246d7c22bfbbda589e3acf5c0351137d87dd2939d984d37c3b" + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.1" shared_preferences_web: dependency: transitive description: name: shared_preferences_web - sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a" + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.4.3" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows - sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.1" shelf: dependency: transitive description: name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.2" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.0" signal_strength_indicator: dependency: "direct main" description: @@ -1213,15 +1405,31 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.99" - smart_auth: - dependency: transitive + version: "0.0.0" + sliver_expandable: + dependency: "direct main" description: - name: smart_auth - sha256: "88aa8fe66e951c78a307f26d1c29672dce2e9eb3da2e12e853864d0e615a73ad" + name: sliver_expandable + sha256: "046d8912ebd072bf9d8e8161e50a4669c520f691fce8bfcbae4ada6982b18ba3" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "1.1.2" + sliver_fill_remaining_box_adapter: + dependency: "direct main" + description: + name: sliver_fill_remaining_box_adapter + sha256: "2a222c0f09eb07c37857ce2526c0fbf3b17b2bd1b1ff0e890085f2f7a9ba1927" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + sliver_tools: + dependency: "direct main" + description: + name: sliver_tools + sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6 + url: "https://pub.dev" + source: hosted + version: "0.2.12" sorted_list: dependency: "direct main" description: @@ -1235,26 +1443,26 @@ packages: dependency: transitive description: name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "2.0.0" source_helper: dependency: transitive description: name: source_helper - sha256: "6adebc0006c37dd63fe05bca0a929b99f06402fc95aa35bf36d67f5c06de01fd" + sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" url: "https://pub.dev" source: hosted - version: "1.3.4" + version: "1.3.5" source_span: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.1" split_view: dependency: "direct main" description: @@ -1275,66 +1483,82 @@ packages: dependency: transitive description: name: sqflite - sha256: a43e5a27235518c03ca238e7b4732cf35eabe863a369ceba6cbefa537a66f16d + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 url: "https://pub.dev" source: hosted - version: "2.3.3+1" + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "3da423ce7baf868be70e2c0976c28a1bb2f73644268b7ffa7d2e08eab71f16a4" + sha256: "84731e8bfd8303a3389903e01fb2141b6e59b5973cacbb0929021df08dddbe8b" url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.5" + 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: "direct main" description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" stream_transform: dependency: "direct main" description: name: stream_transform - sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" string_scanner: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.2.0" - stylish_bottom_bar: - dependency: "direct main" - description: - name: stylish_bottom_bar - sha256: ca72557a5bd8f44caae9017eb3a73002e9189d7a9d2fac598fa55be13724f32b - url: "https://pub.dev" - source: hosted - version: "1.1.0" + version: "1.4.1" synchronized: dependency: transitive description: name: synchronized - sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" + sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6" url: "https://pub.dev" source: hosted - version: "3.1.0+1" + version: "3.3.1" system_info2: dependency: transitive description: @@ -1347,42 +1571,58 @@ packages: dependency: transitive description: name: system_info_plus - sha256: b915c811c6605b802f3988859bc2bb79c95f735762a75b5451741f7a2b949d1b + sha256: df94187e95527f9cb459e6a9f6e0b1ea20c157d8029bc233de34b3c1e17e1c48 url: "https://pub.dev" source: hosted - version: "0.0.5" + version: "0.0.6" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.6" timing: dependency: transitive description: name: timing - sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.2" + toastification: + dependency: "direct main" + description: + name: toastification + sha256: "9713989549d60754fd0522425d1251501919cfb7bab4ffbbb36ef40de5ea72b9" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + transitioned_indexed_stack: + dependency: "direct main" + description: + name: transitioned_indexed_stack + sha256: "8023abb5efe72e6d40cc3775fb03d7504c32ac918ec2ce7f9ba6804753820259" + url: "https://pub.dev" + source: hosted + version: "1.0.2" typed_data: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.4.0" universal_io: dependency: transitive description: @@ -1400,45 +1640,45 @@ packages: source: hosted version: "1.1.0" url_launcher: - dependency: transitive + dependency: "direct main" description: name: url_launcher - sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3" + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.3.1" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: ceb2625f0c24ade6ef6778d1de0b2e44f2db71fded235eb52295247feba8c5cf + sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "6.3.16" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "7068716403343f6ba4969b4173cbf3b84fc768042124bc2c011e5d782b24fe89" + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.3.3" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.2.1" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de" + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "3.2.2" url_launcher_platform_interface: dependency: transitive description: @@ -1451,50 +1691,58 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a" + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.4.1" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.4" uuid: dependency: "direct main" description: name: uuid - sha256: "814e9e88f21a176ae1359149021870e87f7cddaf633ab678a5d2b0bff7fd1ba8" + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff url: "https://pub.dev" source: hosted - version: "4.4.0" + version: "4.5.1" + value_layout_builder: + dependency: transitive + description: + name: value_layout_builder + sha256: ab4b7d98bac8cefeb9713154d43ee0477490183f5aa23bb4ffa5103d9bbf6275 + url: "https://pub.dev" + source: hosted + version: "0.5.0" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: "32c3c684e02f9bc0afb0ae0aa653337a2fe022e8ab064bcd7ffda27a74e288e3" + sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de" url: "https://pub.dev" source: hosted - version: "1.1.11+1" + version: "1.1.18" vector_graphics_codec: dependency: transitive description: name: vector_graphics_codec - sha256: c86987475f162fadff579e7320c7ddda04cd2fdeffbe1129227a85d9ac9e03da + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" url: "https://pub.dev" source: hosted - version: "1.1.11+1" + version: "1.1.13" vector_graphics_compiler: dependency: transitive description: name: vector_graphics_compiler - sha256: "12faff3f73b1741a36ca7e31b292ddeb629af819ca9efe9953b70bd63fc8cd81" + sha256: "557a315b7d2a6dbb0aaaff84d857967ce6bdc96a63dc6ee2a57ce5a6ee5d3331" url: "https://pub.dev" source: hosted - version: "1.1.11+1" + version: "1.1.17" vector_math: dependency: transitive description: @@ -1509,7 +1757,7 @@ packages: path: "../veilid/veilid-flutter" relative: true source: path - version: "0.3.2" + version: "0.4.7" veilid_support: dependency: "direct main" description: @@ -1517,70 +1765,62 @@ packages: relative: true source: path version: "1.0.2+0" - visibility_detector: - dependency: transitive - description: - name: visibility_detector - sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420 - url: "https://pub.dev" - source: hosted - version: "0.4.0+2" watcher: dependency: transitive description: name: watcher - sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" web: dependency: transitive description: name: web - sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "1.1.1" web_socket: dependency: transitive description: name: web_socket - sha256: "24301d8c293ce6fe327ffe6f59d8fd8834735f0ec36e4fd383ec7ff8a64aa078" + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" url: "https://pub.dev" source: hosted - version: "0.1.5" + version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: a2d56211ee4d35d9b344d9d4ce60f362e4f5d1aafb988302906bd732bc731276 + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.0.3" win32: dependency: transitive description: name: win32 - sha256: a79dbe579cb51ecd6d30b17e0cae4e0ea15e2c0e66f69ad4198f22a6789e94f4 + sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" url: "https://pub.dev" source: hosted - version: "5.5.1" + version: "5.13.0" window_manager: dependency: "direct main" description: name: window_manager - sha256: "8699323b30da4cdbe2aa2e7c9de567a6abd8a97d9a5c850a3c86dcd0b34bbfbf" + sha256: "732896e1416297c63c9e3fb95aea72d0355f61390263982a47fd519169dc5059" url: "https://pub.dev" source: hosted - version: "0.3.9" + version: "0.4.3" xdg_directories: dependency: transitive description: name: xdg_directories - sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.1.0" xml: dependency: transitive description: @@ -1601,10 +1841,10 @@ packages: dependency: transitive description: name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.3" zmodem: dependency: transitive description: @@ -1630,5 +1870,5 @@ packages: source: hosted version: "1.1.2" sdks: - dart: ">=3.4.0 <4.0.0" - flutter: ">=3.22.1" + dart: ">=3.7.0 <4.0.0" + flutter: ">=3.32.0" diff --git a/pubspec.yaml b/pubspec.yaml index a77991a..5cdbcb0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,129 +1,150 @@ name: veilidchat description: VeilidChat -publish_to: 'none' -version: 0.2.1+11 +publish_to: "none" +version: 0.4.8+21 environment: - sdk: '>=3.2.0 <4.0.0' - flutter: '>=3.22.1' + sdk: ">=3.2.0 <4.0.0" + flutter: ">=3.32.0" dependencies: + accordion: ^2.6.0 + animated_bottom_navigation_bar: ^1.4.0 + animated_custom_dropdown: ^3.1.1 + animated_switcher_transitions: ^1.0.0 animated_theme_switcher: ^2.0.10 - ansicolor: ^2.0.2 - archive: ^3.6.1 - async_tools: ^0.1.2 - awesome_extensions: ^2.0.16 + ansicolor: ^2.0.3 + archive: ^4.0.4 + async_tools: ^0.1.10 + auto_size_text: ^3.0.0 + awesome_extensions: ^2.0.21 badges: ^3.1.2 - basic_utils: ^5.7.0 - bloc: ^8.1.4 - bloc_advanced_tools: ^0.1.2 + basic_utils: ^5.8.2 + bloc: ^9.0.0 + bloc_advanced_tools: ^0.1.13 blurry_modal_progress_hud: ^1.1.1 - change_case: ^2.1.0 - charcode: ^1.3.1 + camera: ^0.11.1 + change_case: ^2.2.0 + charcode: ^1.4.0 circular_profile_avatar: ^2.0.5 circular_reveal_animation: ^2.0.1 - cool_dropdown: ^2.1.0 + convert: ^3.1.2 cupertino_icons: ^1.0.8 - equatable: ^2.0.5 - fast_immutable_collections: ^10.2.4 - fixnum: ^1.1.0 + equatable: ^2.0.7 + expansion_tile_group: ^2.2.0 + fast_immutable_collections: ^11.0.3 + file_saver: ^0.2.14 + fixnum: ^1.1.1 flutter: sdk: flutter - flutter_animate: ^4.5.0 - flutter_bloc: ^8.1.5 - flutter_chat_types: ^3.6.2 - flutter_chat_ui: - git: - url: https://gitlab.com/veilid/flutter-chat-ui.git - ref: main - flutter_form_builder: ^9.3.0 - flutter_hooks: ^0.20.5 + flutter_animate: ^4.5.2 + flutter_bloc: ^9.1.0 + flutter_chat_core: ^2.2.0 + flutter_chat_ui: ^2.2.0 + flutter_form_builder: ^10.0.1 + flutter_hooks: ^0.21.2 flutter_localizations: sdk: flutter - flutter_native_splash: ^2.4.0 - flutter_slidable: ^3.1.0 + flutter_native_splash: ^2.4.5 + flutter_slidable: ^4.0.0 flutter_spinkit: ^5.2.1 - flutter_svg: ^2.0.10+1 + flutter_sticky_header: ^0.8.0 + flutter_svg: ^2.0.17 flutter_translate: ^4.1.0 - form_builder_validators: ^10.0.1 - freezed_annotation: ^2.4.1 - go_router: ^14.1.4 - hydrated_bloc: ^9.1.5 - image: ^4.2.0 + flutter_zoom_drawer: ^3.2.0 + form_builder_validators: ^11.1.2 + freezed_annotation: ^3.0.0 + go_router: ^15.1.2 + image: ^4.5.3 intl: ^0.19.0 json_annotation: ^4.9.0 loggy: ^2.0.3 - meta: ^1.12.0 - mobile_scanner: ^5.1.1 - motion_toast: ^2.10.0 - pasteboard: ^0.2.0 - path: ^1.9.0 - path_provider: ^2.1.3 - pinput: ^4.0.0 + meta: ^1.16.0 + package_info_plus: ^8.3.0 + pasteboard: ^0.4.0 + path: ^1.9.1 + path_provider: ^2.1.5 + pdf: ^3.11.3 + pinput: ^5.0.1 preload_page_view: ^0.2.0 - protobuf: ^3.1.0 + printing: ^5.14.2 + protobuf: ^4.1.0 provider: ^6.1.2 - qr_code_dart_scan: ^0.8.0 + qr_code_dart_scan: ^0.10.0 qr_flutter: ^4.1.0 - quickalert: ^1.1.0 radix_colors: ^1.0.4 reorderable_grid: ^1.0.10 + rflutter_alert: ^2.0.7 + screenshot: ^3.0.0 scroll_to_index: ^3.0.1 - searchable_listview: ^2.14.0 - share_plus: ^9.0.0 - shared_preferences: ^2.2.3 + searchable_listview: + git: + url: https://gitlab.com/veilid/Searchable-Listview.git + ref: main + share_plus: ^11.0.0 + shared_preferences: ^2.5.2 signal_strength_indicator: ^0.4.1 + sliver_expandable: ^1.1.2 + sliver_fill_remaining_box_adapter: ^1.0.0 + sliver_tools: ^0.2.12 sorted_list: git: url: https://gitlab.com/veilid/dart-sorted-list-improved.git ref: main split_view: ^3.2.1 - stack_trace: ^1.11.1 - stream_transform: ^2.1.0 - stylish_bottom_bar: ^1.1.0 - uuid: ^4.4.0 + stack_trace: ^1.12.1 + stream_transform: ^2.1.1 + toastification: ^3.0.2 + transitioned_indexed_stack: ^1.0.2 + url_launcher: ^6.3.1 + uuid: ^4.5.1 veilid: # veilid: ^0.0.1 path: ../veilid/veilid-flutter veilid_support: path: packages/veilid_support - window_manager: ^0.3.9 + window_manager: ^0.4.3 xterm: ^4.0.0 zxing2: ^0.2.3 -# dependency_overrides: -# async_tools: +dependency_overrides: + intl: ^0.20.2 # Until flutter_translate updates intl +# async_tools: # path: ../dart_async_tools -# bloc_advanced_tools: +# bloc_advanced_tools: # path: ../bloc_advanced_tools -# flutter_chat_ui: -# path: ../flutter_chat_ui +# searchable_listview: +# path: ../Searchable-Listview +# flutter_chat_core: +# path: ../flutter_chat_ui/packages/flutter_chat_core +# flutter_chat_ui: +# path: ../flutter_chat_ui/packages/flutter_chat_ui dev_dependencies: - build_runner: ^2.4.11 - freezed: ^2.5.2 - icons_launcher: ^2.1.7 - json_serializable: ^6.8.0 - lint_hard: ^4.0.0 + build_runner: ^2.4.15 + freezed: ^3.0.4 + icons_launcher: ^3.0.1 + json_serializable: ^6.9.4 + lint_hard: ^6.0.0 flutter_native_splash: color: "#8588D0" - + icons_launcher: - image_path: 'assets/launcher/icon.png' + image_path: "assets/launcher/icon.png" platforms: android: enable: true - adaptive_background_color: '#ffffff' - adaptive_foreground_image: 'assets/launcher/icon.png' - adaptive_round_image: 'assets/launcher/icon.png' + adaptive_background_color: "#ffffff" + adaptive_foreground_image: "assets/launcher/icon.png" + adaptive_round_image: "assets/launcher/icon.png" ios: enable: true web: enable: true macos: enable: true - image_path: 'assets/launcher/macos_icon.png' + image_path: "assets/launcher/macos_icon.png" windows: enable: true linux: @@ -136,12 +157,34 @@ flutter: - assets/i18n/en.json # Launcher icon - assets/launcher/icon.png - # Images - - assets/images/splash.svg + # Theme wallpaper + - assets/images/wallpaper/arctic.svg + - assets/images/wallpaper/babydoll.svg + - assets/images/wallpaper/eggplant.svg + - assets/images/wallpaper/elite.svg + - assets/images/wallpaper/forest.svg + - assets/images/wallpaper/garden.svg + - assets/images/wallpaper/gold.svg + - assets/images/wallpaper/grim.svg + - assets/images/wallpaper/lapis.svg + - assets/images/wallpaper/lime.svg + - assets/images/wallpaper/scarlet.svg + - assets/images/wallpaper/vapor.svg + # Vector Images - assets/images/icon.svg + - assets/images/splash.svg - assets/images/title.svg - assets/images/vlogo.svg + - assets/images/toilet.svg + # Raster Images - assets/images/ellet.png + # Printing + - assets/js/pdf/3.2.146/pdf.min.js + # Sounds + - assets/sounds/bonk.wav + - assets/sounds/boop.wav + - assets/sounds/badeep.wav + - assets/sounds/beepbadeep.wav # Fonts fonts: - family: Source Code Pro @@ -149,7 +192,7 @@ flutter: - asset: assets/fonts/SourceCodePro-Regular.ttf - asset: assets/fonts/SourceCodePro-Bold.ttf weight: 700 - + # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware diff --git a/build.bat b/update_generated_files.bat similarity index 100% rename from build.bat rename to update_generated_files.bat diff --git a/build.sh b/update_generated_files.sh similarity index 100% rename from build.sh rename to update_generated_files.sh diff --git a/version_bump.sh b/version_bump.sh index cd17b37..9733313 100755 --- a/version_bump.sh +++ b/version_bump.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Fail out if any step has an error set -e @@ -12,8 +12,11 @@ elif [ "$1" == "minor" ]; then elif [ "$1" == "major" ]; then echo Bumping major version PART=major +elif [ "$1" == "build" ]; then + echo Bumping build code + PART=build else - echo Unsupported part! Specify 'patch', 'minor', or 'major' + echo Unsupported part! Specify 'build', 'patch', 'minor', or 'major' exit 1 fi @@ -25,7 +28,6 @@ increment_buildcode() { local new_buildcode=$((buildcode + 1)) echo "${major_minor_patch}+${new_buildcode}" } - # Function to get the current version from pubspec.yaml get_current_version() { awk '/^version: / { print $2 }' pubspec.yaml @@ -43,25 +45,23 @@ update_version() { eval "$SED_CMD 's/version: .*/version: ${new_version}/' pubspec.yaml" } -# I pray none of this errors! - I think it should popup an error should that happen.. - current_version=$(get_current_version) echo "Current Version: $current_version" -# Bump the major, minor, or patch version using bump2version -bump2version --current-version $current_version $PART +if [ "$PART" == "build" ]; then + final_version=$(increment_buildcode $current_version) +else + # Bump the major, minor, or patch version using bump2version + bump2version --current-version $current_version $PART -# Get the new version after bump2version -new_version=$(get_current_version) + new_version_base=$(get_current_version) -# Preserve the current build code -buildcode=${current_version#*+} -new_version="${new_version%+*}+${buildcode}" - -# Increment the build code -final_version=$(increment_buildcode $new_version) + buildcode=${current_version#*+} + intermediate_version="${new_version_base%+*}+${buildcode}" + final_version=$(increment_buildcode $intermediate_version) +fi # Update pubspec.yaml with the final version update_version $final_version @@ -71,4 +71,3 @@ echo "New Version: $final_version" #git add pubspec.yaml #git commit -m "Bump version to $final_version" #git tag "v$final_version" - diff --git a/web/index.html b/web/index.html index a9c0bf2..99eac34 100644 --- a/web/index.html +++ b/web/index.html @@ -1,5 +1,5 @@ - + - + - + @@ -34,19 +33,16 @@ VeilidChat - - - - - diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt index 930d207..903f489 100644 --- a/windows/flutter/CMakeLists.txt +++ b/windows/flutter/CMakeLists.txt @@ -10,6 +10,11 @@ include(${EPHEMERAL_DIR}/generated_config.cmake) # https://github.com/flutter/flutter/issues/57146. set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + # === Flutter Library === set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") @@ -92,7 +97,7 @@ add_custom_command( COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - windows-x64 $ + ${FLUTTER_TARGET_PLATFORM} $ VERBATIM ) add_custom_target(flutter_assemble DEPENDS diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index c453077..bb1eee2 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,23 +6,26 @@ #include "generated_plugin_registrant.h" +#include #include -#include +#include +#include #include -#include #include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSaverPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSaverPlugin")); PasteboardPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PasteboardPlugin")); - ScreenRetrieverPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("ScreenRetrieverPlugin")); + PrintingPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PrintingPlugin")); + ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); - SmartAuthPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("SmartAuthPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); VeilidPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index e705509..6cba61e 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,10 +3,11 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_saver pasteboard - screen_retriever + printing + screen_retriever_windows share_plus - smart_auth url_launcher_windows veilid window_manager