diff --git a/.github/workflows/build-android-app.yml b/.github/workflows/build-android-app.yml new file mode 100644 index 00000000..e985b692 --- /dev/null +++ b/.github/workflows/build-android-app.yml @@ -0,0 +1,45 @@ +name: Flutter Android CI/CD + +on: + push: + branches: [ main ] +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Java + uses: actions/setup-java@v3 + with: + distribution: 'zulu' + java-version: '11' + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.24.4' + channel: 'stable' + + - name: Install dependencies + run: flutter pub get + + + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.0' + + - name: Install Fastlane + run: | + cd android + gem install bundler + bundle install + + - name: Deploy to Test Track Play Store + env: + G_PLAY_FASTLANE_SERVICE_ACCOUNT: ${{ secrets.G_PLAY_FASTLANE_SERVICE_ACCOUNT }} + run: | + cd android + bundle exec fastlane android beta \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..78ff3ecc --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,21 @@ +name: flutter CI + +on: + push: + branches: [ dev, main ] + pull_request: + branches: [ dev, main ] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' + cache: true + cache-key: 'flutter-:os:-:channel:-:version:-:arch:-:hash:' # optional, change this to force refresh cache + cache-path: '${{ runner.tool_cache }}/flutter/:channel:-:version:-:arch:' # optional, change this to specify the cache path + #architecture: x64 # optional, x64 or arm64 + - run: flutter --version + - run: flutter test -j 1 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 15edab5e..1667066e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ .buildlog/ .history .svn/ +.keys/ migrate_working_dir/ # IntelliJ related @@ -49,3 +50,20 @@ app.*.map.json # NOTES.md + +#db +default.isar +default.isar-lck +isar.dll + +# fastlane specific +**/fastlane/report.xml + +# deliver temporary files +**/fastlane/Preview.html + +# snapshot generated screenshots +**/fastlane/screenshots + +# scan temporary files +**/fastlane/test_output \ No newline at end of file diff --git a/.metadata b/.metadata index 39f2501e..90eabcff 100644 --- a/.metadata +++ b/.metadata @@ -1,11 +1,11 @@ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # -# This file should be version controlled. +# This file should be version controlled and should not be manually edited. version: - revision: f1875d570e39de09040c8f79aa13cc56baab8db1 - channel: stable + revision: "80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819" + channel: "stable" project_type: app @@ -13,26 +13,26 @@ project_type: app migration: platforms: - platform: root - create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 - base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 + create_revision: 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 + base_revision: 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 - platform: android - create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 - base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 + create_revision: 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 + base_revision: 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 - platform: ios - create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 - base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 + create_revision: 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 + base_revision: 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 - platform: linux - create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 - base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 + create_revision: 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 + base_revision: 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 - platform: macos - create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 - base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 + create_revision: 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 + base_revision: 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 - platform: web - create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 - base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 + create_revision: 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 + base_revision: 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 - platform: windows - create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 - base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 + create_revision: 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 + base_revision: 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 # User provided section diff --git a/README.md b/README.md index b3f63c58..1d4acf29 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,33 @@ ## What is it? -camelus is a nostr client written in flutter, it is in heavy development, feel free to test it but expect some issues and missing features. +camelus is a nostr microblogging client written in flutter, it is in heavy development. Feel free to test it, but expect some issues and missing features. +The targeted platforms are mobile clients. Camelus uses dart_ndk as its core lib. +Dart_ndk is maintained by camelus and yana, combining efforts to offer a usable nostr lib accounting for mobile constraints (battery, data). The lib also does the heavy lifting with inbox/outbox (gossip) and database optimizations. -## What is the current mission? -I want to transform camelus into a simple but usable client. If you are looking for fancy features, please look elsewhere. +## architecture +The project uses clean architecture; if you are new to this, look in `domain_layer/usecases` and `domain_layer/entities`. Entities represent data and use cases, the core business logic of camelus. -## how can I test it? +dart_ndk is therefore included as an external lib. +Because camelus and dart_ndk entities are very similar (e.g., `nostr_note`), the conversion is trivial and might raise the question, why not rely on dart_ndk entities? Right now, abstraction is not really needed, and there is a performance penalty. Still, it also offers a clear boundary to dart_ndk and allows us to deviate and experiment on camelus and dart_ndk in the future. In my opinion, this flexibility is more valuable if performance is good enough. + + +To initialize the code, I use riverpod provider. Combined with clean architecture, it allows me to play Lego and manage dependencies in a central location, and expose it to the presentation_layer. A good example of this is `ndk_provider.dart`. +I use the riverpod provider very similar to singeltons, but the riverpod provider provides a better way to test code. + + +## state of the project + +Right now, camelus is unusable/experimental state. We are right in the process of integrating dart_ndk into camelus, on the way cleaning up obsolete code and refactoring widgets. The goal is to have a reliable codebase that makes it easy for other developers (you?) to contribute to the project. + +## Development + +To get started, link dart_ndk in `pubspec.yaml` like this: +``` + dart_ndk: + path: ../dart_ndk +``` ### Android @@ -21,3 +41,16 @@ or use the [apk](https://camelus.app/), it is signed with my key so you will nee I don't have an iOS device so I can't test it, if you have an iOS device and want to test it, you can build it yourself, I will be happy to help you. Otherwise wait for testflight to be available. + + +# How to build + +1. make sure flutter is installed + +2. clone the repo + +3. clone [dart_ndk](https://github.com/relaystr/dart_ndk) and depending on your folder structure edit pubspec.yaml to point to the correct path + +4. run `flutter pub get` + +5. run `flutter build apk --release` or `flutter run` to run directly on your device in debug mode \ No newline at end of file diff --git a/android/.gitignore b/android/.gitignore index 6f568019..bc8b9cb9 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -5,6 +5,7 @@ gradle-wrapper.jar /gradlew.bat /local.properties GeneratedPluginRegistrant.java +/.keys/ # Remember to never publicly share your keystore. # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app diff --git a/android/Gemfile b/android/Gemfile new file mode 100644 index 00000000..7a118b49 --- /dev/null +++ b/android/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "fastlane" diff --git a/android/Gemfile.lock b/android/Gemfile.lock new file mode 100644 index 00000000..2e6b3e9a --- /dev/null +++ b/android/Gemfile.lock @@ -0,0 +1,217 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.6) + rexml + addressable (2.8.5) + public_suffix (>= 2.0.2, < 6.0) + artifactory (3.0.15) + atomos (0.1.3) + aws-eventstream (1.2.0) + aws-partitions (1.828.0) + aws-sdk-core (3.183.1) + aws-eventstream (~> 1, >= 1.0.2) + aws-partitions (~> 1, >= 1.651.0) + aws-sigv4 (~> 1.5) + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.71.0) + aws-sdk-core (~> 3, >= 3.177.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.136.0) + aws-sdk-core (~> 3, >= 3.181.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.6) + aws-sigv4 (1.6.0) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + declarative (0.0.20) + digest-crc (0.6.5) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.5.20190701) + unf (>= 0.0.5, < 1.0.0) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.103.0) + faraday (1.10.3) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.7) + faraday (>= 0.8.0) + http-cookie (~> 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.4) + multipart-post (~> 2) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.0) + faraday (~> 1.0) + fastimage (2.2.7) + fastlane (2.216.0) + CFPropertyList (>= 2.3, < 4.0.0) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.0.0) + colored + commander (~> 4.6) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + naturally (~> 2.2) + optparse (~> 0.1.1) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.3) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.3.0) + xcpretty-travis-formatter (>= 0.0.3) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.50.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.1) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + webrick + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.19.0) + google-apis-core (>= 0.9.0, < 2.a) + google-cloud-core (1.6.0) + google-cloud-env (~> 1.0) + google-cloud-errors (~> 1.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.3.1) + google-cloud-storage (1.44.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.19.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.8.1) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.5) + domain_name (~> 0.5) + httpclient (2.8.3) + jmespath (1.6.2) + json (2.6.3) + jwt (2.7.1) + mini_magick (4.12.0) + mini_mime (1.1.5) + multi_json (1.15.0) + multipart-post (2.3.0) + nanaimo (0.3.0) + naturally (2.2.1) + optparse (0.1.1) + os (1.1.4) + plist (3.7.0) + public_suffix (5.0.3) + rake (13.0.6) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.1.2) + rexml (3.2.6) + rouge (2.0.7) + ruby2_keywords (0.0.5) + rubyzip (2.3.2) + security (0.1.3) + signet (0.18.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.1) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unf (0.1.4) + unf_ext + unf_ext (0.0.8.2) + unicode-display_width (2.4.2) + webrick (1.8.1) + word_wrap (1.0.0) + xcodeproj (1.23.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.3.0) + rexml (~> 3.2.4) + xcpretty (0.3.0) + rouge (~> 2.0.7) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + x64-mingw-ucrt + +DEPENDENCIES + fastlane + +BUNDLED WITH + 2.4.20 diff --git a/android/app/build.gradle b/android/app/build.gradle index c58ff2f3..1f378cf5 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,3 +1,10 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" + id "com.google.protobuf" +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -6,10 +13,7 @@ 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) { @@ -21,10 +25,7 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'com.google.protobuf' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + // signing @@ -37,7 +38,7 @@ apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion 33 + compileSdk 35 ndkVersion flutter.ndkVersion compileOptions { @@ -46,7 +47,7 @@ android { } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = JavaVersion.VERSION_1_8 } sourceSets { @@ -61,11 +62,10 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "de.lox.dev.camelus" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. - minSdkVersion 21 + minSdkVersion 23 targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName @@ -91,8 +91,8 @@ flutter { source '../..' } -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +// dependencies { +// implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} +// } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 579e483c..55ee546b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,7 +6,14 @@ - + + + + + + + + + + + diff --git a/android/build.gradle b/android/build.gradle index ad28fb22..1e73c4ff 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,20 +1,3 @@ -buildscript { - ext.kotlin_version = '1.8.20' - ext.javalite_version = '3.21.7' - ext.protoc_version = '3.9.2' - ext.protobuf_gradle = '0.9.1' - - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.4.2' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath "com.google.protobuf:protobuf-gradle-plugin:$protobuf_gradle" - } -} allprojects { repositories { diff --git a/android/fastlane/Appfile b/android/fastlane/Appfile new file mode 100644 index 00000000..c3f396ba --- /dev/null +++ b/android/fastlane/Appfile @@ -0,0 +1,2 @@ +#json_key_file("./.keys/camelus-fastlane-key.json") # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one +package_name("de.lox.dev.camelus") # e.g. com.krausefx.app diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile new file mode 100644 index 00000000..bd3ffaa4 --- /dev/null +++ b/android/fastlane/Fastfile @@ -0,0 +1,44 @@ +# This file contains the fastlane.tools configuration +# You can find the documentation at https://docs.fastlane.tools +# +# For a list of all available actions, check out +# +# https://docs.fastlane.tools/actions +# +# For a list of all available plugins, check out +# +# https://docs.fastlane.tools/plugins/available-plugins +# + +# Uncomment the line if you want fastlane to automatically update itself +# update_fastlane + +default_platform(:android) + +platform :android do + desc "Runs all the tests" + lane :test do + gradle(task: "test") + end + + + + desc "Deploy a new version to the Google Play" + lane :production do + gradle(task: "clean assembleRelease") + upload_to_play_store + end + + desc "Deploy a new version to open Testing Track" + lane :beta do + gradle( + task: 'bundle', + build_type: 'Release' + ) + upload_to_play_store( + track: 'beta', + aab: '../build/app/outputs/bundle/release/app-release.aab' + json_key_data: ENV['G_PLAY_FASTLANE_SERVICE_ACCOUNT'] + ) + end +end diff --git a/android/fastlane/README.md b/android/fastlane/README.md new file mode 100644 index 00000000..c888172d --- /dev/null +++ b/android/fastlane/README.md @@ -0,0 +1,48 @@ +fastlane documentation +---- + +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +```sh +xcode-select --install +``` + +For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) + +# Available Actions + +## Android + +### android test + +```sh +[bundle exec] fastlane android test +``` + +Runs all the tests + +### android production + +```sh +[bundle exec] fastlane android production +``` + +Deploy a new version to the Google Play + +### android beta + +```sh +[bundle exec] fastlane android beta +``` + +Deploy a new version to open Testing Track + +---- + +This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. + +More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). + +The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). diff --git a/android/fastlane/metadata/android/en-GB/full_description.txt b/android/fastlane/metadata/android/en-GB/full_description.txt new file mode 100644 index 00000000..46af2a5b --- /dev/null +++ b/android/fastlane/metadata/android/en-GB/full_description.txt @@ -0,0 +1,5 @@ +On camelus you can follow people to read their posts or write your own posts. +You can decide where you want to publish your posts, or from which relays posts are received. + + +You stay in control. \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-GB/images/featureGraphic.png b/android/fastlane/metadata/android/en-GB/images/featureGraphic.png new file mode 100644 index 00000000..8da461ff Binary files /dev/null and b/android/fastlane/metadata/android/en-GB/images/featureGraphic.png differ diff --git a/android/fastlane/metadata/android/en-GB/images/icon.png b/android/fastlane/metadata/android/en-GB/images/icon.png new file mode 100644 index 00000000..aef422bb Binary files /dev/null and b/android/fastlane/metadata/android/en-GB/images/icon.png differ diff --git a/android/fastlane/metadata/android/en-GB/images/phoneScreenshots/1_en-GB.png b/android/fastlane/metadata/android/en-GB/images/phoneScreenshots/1_en-GB.png new file mode 100644 index 00000000..46a0f99b Binary files /dev/null and b/android/fastlane/metadata/android/en-GB/images/phoneScreenshots/1_en-GB.png differ diff --git a/android/fastlane/metadata/android/en-GB/images/phoneScreenshots/2_en-GB.png b/android/fastlane/metadata/android/en-GB/images/phoneScreenshots/2_en-GB.png new file mode 100644 index 00000000..1125307e Binary files /dev/null and b/android/fastlane/metadata/android/en-GB/images/phoneScreenshots/2_en-GB.png differ diff --git a/android/fastlane/metadata/android/en-GB/images/phoneScreenshots/3_en-GB.png b/android/fastlane/metadata/android/en-GB/images/phoneScreenshots/3_en-GB.png new file mode 100644 index 00000000..7edd923c Binary files /dev/null and b/android/fastlane/metadata/android/en-GB/images/phoneScreenshots/3_en-GB.png differ diff --git a/android/fastlane/metadata/android/en-GB/short_description.txt b/android/fastlane/metadata/android/en-GB/short_description.txt new file mode 100644 index 00000000..66df3f18 --- /dev/null +++ b/android/fastlane/metadata/android/en-GB/short_description.txt @@ -0,0 +1 @@ +camelus connects you to the nostr network \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-GB/title.txt b/android/fastlane/metadata/android/en-GB/title.txt new file mode 100644 index 00000000..4237fa77 --- /dev/null +++ b/android/fastlane/metadata/android/en-GB/title.txt @@ -0,0 +1 @@ +camelus \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-GB/video.txt b/android/fastlane/metadata/android/en-GB/video.txt new file mode 100644 index 00000000..e69de29b diff --git a/android/gradle.properties b/android/gradle.properties index 94adc3a3..b9a9a246 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,6 @@ org.gradle.jvmargs=-Xmx1536M android.useAndroidX=true android.enableJetifier=true +android.defaults.buildfeatures.buildconfig=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 6b665338..d11cdd90 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Fri Jun 23 08:50:38 CEST 2017 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 44e62bcf..d3a2244b 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,11 +1,26 @@ -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.1.0' apply false + id "org.jetbrains.kotlin.android" version "1.8.22" apply false + id "com.google.protobuf" version "0.9.1" apply false +} + +include ":app" diff --git a/assets/images/default_header.jpg b/assets/images/default_header.jpg deleted file mode 100644 index 5fdc5568..00000000 Binary files a/assets/images/default_header.jpg and /dev/null differ diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 00000000..7e7e7f67 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1 @@ +extensions: diff --git a/fonts/Poppins/Poppins-Black.ttf b/fonts/Poppins/Poppins-Black.ttf new file mode 100644 index 00000000..71c0f995 Binary files /dev/null and b/fonts/Poppins/Poppins-Black.ttf differ diff --git a/fonts/Poppins/Poppins-BlackItalic.ttf b/fonts/Poppins/Poppins-BlackItalic.ttf new file mode 100644 index 00000000..7aeb58bd Binary files /dev/null and b/fonts/Poppins/Poppins-BlackItalic.ttf differ diff --git a/fonts/Poppins/Poppins-Bold.ttf b/fonts/Poppins/Poppins-Bold.ttf new file mode 100644 index 00000000..00559eeb Binary files /dev/null and b/fonts/Poppins/Poppins-Bold.ttf differ diff --git a/fonts/Poppins/Poppins-BoldItalic.ttf b/fonts/Poppins/Poppins-BoldItalic.ttf new file mode 100644 index 00000000..e61e8e88 Binary files /dev/null and b/fonts/Poppins/Poppins-BoldItalic.ttf differ diff --git a/fonts/Poppins/Poppins-ExtraBold.ttf b/fonts/Poppins/Poppins-ExtraBold.ttf new file mode 100644 index 00000000..df709360 Binary files /dev/null and b/fonts/Poppins/Poppins-ExtraBold.ttf differ diff --git a/fonts/Poppins/Poppins-ExtraBoldItalic.ttf b/fonts/Poppins/Poppins-ExtraBoldItalic.ttf new file mode 100644 index 00000000..14d2b375 Binary files /dev/null and b/fonts/Poppins/Poppins-ExtraBoldItalic.ttf differ diff --git a/fonts/Poppins/Poppins-ExtraLight.ttf b/fonts/Poppins/Poppins-ExtraLight.ttf new file mode 100644 index 00000000..e76ec69a Binary files /dev/null and b/fonts/Poppins/Poppins-ExtraLight.ttf differ diff --git a/fonts/Poppins/Poppins-ExtraLightItalic.ttf b/fonts/Poppins/Poppins-ExtraLightItalic.ttf new file mode 100644 index 00000000..89513d94 Binary files /dev/null and b/fonts/Poppins/Poppins-ExtraLightItalic.ttf differ diff --git a/fonts/Poppins/Poppins-Italic.ttf b/fonts/Poppins/Poppins-Italic.ttf new file mode 100644 index 00000000..12b7b3c4 Binary files /dev/null and b/fonts/Poppins/Poppins-Italic.ttf differ diff --git a/fonts/Poppins/Poppins-Light.ttf b/fonts/Poppins/Poppins-Light.ttf new file mode 100644 index 00000000..bc36bcc2 Binary files /dev/null and b/fonts/Poppins/Poppins-Light.ttf differ diff --git a/fonts/Poppins/Poppins-LightItalic.ttf b/fonts/Poppins/Poppins-LightItalic.ttf new file mode 100644 index 00000000..9e70be6a Binary files /dev/null and b/fonts/Poppins/Poppins-LightItalic.ttf differ diff --git a/fonts/Poppins/Poppins-Medium.ttf b/fonts/Poppins/Poppins-Medium.ttf new file mode 100644 index 00000000..6bcdcc27 Binary files /dev/null and b/fonts/Poppins/Poppins-Medium.ttf differ diff --git a/fonts/Poppins/Poppins-MediumItalic.ttf b/fonts/Poppins/Poppins-MediumItalic.ttf new file mode 100644 index 00000000..be67410f Binary files /dev/null and b/fonts/Poppins/Poppins-MediumItalic.ttf differ diff --git a/fonts/Poppins/Poppins-Regular.ttf b/fonts/Poppins/Poppins-Regular.ttf new file mode 100644 index 00000000..9f0c71b7 Binary files /dev/null and b/fonts/Poppins/Poppins-Regular.ttf differ diff --git a/fonts/Poppins/Poppins-SemiBold.ttf b/fonts/Poppins/Poppins-SemiBold.ttf new file mode 100644 index 00000000..74c726e3 Binary files /dev/null and b/fonts/Poppins/Poppins-SemiBold.ttf differ diff --git a/fonts/Poppins/Poppins-SemiBoldItalic.ttf b/fonts/Poppins/Poppins-SemiBoldItalic.ttf new file mode 100644 index 00000000..3e6c9422 Binary files /dev/null and b/fonts/Poppins/Poppins-SemiBoldItalic.ttf differ diff --git a/fonts/Poppins/Poppins-Thin.ttf b/fonts/Poppins/Poppins-Thin.ttf new file mode 100644 index 00000000..03e73661 Binary files /dev/null and b/fonts/Poppins/Poppins-Thin.ttf differ diff --git a/fonts/Poppins/Poppins-ThinItalic.ttf b/fonts/Poppins/Poppins-ThinItalic.ttf new file mode 100644 index 00000000..e26db5dd Binary files /dev/null and b/fonts/Poppins/Poppins-ThinItalic.ttf differ diff --git a/integration_test/onboarding_test.dart b/integration_test/onboarding_test.dart index adece8e1..5dcca4ea 100644 --- a/integration_test/onboarding_test.dart +++ b/integration_test/onboarding_test.dart @@ -1,4 +1,4 @@ -import 'package:camelus/routes/nostr/onboarding/onboarding.dart'; +import 'package:camelus/presentation_layer/routes/nostr/onboarding/onboarding.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:flutter/material.dart'; diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 9625e105..7c569640 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 11.0 + 12.0 diff --git a/ios/Podfile b/ios/Podfile index 88359b22..f3eff09a 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,6 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '11.0' +# minimum iOS version + platform :ios, '12.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -37,5 +38,17 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + + # Set custom iOS target for flutter_secure_storage and flutter_email_sender + #target.build_configurations.each do |config| + # if target.name == 'flutter_secure_storage' + # config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0' # Custom iOS target for flutter_secure_storage + # else Gem::Version.new($iOSVersion) > Gem::Version.new(config.build_settings['IPHONEOS_DEPLOYMENT_TARGET']) + # config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = $iOSVersion + # end + #end + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0' # Set this to match your platform version + end end end diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 38baa3da..ab4e5347 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -1,51 +1,55 @@ - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - camelus - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - camelus - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - - + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + camelus + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + camelus + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + NSPhotoLibraryUsageDescription + Photo access is used to add account profile, include pictures in posts + ITSAppUsesNonExemptEncryption + + diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..86a7c3b1 --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/atoms/my_profile_picture.dart b/lib/atoms/my_profile_picture.dart deleted file mode 100644 index 7e9ab9c0..00000000 --- a/lib/atoms/my_profile_picture.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.dart'; -import 'package:camelus/config/palette.dart'; -import 'package:cached_network_image/cached_network_image.dart'; - -Widget myProfilePicture({ - required String pictureUrl, - required String pubkey, - FilterQuality filterQuality = FilterQuality.medium, - int? cacheHeight, - bool disableGif = false, -}) { - // all other image types - if (pictureUrl.contains(".png") || - pictureUrl.contains(".jpg") || - pictureUrl.contains(".jpeg") || - (!disableGif && pictureUrl.contains(".gif")) || - pictureUrl.contains(".webp") || - pictureUrl.contains(".avif")) { - return ClipOval( - child: SizedBox.fromSize( - size: const Size.fromRadius(30), // Image radius - child: Container( - color: Palette.background, - child: CachedNetworkImage( - imageUrl: pictureUrl, - filterQuality: filterQuality, - progressIndicatorBuilder: (context, url, downloadProgress) => - const CircularProgressIndicator( - color: Palette.darkGray, - ), - errorWidget: (context, url, error) => const Icon(Icons.error), - //memCacheHeight: cacheHeight ?? 200, - memCacheWidth: cacheHeight ?? 150, //cacheHeight ?? 200, - maxHeightDiskCache: cacheHeight ?? 150, - maxWidthDiskCache: cacheHeight ?? 150, - alignment: Alignment.center, - fit: BoxFit.cover, - )), - ), - ); - } - - //if svg - if (pictureUrl.contains(".svg")) { - return Container( - height: 60, - width: 60, - decoration: const BoxDecoration( - color: Palette.primary, - shape: BoxShape.circle, - ), - child: SvgPicture.network(pictureUrl), - ); - } - // default - return Container( - height: 60, - width: 60, - decoration: const BoxDecoration( - color: Palette.primary, - shape: BoxShape.circle, - ), - child: SvgPicture.network( - "https://avatars.dicebear.com/api/personas/$pubkey.svg"), - ); -} - -class UserImage extends StatelessWidget { - const UserImage({ - super.key, - required this.myMetadata, - required this.pubkey, - }); - - final Future myMetadata; - final String pubkey; - - @override - Widget build(BuildContext context) { - return FutureBuilder( - future: myMetadata, - builder: (BuildContext context, AsyncSnapshot snapshot) { - var picture = ""; - var defaultPicture = - "https://avatars.dicebear.com/api/personas/${pubkey}.svg"; - if (snapshot.hasData) { - picture = snapshot.data?["picture"] ?? defaultPicture; - } else if (snapshot.hasError) { - picture = defaultPicture; - } else { - // loading - picture = defaultPicture; - } - - return myProfilePicture( - pictureUrl: picture, - pubkey: pubkey, - filterQuality: FilterQuality.medium, - ); - }); - } -} diff --git a/lib/components/note_card/note_card.dart b/lib/components/note_card/note_card.dart deleted file mode 100644 index cd61cbf4..00000000 --- a/lib/components/note_card/note_card.dart +++ /dev/null @@ -1,184 +0,0 @@ -import 'dart:ui'; - -import 'package:camelus/atoms/my_profile_picture.dart'; -import 'package:camelus/components/bottom_sheet_share.dart'; -import 'package:camelus/components/images_tile_view.dart'; -import 'package:camelus/components/note_card/bottom_action_row.dart'; -import 'package:camelus/components/note_card/bottom_sheet_more.dart'; -import 'package:camelus/components/note_card/name_row.dart'; -import 'package:camelus/components/note_card/note_card_build_split_content.dart'; -import 'package:camelus/components/write_post.dart'; -import 'package:camelus/config/palette.dart'; -import 'package:camelus/models/nostr_note.dart'; -import 'package:camelus/models/post_context.dart'; -import 'package:camelus/providers/metadata_provider.dart'; -import 'package:camelus/providers/nostr_service_provider.dart'; -import 'package:camelus/services/nostr/metadata/user_metadata.dart'; -import 'package:camelus/services/nostr/nostr_service.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:timeago/timeago.dart' as timeago; - -class NoteCard extends ConsumerStatefulWidget { - final NostrNote note; - - const NoteCard({Key? key, required this.note}) : super(key: key); - - @override - ConsumerState createState() => _NoteCardState(); -} - -class _NoteCardState extends ConsumerState { - _openProfile(String pubkey) { - Navigator.pushNamed(context, "/nostr/profile", arguments: pubkey); - } - - _openHashtag(String hashtag) { - Navigator.pushNamed(context, "/nostr/hastag", arguments: hashtag); - } - - late NostrService myNostrService; - late UserMetadata metadata; - late Future> myMetadata; - late NoteCardSplitContent splitContent; - - void _splitContent() { - splitContent = - NoteCardSplitContent(widget.note, metadata, _openProfile, _openHashtag); - - setState(() {}); - } - - @override - void initState() { - super.initState(); - metadata = ref.read(metadataProvider); - myMetadata = metadata.getMetadataByPubkey(widget.note.pubkey); - _splitContent(); - } - - @override - void didUpdateWidget(covariant NoteCard oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.note.id != widget.note.id) { - _splitContent(); - metadata = ref.watch(metadataProvider); - myMetadata = metadata.getMetadataByPubkey(widget.note.pubkey); - } - } - - @override - Widget build(BuildContext context) { - myNostrService = ref.watch(nostrServiceProvider); - - if (widget.note.pubkey == 'missing') { - return SizedBox( - height: 50, - child: Center( - child: Text( - "Missing note: ${widget.note.getDirectReply?.recommended_relay}, ${widget.note.getRootReply?.recommended_relay}", - style: const TextStyle(color: Colors.purple, fontSize: 20), - ), - ), - ); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(10, 10, 10, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - onTap: () { - Navigator.pushNamed(context, "/nostr/profile", - arguments: widget.note.pubkey); - }, - child: UserImage( - myMetadata: myMetadata, pubkey: widget.note.pubkey), - ), - Expanded( - // click container - child: Container( - margin: const EdgeInsets.only(left: 5, right: 10), - color: Palette.background, - child: Padding( - padding: const EdgeInsets.only(left: 10.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - NoteCardNameRow( - created_at: widget.note.created_at, - myMetadata: myMetadata, - pubkey: widget.note.pubkey, - openMore: () => - openBottomSheetMore(context, widget.note), - ), - - const SizedBox(height: 10), - splitContent.content, - - const SizedBox(height: 6), - if (splitContent.imageLinks.isNotEmpty) - ImagesTileView( - images: splitContent.imageLinks, - //galleryBottomWidget: splitContent.content, - ), - Padding( - padding: const EdgeInsets.only(top: 10.0), - child: BottomActionRow( - onComment: () { - _writeReply(context, widget.note); - }, - onLike: () {}, - onRetweet: () {}, - onShare: () { - openBottomSheetShare(context, widget.note); - }, - ), - ), - const SizedBox(height: 20), - // show text if replies > 0 - ], - ), - ), - ), - ), - ], - ), - ], - ), - ), - const Divider( - thickness: 0.3, - color: Palette.darkGray, - ) - ], - ); - } -} - -void _writeReply(ctx, NostrNote note) { - showModalBottomSheet( - isScrollControlled: true, - elevation: 10, - backgroundColor: Palette.background, - isDismissible: false, - context: ctx, - builder: (ctx) => BackdropFilter( - filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), - child: Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(ctx).viewInsets.bottom), - child: WritePost( - context: PostContext(replyToNote: note), - )), - )); -} diff --git a/lib/components/note_card/note_card_build_split_content.dart b/lib/components/note_card/note_card_build_split_content.dart deleted file mode 100644 index 4f941593..00000000 --- a/lib/components/note_card/note_card_build_split_content.dart +++ /dev/null @@ -1,237 +0,0 @@ -import 'dart:async'; -import 'dart:developer'; - -import 'package:camelus/config/palette.dart'; -import 'package:camelus/helpers/helpers.dart'; -import 'package:camelus/helpers/nprofile_helper.dart'; -import 'package:camelus/models/nostr_note.dart'; -import 'package:camelus/services/nostr/metadata/user_metadata.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:url_launcher/url_launcher_string.dart'; - -final profilePattern = RegExp(r"nostr:(nprofile|npub)[a-zA-Z0-9]+"); - -/// this class is responsible for building the content of a note -class NoteCardSplitContent { - final NostrNote _note; - - final Function(String) _profileCallback; - final Function(String) _hashtagCallback; - - final Map _tagsMetadata = {}; - - final UserMetadata _metadataProvider; - - List imageLinks = []; - - late Widget _content; - - NoteCardSplitContent( - this._note, - this._metadataProvider, - this._profileCallback, - this._hashtagCallback, - ) { - imageLinks.addAll(_extractImages(_note)); - _content = _buildContentHousing(); - } - - Widget get content => _content; - - _buildContentHousing() { - return Wrap( - spacing: 0, - runSpacing: 4, - direction: Axis.horizontal, - verticalDirection: VerticalDirection.down, - children: _buildContent(_note.content), - ); - } - - List _buildContent(String content) { - List widgets = []; - List lines = content.split("\n"); - for (var line in lines) { - if (line == "") { - //widgets.add(_buildText("\n")); - widgets.add(const SizedBox(height: 7, width: 1000)); - continue; - } - List words = line.split(" "); - for (var word in words) { - if (profilePattern.hasMatch(word)) { - widgets.add(_buildProfileLink(word)); - } else if (word.startsWith("#[")) { - widgets.add(_buildLegacyMentionHashtag(word)); - } else if (word.startsWith("#")) { - widgets.add(_buildHashtagLink(word)); - } else if (word.startsWith("http")) { - widgets.add(_buildLink(word)); - } else { - widgets.add(_buildText(word)); - } - widgets.add(_buildText(" ")); - } - widgets.removeLast(); // remove last space - //widgets.add(_buildText("\n")); // add back the original line break - widgets.add(const SizedBox(height: 7, width: 1000)); - } - - return widgets; - } - - List _extractImages(NostrNote note) { - List imageLinks = []; - RegExp exp = RegExp(r"(https?:\/\/[^\s]+)"); - Iterable matches = exp.allMatches(note.content); - for (var match in matches) { - var link = match.group(0); - if (link!.endsWith(".jpg") || - link.endsWith(".jpeg") || - link.endsWith(".png") || - link.endsWith(".webp") || - link.endsWith(".gif")) { - imageLinks.add(link); - } - } - - return imageLinks; - } - - Widget _buildProfileLink(String word) { - var group = profilePattern.allMatches(word); - var match = group.first; - var cleaned = match.group(0); - - if (cleaned == "") return const SizedBox(height: 0, width: 0); - - final myMatch = cleaned!.replaceAll("nostr:", ""); - String myPubkeyHex = ""; - String pubkeyBech = ""; - - if (myMatch.contains("nprofile")) { - // remove the "nostr:" part - - Map nProfileDecode = - NprofileHelper().bech32toMap(myMatch); - - final List myRelays = nProfileDecode['relays']; - myPubkeyHex = nProfileDecode['pubkey']; - pubkeyBech = Helpers().encodeBech32(myPubkeyHex, "npub"); - } - - if (myMatch.contains("npub")) { - pubkeyBech = myMatch; - final List decode = Helpers().decodeBech32(myMatch); - - myPubkeyHex = decode[0]; - } - - final String pubkeyHr = - "${pubkeyBech.substring(0, 5)}:${pubkeyBech.substring(pubkeyBech.length - 5)}"; - - var metadata = _metadataProvider.getMetadataByPubkey(myPubkeyHex); - - return GestureDetector( - onTap: () { - _profileCallback(myPubkeyHex); - }, - child: FutureBuilder>( - future: metadata, - builder: (context, metadataSnp) { - if (metadataSnp.hasData) { - return Text( - "@${metadataSnp.data!['name'] ?? pubkeyHr}", - style: const TextStyle(color: Palette.primary, fontSize: 17), - ); - } - return Text( - "@$pubkeyHr", - style: const TextStyle(color: Palette.primary, fontSize: 17), - ); - }), - ); - } - - Widget _buildLegacyMentionHashtag(String word) { - var indexString = word.replaceAll("#[", "").replaceAll("]", ""); - int index; - try { - index = int.parse(indexString); - } catch (e) { - return const SizedBox(height: 0, width: 0); - } - - var tag = _note.tags[index]; - - if (tag.type != 'p') { - return const SizedBox(height: 0, width: 0); - } - - var pubkeyBech = Helpers().encodeBech32(tag.value, "npub"); - // first 5 chars then ... then last 5 chars - var pubkeyHr = - "${pubkeyBech.substring(0, 5)}...${pubkeyBech.substring(pubkeyBech.length - 5)}"; - _tagsMetadata[tag.value] = pubkeyHr; - var metadata = _metadataProvider.getMetadataByPubkey(tag.value); - - return GestureDetector( - onTap: () { - _profileCallback(tag.value); - }, - child: FutureBuilder>( - future: metadata, - builder: (context, snapshot) { - if (snapshot.hasData) { - return Text( - "@${snapshot.data!['name'] ?? pubkeyHr}", - style: const TextStyle(color: Palette.primary, fontSize: 17), - ); - } - return Text( - "@$pubkeyHr", - style: const TextStyle(color: Palette.primary, fontSize: 17), - ); - }), - ); - } - - Widget _buildHashtagLink(String word) { - String hashtag = word.substring(1); - - return GestureDetector( - onTap: () { - _hashtagCallback(hashtag); - }, - child: Text( - word, - style: const TextStyle(color: Palette.primary, fontSize: 17), - ), - ); - } - - _buildLink(String word) { - if (imageLinks.contains(word)) { - return const SizedBox(height: 0, width: 0); - } - - return GestureDetector( - onTap: () { - launchUrlString(word, mode: LaunchMode.externalApplication); - }, - child: Text( - word, - style: const TextStyle(color: Palette.primary, fontSize: 17), - ), - ); - } - - _buildText(String word) { - return Text( - word, - style: const TextStyle(color: Palette.lightGray, fontSize: 17), - ); - } -} diff --git a/lib/components/note_card/note_card_container.dart b/lib/components/note_card/note_card_container.dart deleted file mode 100644 index 8bf30f8d..00000000 --- a/lib/components/note_card/note_card_container.dart +++ /dev/null @@ -1,215 +0,0 @@ -import 'dart:developer'; - -import 'package:camelus/components/note_card/note_card.dart'; -import 'package:camelus/config/palette.dart'; -import 'package:camelus/helpers/helpers.dart'; -import 'package:camelus/models/nostr_note.dart'; -import 'package:camelus/models/nostr_tag.dart'; -import 'package:camelus/providers/metadata_provider.dart'; -import 'package:camelus/services/nostr/metadata/user_metadata.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -/// this is a container for the note cards -/// its purpose is to hold connected notes (mostly replies) and paint connections between them -/// it also handles the logic on what to show => button to show more replies, etc - -class NoteCardContainer extends ConsumerStatefulWidget { - final List notes; - final List otherContainers; - - const NoteCardContainer( - {Key? key, required this.notes, this.otherContainers = const []}) - : super(key: key); - - @override - ConsumerState createState() => _NoteCardContainerState(); -} - -class _NoteCardContainerState extends ConsumerState { - List myWidgets = []; - List combinedWidgets = []; - - void _onNoteTab(BuildContext context, NostrNote myNote) { - var refEvents = myNote.getTagEvents; - - if (myNote.isRoot) { - _navigateToEventViewPage(context, myNote.id, null); - return; - } - - NostrTag? root = myNote.getRootReply; - NostrTag? reply = myNote.getDirectReply; - - // off spec support, sometimes not marked as root - root ??= refEvents.first; - - _navigateToEventViewPage(context, root.value, reply?.value ?? myNote.id); - } - - void _navigateToEventViewPage( - BuildContext context, String root, String? scrollIntoView) { - Navigator.pushNamed(context, "/nostr/event", arguments: { - "root": root, - "scrollIntoView": scrollIntoView - }); - } - - @override - void initState() { - super.initState(); - myWidgets = _buildContainerNotes(ref.read(metadataProvider), context); - combinedWidgets = [...myWidgets, ...widget.otherContainers]; - } - - @override - void dispose() { - super.dispose(); - } - - @override - void didUpdateWidget(NoteCardContainer oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.notes != widget.notes) { - setState(() { - myWidgets = _buildContainerNotes(ref.read(metadataProvider), context); - combinedWidgets = [...myWidgets, ...widget.otherContainers]; - }); - } - } - - @override - Widget build(BuildContext context) { - return SizedBox( - width: double.infinity, - child: Container( - color: Palette.background, - child: ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: combinedWidgets.length, - itemBuilder: (context, index) => combinedWidgets[index], - ), - ), - ); - } - - List _buildContainerNotes( - UserMetadata metadata, BuildContext context) { - List widgets = []; - for (int i = 0; i < widget.notes.length; i++) { - var note = widget.notes[i]; - widgets.add(Stack( - children: [ - // vertical line top - if (i != 0) - Positioned( - left: 40, - top: 0, - height: 50, - child: Container( - width: 2, - color: Palette.darkGray, - ), - ), - // vertical line bottom - if (i != widget.notes.length - 1) - Positioned( - left: 40, - bottom: 0, - // top - 50 - top: 50, - child: Container( - width: 2, - color: Palette.darkGray, - ), - ), - Container( - padding: const EdgeInsets.only(top: 10), - child: Column( - children: [ - // check if reply - if (note.getTagEvents.isNotEmpty) - // for myNote.getTagPubkeys - _buildInReplyTo(note, metadata, context, i, widget.notes), - - GestureDetector( - onTap: () { - _onNoteTab(context, note); - }, - child: Container( - //color: Palette.background, - child: NoteCard(note: note), - ), - ), - ], - ), - ), - ], - )); - } - return widgets; - } - - Row _buildInReplyTo(NostrNote myNote, UserMetadata metadata, - BuildContext context, int index, List notes) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (index != 0) SizedBox(width: notes.length > 1 ? 70 : 30), - if (index == 0) const SizedBox(width: 30), - const Text( - "reply to ", - style: TextStyle(fontSize: 16, color: Palette.gray), - ), - if (myNote.getTagPubkeys.length < 3) - ...myNote.getTagPubkeys - .map( - (tag) => linkedUsername(tag.value, metadata, context), - ) - .toList(), - if (myNote.getTagPubkeys.length > 2) - Expanded( - child: Wrap( - children: [ - linkedUsername( - myNote.getTagPubkeys[0].value, metadata, context), - linkedUsername( - myNote.getTagPubkeys[1].value, metadata, context), - Text( - " and ${myNote.getTagPubkeys.length - 2} ${myNote.getTagPubkeys.length > 3 ? 'others' : 'other'}", - style: const TextStyle(fontSize: 16, color: Palette.gray), - ) - ], - ), - ), - ], - ); - } -} - -Widget linkedUsername( - String pubkey, UserMetadata metadata, BuildContext context) { - return GestureDetector( - onTap: () { - Navigator.pushNamed(context, "/nostr/profile", arguments: pubkey); - }, - child: FutureBuilder( - builder: (context, AsyncSnapshot snapshot) { - var pubkeyBech = Helpers().encodeBech32(pubkey, "npub"); - var pubkeyHr = - "${pubkeyBech.substring(0, 4)}:${pubkeyBech.substring(pubkeyBech.length - 5)}"; - if (snapshot.hasData) { - return Text('@${snapshot.data?['name'] ?? pubkeyHr} ', - style: const TextStyle( - color: Palette.primary, fontSize: 16, height: 1.3)); - } else { - return Text(pubkeyHr, - style: const TextStyle( - color: Palette.primary, fontSize: 16, height: 1.3)); - } - }, - future: metadata.getMetadataByPubkey(pubkey), - ), - ); -} diff --git a/lib/components/seen_on_relays.dart b/lib/components/seen_on_relays.dart deleted file mode 100644 index 8f3234e9..00000000 --- a/lib/components/seen_on_relays.dart +++ /dev/null @@ -1,163 +0,0 @@ -import 'dart:developer'; - -import 'package:camelus/config/palette.dart'; -import 'package:camelus/models/tweet.dart'; -import 'package:camelus/services/nostr/relays/relay_tracker.dart'; -import 'package:camelus/services/nostr/relays/relays_injector.dart'; -import 'package:camelus/services/nostr/relays/relays_ranking.dart'; -import 'package:flutter/material.dart'; -import 'package:timeago/timeago.dart' as timeago; - -class SeenOnRelaysPage extends StatefulWidget { - Tweet tweet; - late RelaysRanking _relaysRanking; - late RelayTracker _relayTracker; - SeenOnRelaysPage({Key? key, required this.tweet}) : super(key: key) { - RelaysInjector injector = RelaysInjector(); - _relaysRanking = injector.relaysRanking; - _relayTracker = injector.relayTracker; - } - - @override - State createState() => _SeenOnRelaysPageState(); -} - -class _SeenOnRelaysPageState extends State { - @override - void initState() { - log(widget.tweet.relayHints.toString()); - super.initState(); - } - - String _timeago(int time) { - return timeago.format(DateTime.fromMillisecondsSinceEpoch(time * 1000)); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Seen on relays'), - backgroundColor: Palette.background, - ), - backgroundColor: Palette.background, - body: Container( - padding: const EdgeInsets.all(10), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 20), - const Text('event seen on', - style: TextStyle(color: Palette.white, fontSize: 35)), - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: widget.tweet.relayHints.length, - itemBuilder: (context, index) { - return ListTile( - title: Text( - widget.tweet.relayHints.keys.elementAt(index), - style: const TextStyle( - color: Palette.white, fontWeight: FontWeight.bold), - ), - subtitle: Text( - _timeago(widget.tweet.relayHints.values - .elementAt(index)['lastFetched']), - style: const TextStyle(color: Palette.white)), - ); - }, - ), - const SizedBox(height: 25), - const Text('author gossip hints', - style: TextStyle(color: Palette.white, fontSize: 35)), - FutureBuilder( - future: widget._relaysRanking - .getBestRelays(widget.tweet.pubkey, Direction.read), - builder: (context, snapshot) { - if (snapshot.hasData) { - return ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: snapshot.data!.length, - itemBuilder: (context, index) { - return ListTile( - title: Text( - snapshot.data![index]['relay'], - style: const TextStyle( - color: Palette.white, - fontWeight: FontWeight.bold), - ), - subtitle: Text( - "score: ${snapshot.data![index]['score'].toString()}", - style: const TextStyle(color: Palette.white)), - ); - }, - ); - } else { - return const Text("Loading..."); - } - }, - ), - const SizedBox( - height: 25, - ), - const Text('recorded data', - style: TextStyle(color: Palette.white, fontSize: 35)), - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: - widget._relayTracker.tracker[widget.tweet.pubkey]?.length, - itemBuilder: (context, index) { - return ListTile( - title: Text( - widget._relayTracker.tracker[widget.tweet.pubkey] - ?.keys - .elementAt(index) ?? - "not found", - style: const TextStyle( - color: Palette.white, - fontWeight: FontWeight.bold), - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widget._relayTracker - .tracker[widget.tweet.pubkey]?.values - .elementAt(index)['lastSuggestedKind3'] != - null) - Text( - "lastSuggestedKind3: ${_timeago(widget._relayTracker.tracker[widget.tweet.pubkey]?.values.elementAt(index)['lastSuggestedKind3'])}", - style: const TextStyle(color: Palette.white)), - if (widget._relayTracker - .tracker[widget.tweet.pubkey]?.values - .elementAt(index)['lastSuggestedNip05'] != - null) - Text( - "lastSuggestedNip05: ${_timeago(widget._relayTracker.tracker[widget.tweet.pubkey]?.values.elementAt(index)['lastSuggestedNip05'])}", - style: const TextStyle(color: Palette.white)), - if (widget._relayTracker - .tracker[widget.tweet.pubkey]?.values - .elementAt(index)['lastSuggestedBytag'] != - null) - Text( - "lastSuggestedBytag: ${_timeago(widget._relayTracker.tracker[widget.tweet.pubkey]?.values.elementAt(index)['lastSuggestedBytag'])}", - style: const TextStyle(color: Palette.white)), - if (widget._relayTracker - .tracker[widget.tweet.pubkey]?.values - .elementAt(index)['lastFetched'] != - null) - Text( - "lastFetched: ${_timeago(widget._relayTracker.tracker[widget.tweet.pubkey]?.values.elementAt(index)['lastFetched'])}", - style: const TextStyle(color: Palette.white)), - ], - )); - }, - ), - ], - ), - ), - )); - } -} diff --git a/lib/components/tweet_card.dart b/lib/components/tweet_card.dart deleted file mode 100644 index a8d46b6c..00000000 --- a/lib/components/tweet_card.dart +++ /dev/null @@ -1,516 +0,0 @@ -import 'dart:developer'; -import 'dart:ui'; - -import 'package:camelus/providers/nostr_service_provider.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:camelus/components/write_post.dart'; -import 'package:camelus/config/palette.dart'; -import 'package:camelus/helpers/helpers.dart'; -import 'package:camelus/models/tweet.dart'; -import 'package:camelus/models/tweet_control.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import 'package:timeago/timeago.dart' as timeago; -import 'package:url_launcher/url_launcher_string.dart'; -import 'package:photo_view/photo_view.dart'; - -import '../services/nostr/nostr_service.dart'; - -class TweetCard extends ConsumerStatefulWidget { - final Tweet tweet; - final TweetControl? tweetControl; - - const TweetCard({Key? key, required this.tweet, this.tweetControl}) - : super(key: key); - - @override - ConsumerState createState() => _TweetCardState(); -} - -class _TweetCardState extends ConsumerState { - late NostrService _nostrService; - late ImageProvider myImage; - - List textSpans = []; - - final Map _tagsMetadata = {}; - - String nip05verified = ""; - - List _buildTextSpans(String content) { - var spans = _buildUrlSpans(content); - var completeSpans = _buildHashtagSpans(spans); - - return completeSpans; - } - - List _buildUrlSpans(String input) { - final spans = []; - final linkRegex = RegExp( - r"(https?|ftp|file)://[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]"); - final linkMatches = linkRegex.allMatches(input); - int lastMatchEnd = 0; - for (final match in linkMatches) { - if (match.start > lastMatchEnd) { - spans.add( - TextSpan( - text: input.substring(lastMatchEnd, match.start), - style: const TextStyle( - color: Palette.lightGray, fontSize: 17, height: 1.3), - ), - ); - } - spans.add(TextSpan( - text: match.group(0), - style: const TextStyle( - color: Palette.primary, - fontSize: 17, - height: 1.3, - decoration: TextDecoration.underline), - recognizer: TapGestureRecognizer() - ..onTap = () { - if (match.group(0) == null) return; - - // Open the URL in a browser - launchUrlString(match.group(0)!, - mode: LaunchMode.externalApplication); - log(match.group(0)!); - })); - lastMatchEnd = match.end; - } - if (lastMatchEnd < input.length) { - spans.add(TextSpan( - text: input.substring(lastMatchEnd), - style: const TextStyle( - color: Palette.lightGray, fontSize: 17, height: 1.3))); - } - return spans; - } - - List _buildHashtagSpans(List spans) { - var finalSpans = []; - - for (var span in spans) { - if (span.text!.contains("#[")) { - final pattern = RegExp(r"#\[\d+\]"); - final hashMatches = pattern.allMatches(span.text!); - int lastMatchEnd = 0; - for (final match in hashMatches) { - if (match.start > lastMatchEnd) { - finalSpans.add( - TextSpan( - text: span.text!.substring(lastMatchEnd, match.start), - style: const TextStyle( - color: Palette.lightGray, fontSize: 17, height: 1.3), - ), - ); - } - var indexString = - match.group(0)!.replaceAll("#[", "").replaceAll("]", ""); - var index = int.parse(indexString); - var tag = widget.tweet.tags[index]; - - if (tag[0] == 'p' && _tagsMetadata[tag[1]] == null) { - var pubkeyBech = Helpers().encodeBech32(tag[1], "npub"); - // first 5 chars then ... then last 5 chars - var pubkeyHr = - "${pubkeyBech.substring(0, 5)}...${pubkeyBech.substring(pubkeyBech.length - 5)}"; - _tagsMetadata[tag[1]] = pubkeyHr; - } - finalSpans.add(TextSpan( - text: "@${_tagsMetadata[tag[1]]}", - style: const TextStyle( - color: Palette.primary, fontSize: 17, height: 1.3), - recognizer: TapGestureRecognizer() - ..onTap = () { - Navigator.pushNamed(context, "/nostr/profile", - arguments: tag[1]); - })); - lastMatchEnd = match.end; - } - if (lastMatchEnd < span.text!.length) { - finalSpans.add( - TextSpan( - text: span.text!.substring(lastMatchEnd), - style: const TextStyle( - color: Palette.lightGray, fontSize: 17, height: 1.3), - ), - ); - } - } else { - finalSpans.add(span); - } - } - - return finalSpans; - } - - void _checkNip05(String nip05, String pubkey) async { - if (nip05.isEmpty) return; - if (nip05verified.isNotEmpty) return; - try { - var check = await _nostrService.checkNip05(nip05, pubkey); - - if (check["valid"] == true) { - setState(() { - nip05verified = check["nip05"]; - }); - } - // ignore: empty_catches - } catch (e) {} - } - - // open image in full screen with dialog and zoom - void _openImage(ImageProvider image, BuildContext context) { - showDialog( - context: context, - builder: (BuildContext context) { - return Dialog( - clipBehavior: Clip.antiAliasWithSaveLayer, - insetPadding: const EdgeInsets.all(5), - child: PhotoView( - minScale: PhotoViewComputedScale.contained * 1, - onTapUp: (context, details, controllerValue) { - Navigator.pop(context); - }, - tightMode: true, - imageProvider: image, - ), - ); - }); - } - - void _openReplies(context) { - //if (tweet.replies.isEmpty) { - // return; - //} - Navigator.pushNamed(context, "/nostr/event", arguments: widget.tweet.id); - } - - void _writeReply(ctx, Tweet tweet) { - showModalBottomSheet( - isScrollControlled: true, - elevation: 10, - backgroundColor: Palette.background, - isDismissible: false, - context: ctx, - builder: (ctx) => BackdropFilter( - filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), - child: Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(ctx).viewInsets.bottom), - child: const WritePost( - // context: PostContext(replyToTweet: tweet), - )), - )); - } - - void _initNostrService() { - _nostrService = ref.read(nostrServiceProvider); - } - - @override - void initState() { - super.initState(); - _initNostrService(); - } - - @override - void dispose() { - super.dispose(); - } - - @override - Widget build(BuildContext context) { - if (widget.tweet.imageLinks.isNotEmpty) { - myImage = Image.network(widget.tweet.imageLinks[0], fit: BoxFit.fill, - loadingBuilder: (BuildContext context, Widget child, - ImageChunkEvent? loadingProgress) { - if (loadingProgress == null) return child; - return Center( - child: CircularProgressIndicator( - value: loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - ), - ); - }).image; - } - - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () { - _openReplies(context); - }, - child: Stack( - children: [ - if ((widget.tweetControl?.showVerticalLineTop) ?? false) - Positioned( - top: 0, - left: 49, - child: Container( - height: 50, - width: 2, - color: Palette.gray, - ), - ), - if ((widget.tweetControl?.showVerticalLineBottom) ?? false) - //line from profile picture to bottom of tweet - Positioned( - top: 50, - bottom: 0, - left: 49, - child: Container( - height: 50, - width: 2, - color: Palette.gray, - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - // fix so whole card is clickable - //color: Palette.purple, - margin: const EdgeInsets.fromLTRB(10, 10, 10, 0), - // debug: color if is reply - //color: tweet.isReply ? Palette.darkGray : null, - // height: 200.0, - child: Padding( - padding: const EdgeInsets.fromLTRB(10, 10, 10, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widget.tweet.isReply && - (widget.tweetControl?.showInReplyTo ?? true)) - Container( - margin: const EdgeInsets.fromLTRB(0, 0, 0, 15), - child: Row( - children: [ - const SizedBox(width: 25), - const Text("replying to", - style: TextStyle( - fontSize: 16, color: Palette.gray)), - const SizedBox(width: 5), - if (Helpers() - .getPubkeysFromTags(widget.tweet.tags) - .isNotEmpty) //todo this is a hotfix to not break the feed - - if (Helpers() - .getEventsFromTags(widget.tweet.tags) - .length > - 1) - Text( - " and ${Helpers().getEventsFromTags(widget.tweet.tags).length - 1} more", - style: const TextStyle( - fontSize: 16, color: Palette.gray), - ) - ], - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - onTap: () { - Navigator.pushNamed(context, "/nostr/profile", - arguments: widget.tweet.pubkey); - }, - ), - Expanded( - child: Padding( - padding: const EdgeInsets.only(left: 10.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - //mainAxisAlignment: MainAxisAlignment.end, - //crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const SizedBox(width: 10), - Container( - height: 3, - width: 3, - decoration: const BoxDecoration( - color: Palette.gray, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 5), - Expanded( - child: Text( - timeago.format(DateTime - .fromMillisecondsSinceEpoch( - widget.tweet.tweetedAt * - 1000)), - style: const TextStyle( - color: Palette.gray, - fontSize: 12), - ), - ), - GestureDetector( - onTap: () { - //openBottomSheetMore( - // context, widget.tweet); - }, - child: SvgPicture.asset( - 'assets/icons/tweetSetting.svg', - color: Palette.darkGray, - ), - ) - ]), - const SizedBox(height: 2), - // content - RichText( - text: TextSpan( - children: _buildTextSpans( - widget.tweet.content, - ), - ), - ), - - const SizedBox(height: 6), - if (widget.tweet.imageLinks.isNotEmpty) - GestureDetector( - onTap: () => _openImage(myImage, context), - child: Container( - height: 200, - decoration: BoxDecoration( - color: Palette.darkGray, - shape: BoxShape.rectangle, - borderRadius: - BorderRadius.circular(10), - image: DecorationImage( - fit: BoxFit.cover, - image: myImage), - ), - ), - ), - Padding( - padding: const EdgeInsets.only(top: 10.0), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - GestureDetector( - onTap: () => { - _writeReply(context, widget.tweet) - }, - child: Row( - children: [ - SvgPicture.asset( - height: 23, - 'assets/icons/chat-teardrop-text.svg', - color: Palette.darkGray, - ), - const SizedBox(width: 5), - Text( - // show number of comments if >0 - widget.tweet.commentsCount >= 2 - ? "1+" //widget.tweet.commentsCount - .toString() - : "", - - style: const TextStyle( - color: Palette.gray, - fontSize: 16), - ), - ], - ), - ), - GestureDetector( - onTap: () => { - ScaffoldMessenger.of(context) - .showSnackBar(const SnackBar( - content: Text( - 'repost implemented yet'), - duration: Duration(seconds: 1), - )), - }, - child: Row( - children: [ - SvgPicture.asset( - 'assets/icons/retweet.svg', - color: Palette.darkGray, - ), - const SizedBox(width: 5), - Text( - "" //widget.tweet.retweetsCount - .toString(), - style: const TextStyle( - color: Palette.gray, - fontSize: 16), - ), - ], - ), - ), - // like button - GestureDetector( - onTap: () => { - ScaffoldMessenger.of(context) - .showSnackBar(const SnackBar( - content: Text( - 'Like functionality is not implemented yet'), - duration: Duration(seconds: 1), - )), - }, - child: Row( - children: [ - SvgPicture.asset( - height: 23, - 'assets/icons/heart.svg', - color: Palette.darkGray, - ), - const SizedBox(width: 5), - Text( - "" //widget.tweet.likesCount - .toString(), - style: const TextStyle( - color: Palette.gray, - fontSize: 16), - ), - ], - ), - ), - GestureDetector( - onTap: () => { - // openBottomSheetShare( - // context, widget.tweet) - }, - child: SvgPicture.asset( - height: 23, - 'assets/icons/share.svg', - color: Palette.darkGray, - ), - ), - ], - ), - ), - const SizedBox(height: 20), - // show text if replies > 0 - ], - ), - ), - ), - ], - ), - ], - ), - ), - ), - const Divider( - thickness: 0.3, - color: Palette.darkGray, - ) - ], - ), - ], - ), - ); - } -} diff --git a/lib/config/amber_url.dart b/lib/config/amber_url.dart new file mode 100644 index 00000000..dbaccb18 --- /dev/null +++ b/lib/config/amber_url.dart @@ -0,0 +1,3 @@ +// ignore: constant_identifier_names +const AMBER_INSTANCE_URL = + 'https://github.com/greenart7c3/Amber?tab=readme-ov-file#download-and-install'; diff --git a/lib/config/app_update_config.dart b/lib/config/app_update_config.dart new file mode 100644 index 00000000..4df3f3ad --- /dev/null +++ b/lib/config/app_update_config.dart @@ -0,0 +1,11 @@ +import 'package:package_info_plus/package_info_plus.dart'; + +class AppUpdateConfig { + static Future getBuildNumber() async { + PackageInfo packageInfo = await PackageInfo.fromPlatform(); + return int.parse(packageInfo.buildNumber); + } + + static const String appUpdateCheckUrl = + 'https://lox.de/.well-known/app-update-beta.json'; +} diff --git a/lib/config/default_relays.dart b/lib/config/default_relays.dart new file mode 100644 index 00000000..4dcb058e --- /dev/null +++ b/lib/config/default_relays.dart @@ -0,0 +1,7 @@ +List CAMELUS_BOOTSTRAP_RELAYS = [ + "wss://strfry.iris.to", + "wss://relay.damus.io", + "wss://relay.nostr.band", + "wss://relay.snort.social", + "wss://nos.lol", +]; diff --git a/lib/config/dicebear.dart b/lib/config/dicebear.dart new file mode 100644 index 00000000..e1e9f652 --- /dev/null +++ b/lib/config/dicebear.dart @@ -0,0 +1,4 @@ +class Dicebear { + static const String baseUrl = + 'https://api.dicebear.com/8.x/personas/svg?seed='; +} diff --git a/lib/config/palette.dart b/lib/config/palette.dart index 555facac..ebce1009 100644 --- a/lib/config/palette.dart +++ b/lib/config/palette.dart @@ -13,4 +13,5 @@ class Palette { static const Color extraDarkGray = Color(0xFF1A1A1A); static const Color white = Color(0xFFFFFFFF); static const Color black = Color(0xFF000000); + static const Color error = Color.fromARGB(255, 254, 29, 29); } diff --git a/lib/data_layer/data_sources/api_nostr_band_data_source.dart b/lib/data_layer/data_sources/api_nostr_band_data_source.dart new file mode 100644 index 00000000..a6fbe16f --- /dev/null +++ b/lib/data_layer/data_sources/api_nostr_band_data_source.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; + +import 'package:camelus/data_layer/models/nostr_band_hashtags_model.dart'; +import 'package:camelus/data_layer/models/nostr_band_people_model.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +class ApiNostrBandDataSource { + Future _fetchData(String type, String key, + T Function(Map) fromJson) async { + var file = await DefaultCacheManager().getSingleFile( + 'https://camelus.app/api/v1/nostr-band-cache?type=$type&limit=10', + key: key, + headers: {'Cache-Control': 'max-age=7200'}, + ); + var result = await file.readAsString(); + if (result.isEmpty) { + throw Exception('No data'); + } + var json = jsonDecode(result); + return fromJson(json); + } + + Future getTrendingProfiles() async { + return _fetchData('profiles', 'trending_profiles_nostr_band', + NostrBandPeopleModel.fromJson); + } + + Future getTrendingHashtags() async { + return _fetchData('hashtags', 'trending_hashtags_nostr_band', + NostrBandHashtagsModel.fromJson); + } +} diff --git a/lib/data_layer/data_sources/dart_ndk_source.dart b/lib/data_layer/data_sources/dart_ndk_source.dart new file mode 100644 index 00000000..7c917ca4 --- /dev/null +++ b/lib/data_layer/data_sources/dart_ndk_source.dart @@ -0,0 +1,7 @@ +import 'package:ndk/ndk.dart'; + +class DartNdkSource { + final Ndk dartNdk; + + DartNdkSource(this.dartNdk); +} diff --git a/lib/data_layer/data_sources/http_request_data_source.dart b/lib/data_layer/data_sources/http_request_data_source.dart new file mode 100644 index 00000000..adde64ae --- /dev/null +++ b/lib/data_layer/data_sources/http_request_data_source.dart @@ -0,0 +1,19 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +class HttpRequestDataSource { + final http.Client _client; + + HttpRequestDataSource(this._client); + + Future> jsonRequest(String url) async { + http.Response response = await _client + .get(Uri.parse(url), headers: {"Accept": "application/json"}); + + if (response.statusCode != 200) { + return throw Exception( + "error fetching STATUS: ${response.statusCode}, Link: $url"); + } + return jsonDecode(response.body); + } +} diff --git a/lib/services/external/nostr_build_file_upload.dart b/lib/data_layer/data_sources/nostr_build_file_upload.dart similarity index 61% rename from lib/services/external/nostr_build_file_upload.dart rename to lib/data_layer/data_sources/nostr_build_file_upload.dart index 202b3488..532a2304 100644 --- a/lib/services/external/nostr_build_file_upload.dart +++ b/lib/data_layer/data_sources/nostr_build_file_upload.dart @@ -1,20 +1,20 @@ import 'dart:convert'; -import 'dart:io'; import 'package:http/http.dart' as http; import 'package:http_parser/http_parser.dart'; -import 'package:mime/mime.dart'; + +import '../../domain_layer/entities/mem_file.dart'; class NostrBuildFileUpload { - static Future uploadImage(File file) async { + Future uploadImage(MemFile file) async { final uri = Uri.parse('https://nostr.build/upload.php'); var request = http.MultipartRequest('POST', uri); - var bytes = await file.readAsBytes(); - var mimeType = lookupMimeType(file.path); - var filename = file.path.split('/').last; - - final httpImage = http.MultipartFile.fromBytes("fileToUpload", bytes, - contentType: MediaType.parse(mimeType!), filename: filename); + final httpImage = http.MultipartFile.fromBytes( + "fileToUpload", + file.bytes, + contentType: MediaType.parse(file.mimeType), + filename: file.name, + ); request.files.add(httpImage); final response = await request.send(); @@ -25,9 +25,9 @@ class NostrBuildFileUpload { var responseString = await response.stream.transform(utf8.decoder).join(); - // extract url https://nostr.build/i/4697.png + // extract url https://image.nostr.build/random00values.jpg final RegExp urlPattern = - RegExp(r'https:\/\/nostr\.build\/i\/\S+\.(?:jpg|jpeg|png|gif)'); + RegExp(r'https:\/\/image\.nostr\.build\S+\.(?:jpg|jpeg|png|gif)'); final Match? urlMatch = urlPattern.firstMatch(responseString); if (urlMatch != null) { final String myUrl = urlMatch.group(0)!; diff --git a/lib/data_layer/db/object_box_camelus/db_camelus.dart b/lib/data_layer/db/object_box_camelus/db_camelus.dart new file mode 100644 index 00000000..6d08daba --- /dev/null +++ b/lib/data_layer/db/object_box_camelus/db_camelus.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +import '../../../domain_layer/repositories/app_db.dart'; +import '../../../objectbox.g.dart'; +import 'db_camelus_init.dart'; +import 'schema/db_key_value.dart'; + +class DbAppImpl implements AppDb { + final Completer _initCompleter = Completer(); + Future get _dbRdy => _initCompleter.future; + late DbCamelusInit _objectBox; + + DbAppImpl() { + _init(); + } + + Future _init() async { + final objectbox = await DbCamelusInit.create(); + _objectBox = objectbox; + _initCompleter.complete(); + } + + @override + Future clear() async { + await _dbRdy; + await _objectBox.store.box().removeAll(); + } + + @override + Future delete(String key) async { + await _dbRdy; + + // run transaction to get the id of the key and then delete it + await _objectBox.store.runInTransaction(TxMode.write, () async { + final keyBox = _objectBox.store.box(); + final keyToDelete = + keyBox.query(DbKeyValue_.key.equals(key)).build().findFirst(); + if (keyToDelete != null) { + keyBox.remove(keyToDelete.dbId); + } + }); + } + + @override + Future read(String key) async { + await _dbRdy; + final keyBox = _objectBox.store.box(); + final keyValue = + keyBox.query(DbKeyValue_.key.equals(key)).build().findFirst(); + return Future.value(keyValue?.value); + } + + @override + Future save({required String key, required String value}) async { + await _dbRdy; + + _objectBox.store.runInTransaction(TxMode.write, () { + // check if key already exists + final keyBox = _objectBox.store.box(); + final DbKeyValue? keyValue = + keyBox.query(DbKeyValue_.key.equals(key)).build().findFirst(); + // update + if (keyValue != null) { + keyValue.value = value; + _objectBox.store.box().put(keyValue, mode: PutMode.update); + } else { + // insert + final newKeyValue = DbKeyValue(key: key, value: value); + _objectBox.store + .box() + .put(newKeyValue, mode: PutMode.insert); + } + }); + } +} diff --git a/lib/data_layer/db/object_box_camelus/db_camelus_init.dart b/lib/data_layer/db/object_box_camelus/db_camelus_init.dart new file mode 100644 index 00000000..46527638 --- /dev/null +++ b/lib/data_layer/db/object_box_camelus/db_camelus_init.dart @@ -0,0 +1,21 @@ +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import '../../../objectbox.g.dart'; // created by `flutter pub run build_runner build` + +class DbCamelusInit { + /// The Store of this app. + late final Store store; + + DbCamelusInit._create(this.store) { + // Add any additional setup code, e.g. build queries. + } + + /// Create an instance of ObjectBox to use throughout the app. + static Future create() async { + final docsDir = await getApplicationDocumentsDirectory(); + // Future openStore() {...} is defined in the generated objectbox.g.dart + final store = + await openStore(directory: p.join(docsDir.path, "camelus-obx-default")); + return DbCamelusInit._create(store); + } +} diff --git a/lib/data_layer/db/object_box_camelus/schema/db_key_value.dart b/lib/data_layer/db/object_box_camelus/schema/db_key_value.dart new file mode 100644 index 00000000..21a794fa --- /dev/null +++ b/lib/data_layer/db/object_box_camelus/schema/db_key_value.dart @@ -0,0 +1,18 @@ +import 'package:objectbox/objectbox.dart'; + +@Entity() +class DbKeyValue { + @Id() + int dbId = 0; + + @Unique() + String key = ''; + + @Property() + String value = ''; + + DbKeyValue({ + required this.key, + required this.value, + }); +} diff --git a/lib/data_layer/db/object_box_ndk/admin/docker-compose.yaml b/lib/data_layer/db/object_box_ndk/admin/docker-compose.yaml new file mode 100644 index 00000000..cb887703 --- /dev/null +++ b/lib/data_layer/db/object_box_ndk/admin/docker-compose.yaml @@ -0,0 +1,15 @@ +version: '3.8' + +services: + admin: + image: objectboxio/admin:latest + volumes: + - ~/Documents/camelus-obx-default:/db + ports: + - "8081:8081" + user: "${UID}:${GID}" + stdin_open: true + tty: true + +# run: +# UID=$(id -u) GID=$(id -g) docker-compose up \ No newline at end of file diff --git a/lib/data_layer/db/object_box_ndk/admin/objectbox-admin.sh b/lib/data_layer/db/object_box_ndk/admin/objectbox-admin.sh new file mode 100644 index 00000000..3bd976a5 --- /dev/null +++ b/lib/data_layer/db/object_box_ndk/admin/objectbox-admin.sh @@ -0,0 +1,66 @@ +#!/bin/sh + +# ObjectBox Admin front-end shell script for Docker container version. +# For more information visit https://objectbox.io +# Detailed documentation available at https://docs.objectbox.io/data-browser + +usage() +{ + [ $# -eq 0 ] || echo "$1" 1>&2 + cat <&2 + +usage: $0 [options] [] + + ( defaults to ./objectbox ) should contain an objectbox "data.mdb" file. + +Available (optional) options: + [--port ] Mapped bind port to localhost (defaults to 8081) + +EOF + exit 1 +} + +port=8081 + +while [ $# -ne 0 ]; do + case $1 in + --help|-h) + usage + ;; + --port) + [ $# -ge 2 ] || usage + port=$2 + shift 2 + ;; + -*|--*) + usage + ;; + *) + break + ;; + esac +done + +[ $# -le 1 ] || usage + +db=${1:-.} + +echo "Objectbox Admin (Docker-Version)" +echo "" + +if [ ! -f "$db/data.mdb" ]; then + echo "NOTE: No database found at location '$db', trying default location ('$db/objectbox') .." + db=$db/objectbox +fi + +[ -f "$db/data.mdb" ] || usage "Oops.. no database file found at '$db/data.mdb'. Please check the path to the database directory." + +# make db an absolute path and resolve symbolic links +db=$( cd "$db" ; pwd -P ) + +echo "Found database at local location '${db}'." +echo "Open http://127.0.0.1:${port} in a browser." +echo "Once done, hit CTRL+C to stop 'ObjectBox Admin' Docker container." +echo "==================================================================" + +docker run --rm -it -v "$db:/db" -u $(id -u):$(id -g) -p ${port}:8081 objectboxio/admin:latest || usage diff --git a/lib/data_layer/db/object_box_ndk/db_init_object_box.dart b/lib/data_layer/db/object_box_ndk/db_init_object_box.dart new file mode 100644 index 00000000..3955bf5c --- /dev/null +++ b/lib/data_layer/db/object_box_ndk/db_init_object_box.dart @@ -0,0 +1,21 @@ +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import '../../../objectbox.g.dart'; // created by `flutter pub run build_runner build` + +class ObjectBoxInit { + /// The Store of this app. + late final Store store; + + ObjectBoxInit._create(this.store) { + // Add any additional setup code, e.g. build queries. + } + + /// Create an instance of ObjectBox to use throughout the app. + static Future create() async { + final docsDir = await getApplicationDocumentsDirectory(); + // Future openStore() {...} is defined in the generated objectbox.g.dart + final store = + await openStore(directory: p.join(docsDir.path, "camelus-obx-ndk")); + return ObjectBoxInit._create(store); + } +} diff --git a/lib/data_layer/db/object_box_ndk/db_object_box.dart b/lib/data_layer/db/object_box_ndk/db_object_box.dart new file mode 100644 index 00000000..6e4c99ba --- /dev/null +++ b/lib/data_layer/db/object_box_ndk/db_object_box.dart @@ -0,0 +1,334 @@ +import 'dart:async'; + +import 'package:ndk/domain_layer/entities/user_relay_list.dart'; +import 'package:ndk/ndk.dart'; +import 'package:ndk/shared/nips/nip05/nip05.dart'; + +import '../../../objectbox.g.dart'; +import 'db_init_object_box.dart'; +import 'schema/db_contact_list.dart'; +import 'schema/db_metadata.dart'; +import 'schema/db_nip_01_event.dart'; + +class DbObjectBox implements CacheManager { + final Completer _initCompleter = Completer(); + Future get _dbRdy => _initCompleter.future; + late ObjectBoxInit _objectBox; + + DbObjectBox() { + _init(); + } + + Future _init() async { + final objectbox = await ObjectBoxInit.create(); + _objectBox = objectbox; + _initCompleter.complete(); + } + + @override + Future loadContactList(String pubKey) async { + await _dbRdy; + final contactListBox = _objectBox.store.box(); + final existingContact = contactListBox + .query(DbContactList_.pubKey.equals(pubKey)) + .order(DbContactList_.createdAt, flags: Order.descending) + .build() + .findFirst(); + if (existingContact == null) { + return null; + } + return existingContact.toNdk(); + } + + @override + Future loadEvent(String id) async { + await _dbRdy; + final eventBox = _objectBox.store.box(); + final existingEvent = + eventBox.query(DbNip01Event_.nostrId.equals(id)).build().findFirst(); + if (existingEvent == null) { + return null; + } + return existingEvent.toNdk(); + } + + @override + Future> loadEvents({ + List? pubKeys, + List? kinds, + String? pTag, + int? since, + int? until, + }) async { + await _dbRdy; + final eventBox = _objectBox.store.box(); + + var query = eventBox.query(DbNip01Event_.pubKey + .oneOf(pubKeys!) + .and(DbNip01Event_.kind.oneOf(kinds!))); + + query = query.order(DbNip01Event_.createdAt, flags: Order.descending); + + final foundDb = query.build().find(); + + final foundValid = foundDb.where((event) { + if (pTag != null && !event.pTags.contains(pTag)) { + return false; + } + + if (since != null && event.createdAt < since) { + return false; + } + + if (until != null && event.createdAt > until) { + return false; + } + + return true; + }).toList(); + + return foundValid.map((dbEvent) => dbEvent.toNdk()).toList(); + } + + @override + Future loadMetadata(String pubKey) async { + await _dbRdy; + final metadataBox = _objectBox.store.box(); + final existingMetadata = metadataBox + .query(DbMetadata_.pubKey.equals(pubKey)) + .order(DbMetadata_.updatedAt, flags: Order.descending) + .build() + .findFirst(); + if (existingMetadata == null) { + return null; + } + return existingMetadata.toNdk(); + } + + @override + Future> loadMetadatas(List pubKeys) async { + await _dbRdy; + final metadataBox = _objectBox.store.box(); + final existingMetadatas = metadataBox + .query(DbMetadata_.pubKey.oneOf(pubKeys)) + .order(DbMetadata_.updatedAt, flags: Order.descending) + .build() + .find(); + return existingMetadatas.map((dbMetadata) => dbMetadata.toNdk()).toList(); + } + + @override + Future removeAllContactLists() async { + await _dbRdy; + final contactListBox = _objectBox.store.box(); + contactListBox.removeAll(); + } + + @override + Future removeAllEvents() async { + await _dbRdy; + final eventBox = _objectBox.store.box(); + eventBox.removeAll(); + } + + @override + Future removeAllEventsByPubKey(String pubKey) async { + await _dbRdy; + final eventBox = _objectBox.store.box(); + final events = + eventBox.query(DbNip01Event_.pubKey.equals(pubKey)).build().find(); + eventBox.removeMany(events.map((e) => e.dbId).toList()); + } + + @override + Future removeAllMetadatas() async { + await _dbRdy; + final metadataBox = _objectBox.store.box(); + metadataBox.removeAll(); + } + + @override + Future saveContactList(ContactList contactList) async { + await _dbRdy; + final contactListBox = _objectBox.store.box(); + final existingContact = contactListBox + .query(DbContactList_.pubKey.equals(contactList.pubKey)) + .order(DbContactList_.createdAt, flags: Order.descending) + .build() + .findFirst(); + if (existingContact != null) { + contactListBox.remove(existingContact.dbId); + } + contactListBox.put(DbContactList.fromNdk(contactList)); + } + + @override + Future saveContactLists(List contactLists) async { + await _dbRdy; + final contactListBox = _objectBox.store.box(); + contactListBox + .putMany(contactLists.map((cl) => DbContactList.fromNdk(cl)).toList()); + } + + @override + Future saveEvent(Nip01Event event) async { + await _dbRdy; + final eventBox = _objectBox.store.box(); + final existingEvent = eventBox + .query(DbNip01Event_.nostrId.equals(event.id)) + .build() + .findFirst(); + if (existingEvent != null) { + eventBox.remove(existingEvent.dbId); + } + eventBox.put(DbNip01Event.fromNdk(event)); + } + + @override + Future saveEvents(List events) async { + await _dbRdy; + final eventBox = _objectBox.store.box(); + eventBox.putMany(events.map((e) => DbNip01Event.fromNdk(e)).toList()); + } + + @override + Future saveMetadata(Metadata metadata) async { + await _dbRdy; + final metadataBox = _objectBox.store.box(); + final existingMetadatas = metadataBox + .query(DbMetadata_.pubKey.equals(metadata.pubKey)) + .order(DbMetadata_.updatedAt, flags: Order.descending) + .build() + .find(); + if (existingMetadatas.length > 1) { + metadataBox.removeMany(existingMetadatas.map((e) => e.dbId).toList()); + } + if (existingMetadatas.isNotEmpty && + metadata.updatedAt! < existingMetadatas[0].updatedAt!) { + return; + } + metadataBox.put(DbMetadata.fromNdk(metadata)); + } + + @override + Future saveMetadatas(List metadatas) async { + await _dbRdy; + for (final metadata in metadatas) { + await saveMetadata(metadata); + } + } + + @override + Future loadNip05(String pubKey) { + // TODO: implement loadNip05 + throw UnimplementedError(); + } + + @override + Future> loadNip05s(List pubKeys) { + // TODO: implement loadNip05s + throw UnimplementedError(); + } + + @override + Future loadRelaySet(String name, String pubKey) { + // TODO: implement loadRelaySet + throw UnimplementedError(); + } + + @override + Future loadUserRelayList(String pubKey) { + // TODO: implement loadUserRelayList + throw UnimplementedError(); + } + + @override + Future removeAllNip05s() { + // TODO: implement removeAllNip05s + throw UnimplementedError(); + } + + @override + Future removeAllRelaySets() { + // TODO: implement removeAllRelaySets + throw UnimplementedError(); + } + + @override + Future removeAllUserRelayLists() { + // TODO: implement removeAllUserRelayLists + throw UnimplementedError(); + } + + @override + Future removeContactList(String pubKey) { + // TODO: implement removeContactList + throw UnimplementedError(); + } + + @override + Future removeEvent(String id) { + // TODO: implement removeEvent + throw UnimplementedError(); + } + + @override + Future removeMetadata(String pubKey) { + // TODO: implement removeMetadata + throw UnimplementedError(); + } + + @override + Future removeNip05(String pubKey) { + // TODO: implement removeNip05 + throw UnimplementedError(); + } + + @override + Future removeRelaySet(String name, String pubKey) { + // TODO: implement removeRelaySet + throw UnimplementedError(); + } + + @override + Future removeUserRelayList(String pubKey) { + // TODO: implement removeUserRelayList + throw UnimplementedError(); + } + + @override + Future saveNip05(Nip05 nip05) { + // TODO: implement saveNip05 + throw UnimplementedError(); + } + + @override + Future saveNip05s(List nip05s) { + // TODO: implement saveNip05s + throw UnimplementedError(); + } + + @override + Future saveRelaySet(RelaySet relaySet) { + // TODO: implement saveRelaySet + throw UnimplementedError(); + } + + @override + Future saveUserRelayList(UserRelayList userRelayList) { + // TODO: implement saveUserRelayList + throw UnimplementedError(); + } + + @override + Future saveUserRelayLists(List userRelayLists) { + // TODO: implement saveUserRelayLists + throw UnimplementedError(); + } + + @override + Future> searchMetadatas(String search, int limit) { + // TODO: implement searchMetadatas + throw UnimplementedError(); + } +} diff --git a/lib/data_layer/db/object_box_ndk/schema/db_contact_list.dart b/lib/data_layer/db/object_box_ndk/schema/db_contact_list.dart new file mode 100644 index 00000000..35a11410 --- /dev/null +++ b/lib/data_layer/db/object_box_ndk/schema/db_contact_list.dart @@ -0,0 +1,77 @@ +import 'package:objectbox/objectbox.dart'; +import 'package:ndk/entities.dart' as ndk_entities; + +@Entity() +class DbContactList { + @Id() + int dbId = 0; + + @Property() + late String pubKey; + + @Property() + List contacts = []; + + @Property() + List contactRelays = []; + + @Property() + List petnames = []; + + @Property() + List followedTags = []; + + @Property() + List followedCommunities = []; + + @Property() + List followedEvents = []; + + @Property() + int createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + @Property() + int? loadedTimestamp; + + @Property() + List sources = []; + + DbContactList({ + required this.pubKey, + required this.contacts, + }); + + ndk_entities.ContactList toNdk() { + final ndkContactList = ndk_entities.ContactList( + pubKey: pubKey, + contacts: contacts, + ); + + ndkContactList.contactRelays = contactRelays; + ndkContactList.petnames = petnames; + ndkContactList.followedTags = followedTags; + ndkContactList.followedCommunities = followedCommunities; + ndkContactList.followedEvents = followedEvents; + ndkContactList.sources = sources; + ndkContactList.createdAt = createdAt; + ndkContactList.loadedTimestamp = loadedTimestamp; + + return ndkContactList; + } + + factory DbContactList.fromNdk(ndk_entities.ContactList ndkContactList) { + final dbContactList = DbContactList( + pubKey: ndkContactList.pubKey, + contacts: ndkContactList.contacts, + ); + dbContactList.contactRelays = ndkContactList.contactRelays; + dbContactList.petnames = ndkContactList.petnames; + dbContactList.followedTags = ndkContactList.followedTags; + dbContactList.followedCommunities = ndkContactList.followedCommunities; + dbContactList.followedEvents = ndkContactList.followedEvents; + dbContactList.sources = ndkContactList.sources; + dbContactList.createdAt = ndkContactList.createdAt; + dbContactList.loadedTimestamp = ndkContactList.loadedTimestamp; + return dbContactList; + } +} diff --git a/lib/data_layer/db/object_box_ndk/schema/db_metadata.dart b/lib/data_layer/db/object_box_ndk/schema/db_metadata.dart new file mode 100644 index 00000000..6fdc73ad --- /dev/null +++ b/lib/data_layer/db/object_box_ndk/schema/db_metadata.dart @@ -0,0 +1,149 @@ +import 'package:objectbox/objectbox.dart'; +import 'package:ndk/entities.dart' as ndk_entities; + +@Entity() +class DbMetadata { + @Id() + int dbId = 0; + + static const int KIND = 0; + + @Property() + late String pubKey; + + @Property() + String? name; + + @Property() + String? displayName; + + @Property() + String? picture; + + @Property() + String? banner; + + @Property() + String? website; + + @Property() + String? about; + + @Property() + String? nip05; + + @Property() + String? lud16; + + @Property() + String? lud06; + + @Property() + int? updatedAt; + + @Property() + int? refreshedTimestamp; + + DbMetadata( + {this.pubKey = "", + this.name, + this.displayName, + this.picture, + this.banner, + this.website, + this.about, + this.nip05, + this.lud16, + this.lud06, + this.updatedAt, + this.refreshedTimestamp}); + + String? get cleanNip05 { + if (nip05 != null) { + if (nip05!.startsWith("_@")) { + return nip05!.trim().toLowerCase().replaceAll("_@", "@"); + } + return nip05!.trim().toLowerCase(); + } + return null; + } + + Map toFullJson() { + var data = toJson(); + data['pub_key'] = pubKey; + return data; + } + + Map toJson() { + final Map data = {}; + data['name'] = name; + data['display_name'] = displayName; + data['picture'] = picture; + data['banner'] = banner; + data['website'] = website; + data['about'] = about; + data['nip05'] = nip05; + data['lud16'] = lud16; + data['lud06'] = lud06; + return data; + } + + bool matchesSearch(String str) { + str = str.trim().toLowerCase(); + String d = displayName != null ? displayName!.toLowerCase() : ""; + String n = name != null ? name!.toLowerCase() : ""; + String str2 = " $str"; + return d.startsWith(str) || + d.contains(str2) || + n.startsWith(str) || + n.contains(str2); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DbMetadata && + runtimeType == other.runtimeType && + pubKey == other.pubKey; + + @override + int get hashCode => pubKey.hashCode; + + ndk_entities.Metadata toNdk() { + final ndkM = ndk_entities.Metadata( + pubKey: pubKey, + name: name, + displayName: displayName, + picture: picture, + banner: banner, + website: website, + about: about, + nip05: nip05, + lud16: lud16, + lud06: lud06, + updatedAt: updatedAt, + refreshedTimestamp: refreshedTimestamp, + ); + + return ndkM; + } + + factory DbMetadata.fromNdk(ndk_entities.Metadata ndkM) { + final dbM = DbMetadata( + pubKey: ndkM.pubKey, + name: ndkM.name, + displayName: ndkM.displayName, + picture: ndkM.picture, + banner: ndkM.banner, + website: ndkM.website, + about: ndkM.about, + nip05: ndkM.nip05, + lud16: ndkM.lud16, + lud06: ndkM.lud06, + updatedAt: ndkM.updatedAt, + refreshedTimestamp: ndkM.refreshedTimestamp, + ); + + return dbM; + } +} diff --git a/lib/data_layer/db/object_box_ndk/schema/db_nip_01_event.dart b/lib/data_layer/db/object_box_ndk/schema/db_nip_01_event.dart new file mode 100644 index 00000000..18e9d1ee --- /dev/null +++ b/lib/data_layer/db/object_box_ndk/schema/db_nip_01_event.dart @@ -0,0 +1,213 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:ndk/entities.dart' as ndk_entities; +import 'package:objectbox/objectbox.dart'; + +@Entity() +class DbNip01Event { + DbNip01Event({ + required this.pubKey, + required this.kind, + required this.dbTags, + required this.content, + int createdAt = 0, + }) { + this.createdAt = (createdAt == 0) + ? DateTime.now().millisecondsSinceEpoch ~/ 1000 + : createdAt; + + nostrId = + _calculateId(pubKey, this.createdAt, kind, _tagsToList(tags), content); + } + + @Id() + int dbId = 0; + + @Property() + String nostrId = ''; + + @Property() + final String pubKey; + + @Property() + late int createdAt; + + @Property() + final int kind; + + @Property() + String content; + + @Property() + String sig = ''; + + @Property() + bool? validSig; + + @Property() + List sources = []; + + @Property() + List dbTags = []; + + List get tags => dbTags.map((tag) => DbTag.fromString(tag)).toList(); + + set tags(List value) { + dbTags = value.map((tag) => tag.toString()).toList(); + } + + bool get isIdValid { + return nostrId == + _calculateId(pubKey, createdAt, kind, _tagsToList(tags), content); + } + + @override + bool operator ==(other) => other is DbNip01Event && nostrId == other.nostrId; + + @override + int get hashCode => nostrId.hashCode; + + static int secondsSinceEpoch() { + return DateTime.now().millisecondsSinceEpoch ~/ 1000; + } + + static String _calculateId(String publicKey, int createdAt, int kind, + List> tags, String content) { + final jsonData = + json.encode([0, publicKey, createdAt, kind, tags, content]); + final bytes = utf8.encode(jsonData); + final digest = sha256.convert(bytes); + return digest.toString(); + } + + String? getEId() { + for (var tag in tags) { + if (tag.key == "e") { + return tag.value; + } + } + return null; + } + + static List getTags(List list, String tagKey) { + return list + .where((tag) => tag.key == tagKey) + .map((tag) => tag.value.trim().toLowerCase()) + .toList(); + } + + List get tTags { + return getTags(tags, "t"); + } + + List get pTags { + return getTags(tags, "p"); + } + + List get replyETags { + return tags + .where((tag) => tag.key == "e" && tag.marker == "reply") + .map((tag) => tag.value.trim().toLowerCase()) + .toList(); + } + + String? getDtag() { + for (var tag in tags) { + if (tag.key == "d") { + return tag.value; + } + } + return null; + } + + @override + String toString() { + return 'Nip01Event{pubKey: $pubKey, createdAt: $createdAt, kind: $kind, tags: $tags, content: $content, sources: $sources}'; + } + + ndk_entities.Nip01Event toNdk() { + final ndkE = ndk_entities.Nip01Event( + pubKey: pubKey, + content: content, + createdAt: createdAt, + kind: kind, + tags: _tagsToList(tags), + ); + ndkE.id = nostrId; + ndkE.sig = sig; + ndkE.validSig = validSig; + ndkE.sources = sources; + return ndkE; + } + + factory DbNip01Event.fromNdk(ndk_entities.Nip01Event ndkE) { + final dbE = DbNip01Event( + pubKey: ndkE.pubKey, + content: ndkE.content, + createdAt: ndkE.createdAt, + kind: ndkE.kind, + dbTags: _listToTags(ndkE.tags).map((tag) => tag.toString()).toList(), + ); + dbE.nostrId = ndkE.id; + dbE.sig = ndkE.sig; + dbE.validSig = ndkE.validSig; + dbE.sources = ndkE.sources; + return dbE; + } + + static List> _tagsToList(List tags) { + return tags.map((tag) => tag.toList()).toList(); + } + + static List _listToTags(List> list) { + return list.map((tagList) => DbTag.fromList(tagList)).toList(); + } +} + +@Entity() +class DbTag { + @Id() + int id = 0; + + @Property() + String key; + + @Property() + String value; + + @Property() + String? marker; + + DbTag({this.key = '', this.value = '', this.marker}); + + List toList() { + return marker != null ? [key, value, '', marker!] : [key, value]; + } + + static DbTag fromList(List list) { + return DbTag( + key: list[0], + value: list[1], + marker: list.length >= 4 ? list[3] : null, + ); + } + + @override + String toString() { + return json.encode({ + 'key': key, + 'value': value, + 'marker': marker, + }); + } + + factory DbTag.fromString(String jsonString) { + final Map data = json.decode(jsonString); + return DbTag( + key: data['key'], + value: data['value'], + marker: data['marker'], + ); + } +} diff --git a/lib/data_layer/models/app_update_model.dart b/lib/data_layer/models/app_update_model.dart new file mode 100644 index 00000000..462b9930 --- /dev/null +++ b/lib/data_layer/models/app_update_model.dart @@ -0,0 +1,20 @@ +import 'package:camelus/domain_layer/entities/app_update.dart'; + +class AppUpdateModel extends AppUpdate { + AppUpdateModel({ + super.currentVersion = 0, + required super.latestVersion, + required super.title, + required super.body, + required super.url, + }); + + factory AppUpdateModel.fromJson(Map json) { + return AppUpdateModel( + latestVersion: json['version'], + title: json['title'], + url: json['url'], + body: json['body'], + ); + } +} diff --git a/lib/data_layer/models/contact_list_model.dart b/lib/data_layer/models/contact_list_model.dart new file mode 100644 index 00000000..fc838b26 --- /dev/null +++ b/lib/data_layer/models/contact_list_model.dart @@ -0,0 +1,65 @@ +import 'package:camelus/domain_layer/entities/contact_list.dart'; +import 'package:ndk/entities.dart' as ndk_entities; + +class ContactListModel extends ContactList { + ContactListModel({ + required super.pubKey, + required super.contacts, + required super.contactRelays, + required super.petnames, + required super.followedTags, + required super.followedCommunities, + required super.followedEvents, + required super.sources, + required super.createdAt, + required super.loadedTimestamp, + }); + + factory ContactListModel.fromNdk(ndk_entities.ContactList ndkContactList) { + return ContactListModel( + pubKey: ndkContactList.pubKey, + contacts: ndkContactList.contacts, + contactRelays: ndkContactList.contactRelays, + petnames: ndkContactList.petnames, + followedTags: ndkContactList.followedTags, + followedCommunities: ndkContactList.followedCommunities, + followedEvents: ndkContactList.followedEvents, + sources: ndkContactList.sources, + createdAt: ndkContactList.createdAt, + loadedTimestamp: ndkContactList.loadedTimestamp, + ); + } + + ndk_entities.ContactList toNdk() { + final ndkContactList = ndk_entities.ContactList( + pubKey: pubKey, + contacts: contacts, + ); + + ndkContactList.contactRelays = contactRelays; + ndkContactList.petnames = petnames; + ndkContactList.followedTags = followedTags; + ndkContactList.followedCommunities = followedCommunities; + ndkContactList.followedEvents = followedEvents; + ndkContactList.sources = sources; + ndkContactList.createdAt = createdAt; + ndkContactList.loadedTimestamp = loadedTimestamp; + + return ndkContactList; + } + + factory ContactListModel.fromContactList(ContactList contactList) { + return ContactListModel( + pubKey: contactList.pubKey, + contacts: contactList.contacts, + contactRelays: contactList.contactRelays, + petnames: contactList.petnames, + followedTags: contactList.followedTags, + followedCommunities: contactList.followedCommunities, + followedEvents: contactList.followedEvents, + sources: contactList.sources, + createdAt: contactList.createdAt, + loadedTimestamp: contactList.loadedTimestamp, + ); + } +} diff --git a/lib/data_layer/models/nip05_model.dart b/lib/data_layer/models/nip05_model.dart new file mode 100644 index 00000000..b7796463 --- /dev/null +++ b/lib/data_layer/models/nip05_model.dart @@ -0,0 +1,10 @@ +import 'package:camelus/domain_layer/entities/nip05.dart'; + +class Nip05Model extends Nip05 { + Nip05Model({ + required super.nip05, + required super.valid, + super.lastCheck, + super.relays, + }); +} diff --git a/lib/data_layer/models/nostr_band_hashtags_model.dart b/lib/data_layer/models/nostr_band_hashtags_model.dart new file mode 100644 index 00000000..02f8065d --- /dev/null +++ b/lib/data_layer/models/nostr_band_hashtags_model.dart @@ -0,0 +1,167 @@ +import 'package:camelus/domain_layer/entities/nostr_band_hashtags.dart'; + +class NostrBandHashtagsModel extends NostrBandHashtags { + NostrBandHashtagsModel({ + required super.type, + required super.hashtags, + required super.relays, + }); + + factory NostrBandHashtagsModel.fromJson(Map json) { + List hashtagsJson = json['hashtags'] ?? []; + List hashtags = []; + //cast using for loop + for (Map hashtag in hashtagsJson) { + var myHashtag = HashtagsModel.fromJson(hashtag); + // filter out some hashtags + // todo sentiment analysis + if (myHashtag.hashtag.contains('nude')) continue; + if (myHashtag.hashtag.contains('nsfw')) continue; + hashtags.add(HashtagsModel.fromJson(hashtag)); + } + + return NostrBandHashtagsModel( + type: json['type'], + hashtags: hashtags, + relays: json['relays'], + ); + } + + Map toJson() { + final Map data = {}; + data['type'] = type; + data['hashtags'] = hashtags + .map((v) => { + HashtagsModel( + hashtag: v.hashtag, + threads: v.threads, + threadsCount: v.threadsCount, + ).toJson() + }) + .toList(); + + data['relays'] = relays; + return data; + } +} + +class HashtagsModel extends Hashtags { + HashtagsModel({ + required super.hashtag, + required super.threadsCount, + required super.threads, + }); + + static Map toJsonFromInstance(HashtagsModel instance) { + return instance.toJson(); + } + + factory HashtagsModel.fromJson(Map json) { + List threadsJson = json['threads'] ?? []; + List threads = []; + //cast using for loop + for (Map thread in threadsJson) { + threads.add(ThreadsModel.fromJson(thread)); + } + + return HashtagsModel( + hashtag: json['hashtag'], + threadsCount: json['threads_count'], + threads: threads, + ); + } + + Map toJson() { + final Map data = {}; + data['hashtag'] = hashtag; + data['threads_count'] = threadsCount; + //data['threads'] = threads.map((v) => v.toJson()).toList(); + data['threads'] = threads + .map((v) => { + ThreadsModel( + author: v.author, + content: v.content, + createdAt: v.createdAt, + downvotes: v.downvotes, + id: v.id, + kind: v.kind, + lang: v.lang, + pubkey: v.pubkey, + relays: v.relays, + replyToId: v.replyToId, + reposts: v.reposts, + rootId: v.rootId, + upvotes: v.upvotes, + zappers: v.zappers, + zapAmount: v.zapAmount, + replies: v.replies, + ).toJson() + }) + .toList(); + return data; + } +} + +class ThreadsModel extends Threads { + ThreadsModel({ + required super.id, + required super.kind, + required super.pubkey, + required super.createdAt, + required super.content, + required super.replies, + required super.upvotes, + required super.downvotes, + required super.reposts, + required super.zappers, + required super.zapAmount, + required super.replyToId, + required super.rootId, + required super.lang, + required super.relays, + required super.author, + }); + + factory ThreadsModel.fromJson(Map json) { + return ThreadsModel( + id: json['id'] ?? '', + kind: json['kind'] ?? -1, + pubkey: json['pubkey'] ?? '', + createdAt: json['created_at'] ?? -1, + content: json['content'] ?? '', + replies: json['replies'] ?? 0, + upvotes: json['upvotes'] ?? 0, + downvotes: json['downvotes'] ?? 0, + reposts: json['reposts'] ?? 0, + zappers: json['zappers'] ?? 0, + zapAmount: json['zap_amount'] ?? 0, + replyToId: json['reply_to_id'] ?? '', + rootId: json['root_id'] ?? '', + lang: json['lang'] ?? '', + relays: json['relays'].cast() ?? [], + author: json['author'] ?? '', + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['kind'] = kind; + data['pubkey'] = pubkey; + data['created_at'] = createdAt; + data['content'] = content; + data['replies'] = replies; + data['upvotes'] = upvotes; + data['downvotes'] = downvotes; + data['reposts'] = reposts; + data['zappers'] = zappers; + data['zap_amount'] = zapAmount; + data['reply_to_id'] = replyToId; + data['root_id'] = rootId; + data['lang'] = lang; + data['relays'] = relays; + data['author'] = author; + + return data; + } +} diff --git a/lib/data_layer/models/nostr_band_people_model.dart b/lib/data_layer/models/nostr_band_people_model.dart new file mode 100644 index 00000000..8d54f107 --- /dev/null +++ b/lib/data_layer/models/nostr_band_people_model.dart @@ -0,0 +1,79 @@ +import 'package:camelus/data_layer/models/nostr_note_model.dart'; +import 'package:camelus/domain_layer/entities/nostr_note.dart'; + +import '../../domain_layer/entities/nostr_band_people.dart'; + +class NostrBandPeopleModel extends NostrBandPeople { + NostrBandPeopleModel({required super.profiles}); + + factory NostrBandPeopleModel.fromJson(Map json) { + List profilesJson = json['profiles'] ?? []; + List profiles = []; + //cast using for loop + for (Map profile in profilesJson) { + profiles.add(ProfilesModel.fromJson(profile)); + } + + return NostrBandPeopleModel( + profiles: profiles, + ); + } + + Map toJson() { + final Map data = {}; + data['profiles'] = profiles + .map((v) => { + ProfilesModel( + newFollowersCount: v.newFollowersCount, + profile: v.profile, + pubkey: v.pubkey, + relays: v.relays, + ).toJson() + }) + .toList(); + + return data; + } +} + +class ProfilesModel extends Profiles { + ProfilesModel({ + required super.pubkey, + required super.newFollowersCount, + required super.relays, + required super.profile, + }); + + factory ProfilesModel.fromJson(Map json) { + List relaysJson = json['relays'] ?? []; + List relays = []; + //cast using for loop + for (String relay in relaysJson) { + relays.add(relay); + } + + return ProfilesModel( + pubkey: json['pubkey'], + newFollowersCount: json['new_followers_count'], + relays: relays, + profile: NostrNoteModel.fromJson(json['profile']), + ); + } + + Map toJson() { + final Map data = {}; + data['pubkey'] = pubkey; + data['new_followers_count'] = newFollowersCount; + data['relays'] = relays; + data['profile'] = NostrNoteModel( + id: super.profile.id, + pubkey: super.profile.pubkey, + created_at: super.profile.created_at, + kind: super.profile.kind, + content: super.profile.content, + sig: super.profile.sig, + tags: super.profile.tags, + ).toJson(); + return data; + } +} diff --git a/lib/data_layer/models/nostr_note_model.dart b/lib/data_layer/models/nostr_note_model.dart new file mode 100644 index 00000000..b4468b85 --- /dev/null +++ b/lib/data_layer/models/nostr_note_model.dart @@ -0,0 +1,71 @@ +import 'package:ndk/entities.dart'; + +import '../../domain_layer/entities/nostr_note.dart'; +import 'nostr_tag_model.dart'; + +class NostrNoteModel extends NostrNote { + NostrNoteModel({ + required super.id, + required super.pubkey, + required super.created_at, + required super.kind, + required super.content, + required super.sig, + required super.tags, + super.sig_valid, + }); + + factory NostrNoteModel.fromJson(Map json) { + List tagsJson = json['tags'] ?? []; + List> tags = []; + //cast using for loop + for (List tag in tagsJson) { + tags.add(tag.cast()); + } + + return NostrNoteModel( + id: json['id'], + pubkey: json['pubkey'], + created_at: json['created_at'], + kind: json['kind'], + content: json['content'], + sig: json['sig'], + tags: tags.map((tag) => NostrTagModel.fromJson(tag)).toList(), + ); + } + + factory NostrNoteModel.fromNDKEvent(Nip01Event nip01event) { + // sanitize tags + final sanitizedTags = nip01event.tags.where((tags) { + // Assuming tags are in 'tags' key + + if (tags == null) return false; // Or handle null tags differently + + return tags is List && tags.isNotEmpty; + }).toList(); + + final myTags = + sanitizedTags.map((tag) => NostrTagModel.fromJson(tag)).toList(); + + return NostrNoteModel( + id: nip01event.id, + pubkey: nip01event.pubKey, + created_at: nip01event.createdAt, + kind: nip01event.kind, + content: nip01event.content, + sig: nip01event.sig, + tags: myTags, + sig_valid: nip01event.validSig, + ); + } + + Map toJson() => { + 'id': id, + 'pubkey': pubkey, + 'created_at': created_at, + 'kind': kind, + 'content': content, + 'sig': sig, + 'tags': tags + }; +} diff --git a/lib/models/nostr_request.dart b/lib/data_layer/models/nostr_request.dart similarity index 100% rename from lib/models/nostr_request.dart rename to lib/data_layer/models/nostr_request.dart diff --git a/lib/models/nostr_request_close.dart b/lib/data_layer/models/nostr_request_close.dart similarity index 85% rename from lib/models/nostr_request_close.dart rename to lib/data_layer/models/nostr_request_close.dart index 7e395a50..6a225450 100644 --- a/lib/models/nostr_request_close.dart +++ b/lib/data_layer/models/nostr_request_close.dart @@ -1,6 +1,6 @@ import 'dart:convert'; -import 'package:camelus/models/nostr_request.dart'; +import 'package:camelus/data_layer/models/nostr_request.dart'; class NostrRequestClose implements NostrRequest { final String type = "CLOSE"; diff --git a/lib/models/nostr_request_query.dart b/lib/data_layer/models/nostr_request_query.dart similarity index 98% rename from lib/models/nostr_request_query.dart rename to lib/data_layer/models/nostr_request_query.dart index 17701eb3..198dc32a 100644 --- a/lib/models/nostr_request_query.dart +++ b/lib/data_layer/models/nostr_request_query.dart @@ -1,5 +1,5 @@ import 'dart:convert'; -import 'package:camelus/models/nostr_request.dart'; +import 'package:camelus/data_layer/models/nostr_request.dart'; class NostrRequestQuery implements NostrRequest { final String type = "REQ"; diff --git a/lib/data_layer/models/nostr_tag_model.dart b/lib/data_layer/models/nostr_tag_model.dart new file mode 100644 index 00000000..8e16eb8f --- /dev/null +++ b/lib/data_layer/models/nostr_tag_model.dart @@ -0,0 +1,29 @@ +import 'package:camelus/domain_layer/entities/nostr_tag.dart'; + +class NostrTagModel extends NostrTag { + NostrTagModel({ + required super.type, + required super.value, + super.recommended_relay, + super.marker, + }); + + // ["e", <32-bytes hex of the id of another event>, , ] + factory NostrTagModel.fromJson(List? json) { + if (json == null || json.isEmpty) { + throw ArgumentError("Invalid tag format: Tag cannot be empty"); + } + + final type = json[0]; + final value = json.length > 1 ? json[1] : null; + final recommendedRelay = json.length > 2 ? json[2] : null; + final marker = json.length > 3 ? json[3] : null; + + return NostrTagModel( + type: type, + value: value, + recommended_relay: recommendedRelay, + marker: marker, + ); + } +} diff --git a/lib/models/post_context.dart b/lib/data_layer/models/post_context.dart similarity index 69% rename from lib/models/post_context.dart rename to lib/data_layer/models/post_context.dart index 3b510384..9e3c1627 100644 --- a/lib/models/post_context.dart +++ b/lib/data_layer/models/post_context.dart @@ -1,4 +1,4 @@ -import 'package:camelus/models/nostr_note.dart'; +import 'package:camelus/domain_layer/entities/nostr_note.dart'; class PostContext { NostrNote replyToNote; diff --git a/lib/models/tweet_control.dart b/lib/data_layer/models/tweet_control.dart similarity index 100% rename from lib/models/tweet_control.dart rename to lib/data_layer/models/tweet_control.dart diff --git a/lib/data_layer/models/user_metadata_model.dart b/lib/data_layer/models/user_metadata_model.dart new file mode 100644 index 00000000..d24a366b --- /dev/null +++ b/lib/data_layer/models/user_metadata_model.dart @@ -0,0 +1,88 @@ +import 'dart:convert'; +import 'package:ndk/entities.dart' as ndk_entities; + +import '../../domain_layer/entities/user_metadata.dart'; + +class UserMetadataModel extends UserMetadata { + UserMetadataModel({ + required super.eventId, + required super.pubkey, + required super.lastFetch, + super.picture, + super.banner, + super.name, + super.nip05, + super.about, + super.website, + super.lud06, + super.lud16, + }); + + factory UserMetadataModel.fromNDKEvent(ndk_entities.Nip01Event event) { + final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + final contentJson = jsonDecode(event.content); + + return UserMetadataModel( + eventId: event.id, + pubkey: event.pubKey, + lastFetch: now, + picture: contentJson['picture'], + banner: contentJson['banner'], + name: contentJson['name'], + nip05: contentJson['nip05'], + about: contentJson['about'], + website: contentJson['website'], + lud06: contentJson['lud06'], + lud16: contentJson['lud16'], + ); + } + + factory UserMetadataModel.fromNDKMetadata( + ndk_entities.Metadata metadata, + ) { + return UserMetadataModel( + eventId: metadata.hashCode.toString(), + pubkey: metadata.pubKey, + lastFetch: metadata.refreshedTimestamp ?? 0, + picture: metadata.picture, + banner: metadata.banner, + name: metadata.name, + nip05: metadata.nip05, + about: metadata.about, + website: metadata.website, + lud06: metadata.lud06, + lud16: metadata.lud16, + ); + } + + ndk_entities.Metadata toNDKMetadata() { + return ndk_entities.Metadata( + pubKey: pubkey, + picture: picture, + banner: banner, + name: name, + nip05: nip05, + about: about, + website: website, + lud06: lud06, + lud16: lud16, + ); + } + + factory UserMetadataModel.fromUserMetadata(UserMetadata metadata) { + return UserMetadataModel( + eventId: metadata.eventId, + pubkey: metadata.pubkey, + lastFetch: metadata.lastFetch, + picture: metadata.picture, + banner: metadata.banner, + name: metadata.name, + nip05: metadata.nip05, + about: metadata.about, + website: metadata.website, + lud06: metadata.lud06, + lud16: metadata.lud16, + ); + } +} diff --git a/lib/data_layer/repositories/app_update_repository_impl.dart b/lib/data_layer/repositories/app_update_repository_impl.dart new file mode 100644 index 00000000..0e728a61 --- /dev/null +++ b/lib/data_layer/repositories/app_update_repository_impl.dart @@ -0,0 +1,23 @@ +import 'package:camelus/config/app_update_config.dart'; +import 'package:camelus/data_layer/models/app_update_model.dart'; +import 'package:camelus/domain_layer/entities/app_update.dart'; +import 'package:camelus/domain_layer/repositories/app_update_repository.dart'; + +import '../data_sources/http_request_data_source.dart'; + +class AppUpdateRepositoryImpl implements AppUpdateRepository { + final HttpRequestDataSource httpJsonDataSource; + + AppUpdateRepositoryImpl({required this.httpJsonDataSource}); + + @override + Future checkAppUpdate() async { + final json = + await httpJsonDataSource.jsonRequest(AppUpdateConfig.appUpdateCheckUrl); + + final myUpdate = AppUpdateModel.fromJson(json); + myUpdate.currentVersion = await AppUpdateConfig.getBuildNumber(); + + return myUpdate; + } +} diff --git a/lib/data_layer/repositories/database_repository_impl.dart b/lib/data_layer/repositories/database_repository_impl.dart new file mode 100644 index 00000000..cab952cd --- /dev/null +++ b/lib/data_layer/repositories/database_repository_impl.dart @@ -0,0 +1,18 @@ +import '../../domain_layer/entities/nip05.dart'; +import '../../domain_layer/repositories/database_repository.dart'; + +class DatabaseRepositoryImpl implements DatabaseRepository { + //final DatabaseProvider databaseProvider; + + //DatabaseRepositoryImpl({required this.databaseProvider}); + + @override + Future getNip05(String nip05) { + throw UnimplementedError(); + } + + @override + Future setNip05(Nip05 nip05) { + throw UnimplementedError(); + } +} diff --git a/lib/data_layer/repositories/edit_relays_repository_impl.dart b/lib/data_layer/repositories/edit_relays_repository_impl.dart new file mode 100644 index 00000000..20e5b60b --- /dev/null +++ b/lib/data_layer/repositories/edit_relays_repository_impl.dart @@ -0,0 +1,38 @@ +import 'package:ndk/ndk.dart' as ndk; + +import '../../domain_layer/entities/relay.dart'; +import '../../domain_layer/repositories/edit_relays_repository.dart'; +import '../data_sources/dart_ndk_source.dart'; + +class EditRelaysRepositoryImpl implements EditRelaysRepository { + final DartNdkSource dartNdkSource; + final ndk.EventVerifier eventVerifier; + + EditRelaysRepositoryImpl({ + required this.dartNdkSource, + required this.eventVerifier, + }); + + @override + Future> getRelays(String pubkey) { + throw UnimplementedError(); + } + + @override + Future> getRelayHintsInbox(String pubkey) { + // TODO: implement getRelayHintsInbox + throw UnimplementedError(); + } + + @override + Future> getRelayHintsOutbox(String pubkey) { + // TODO: implement getRelayHintsOutbox + throw UnimplementedError(); + } + + @override + Future saveRelays(String pubkey, List relays) { + // TODO: implement saveRelays + throw UnimplementedError(); + } +} diff --git a/lib/data_layer/repositories/file_upload_repository_impl.dart b/lib/data_layer/repositories/file_upload_repository_impl.dart new file mode 100644 index 00000000..58c5fc86 --- /dev/null +++ b/lib/data_layer/repositories/file_upload_repository_impl.dart @@ -0,0 +1,34 @@ +import 'dart:io'; + +import 'package:camelus/data_layer/data_sources/nostr_build_file_upload.dart'; +import 'package:camelus/domain_layer/entities/mem_file.dart'; +import 'package:camelus/domain_layer/repositories/upload_file_repository.dart'; +import 'package:mime/mime.dart'; + +class FileUploadRepositoryImpl implements FileUploadRepository { + final NostrBuildFileUpload nostrBuildFileUpload; + + FileUploadRepositoryImpl({ + required this.nostrBuildFileUpload, + }); + + @override + Future uploadImage(MemFile memFile) { + return nostrBuildFileUpload.uploadImage(memFile); + } + + @override + Future uploadImageFile(File file) async { + var bytes = await file.readAsBytes(); + var mimeType = lookupMimeType(file.path); + var filename = file.path.split('/').last; + if (mimeType == null || mimeType.isEmpty) { + throw Exception("No mime type found"); + } + return uploadImage(MemFile( + bytes: bytes, + mimeType: mimeType, + name: filename, + )); + } +} diff --git a/lib/data_layer/repositories/follow_repository_impl.dart b/lib/data_layer/repositories/follow_repository_impl.dart new file mode 100644 index 00000000..6c453954 --- /dev/null +++ b/lib/data_layer/repositories/follow_repository_impl.dart @@ -0,0 +1,81 @@ +import 'package:ndk/entities.dart' as ndk_entities; +import 'package:ndk/ndk.dart' as dart_ndk; + +import '../../domain_layer/entities/contact_list.dart'; +import '../../domain_layer/repositories/follow_repository.dart'; +import '../data_sources/dart_ndk_source.dart'; +import '../models/contact_list_model.dart'; + +class FollowRepositoryImpl implements FollowRepository { + final DartNdkSource dartNdkSource; + final dart_ndk.EventVerifier eventVerifier; + final dart_ndk.EventSigner? eventSigner; + + FollowRepositoryImpl({ + required this.dartNdkSource, + required this.eventVerifier, + required this.eventSigner, + }); + + @override + Future getContacts(String npub, {int? timeout}) async { + final contactListNdk = + await dartNdkSource.dartNdk.follows.getContactList(npub); + + if (contactListNdk == null) { + return null; + } + + return ContactListModel.fromNdk(contactListNdk); + } + + @override + Stream getContactsStream(String npub) { + final ndkResponse = dartNdkSource.dartNdk.follows.getContactList(npub); + + return ndkResponse.asStream().map((ndkContactList) { + return ContactListModel.fromNdk(ndkContactList!); + }); + } + + @override + Stream> getFollowers(String npub) { + // TODO: implement getFollowers + throw UnimplementedError(); + } + + @override + Future isFollowing(String npub) { + // TODO: implement isFollowing + throw UnimplementedError(); + } + + @override + Future setFollowing(ContactList contactList) { + final contactListModel = ContactListModel.fromContactList(contactList); + final ndkContactList = contactListModel.toNdk(); + + return dartNdkSource.dartNdk.follows + .broadcastSetContactList(ndkContactList); + } + + @override + Future followUser(String npub) async { + final ndk_entities.ContactList newContactList = + await dartNdkSource.dartNdk.follows.broadcastAddContact(npub); + + return ContactListModel.fromNdk(newContactList); + } + + @override + Future unfollowUser(String npub) async { + final ndk_entities.ContactList? newContactList = + await dartNdkSource.dartNdk.follows.broadcastRemoveContact(npub); + + if (newContactList == null) { + return null; + } + + return ContactListModel.fromNdk(newContactList); + } +} diff --git a/lib/data_layer/repositories/metadata_repository_impl.dart b/lib/data_layer/repositories/metadata_repository_impl.dart new file mode 100644 index 00000000..5ab1c49a --- /dev/null +++ b/lib/data_layer/repositories/metadata_repository_impl.dart @@ -0,0 +1,40 @@ +import 'package:ndk/entities.dart' as ndk_entities; +import 'package:ndk/ndk.dart' as ndk; + +import '../../domain_layer/entities/user_metadata.dart'; +import '../../domain_layer/repositories/metadata_repository.dart'; +import '../data_sources/dart_ndk_source.dart'; +import '../models/user_metadata_model.dart'; + +class MetadataRepositoryImpl implements MetadataRepository { + final DartNdkSource dartNdkSource; + final ndk.EventVerifier eventVerifier; + + MetadataRepositoryImpl({ + required this.dartNdkSource, + required this.eventVerifier, + }); + + @override + Stream getMetadataByPubkey(String pubkey) { + final myMetadata = dartNdkSource.dartNdk.metadata.loadMetadata(pubkey); + + final Stream myMetadataStream = + myMetadata.asStream(); + + return myMetadataStream.where((event) => event != null).map( + (event) => UserMetadataModel.fromNDKMetadata(event!), + ); + } + + @override + Future broadcastMetadata(UserMetadata metadata) async { + final myMetadataModel = UserMetadataModel.fromUserMetadata(metadata); + + final ndkMetadata = myMetadataModel.toNDKMetadata(); + + final result = + await dartNdkSource.dartNdk.metadata.broadcastMetadata(ndkMetadata); + return UserMetadataModel.fromNDKMetadata(result); + } +} diff --git a/lib/data_layer/repositories/nip05_repository_impl.dart b/lib/data_layer/repositories/nip05_repository_impl.dart new file mode 100644 index 00000000..b9b5188f --- /dev/null +++ b/lib/data_layer/repositories/nip05_repository_impl.dart @@ -0,0 +1,41 @@ +import 'package:camelus/data_layer/models/nip05_model.dart'; +import 'package:camelus/domain_layer/entities/nip05.dart'; + +import '../../domain_layer/repositories/nip05_repository.dart'; +import '../data_sources/http_request_data_source.dart'; + +class Nip05RepositoryImpl implements Nip05Repository { + final HttpRequestDataSource dataSource; + + Nip05RepositoryImpl({required this.dataSource}); + + @override + Future requestNip05(String nip05, String pubkey) async { + String username = nip05.split("@")[0]; + String url = nip05.split("@")[1]; + + String myUrl = "https://$url/.well-known/nostr.json?name=$username"; + + final json = await dataSource.jsonRequest(myUrl); + + Map names = json["names"]; + + Map relays = json["relays"] ?? {}; + + List pRelays = []; + if (relays[pubkey] != null) { + pRelays = List.from(relays[pubkey]); + } + + bool valid = names[username] == pubkey; + + var result = Nip05Model( + nip05: nip05, + valid: valid, + lastCheck: DateTime.now().millisecondsSinceEpoch ~/ 1000, + relays: pRelays, + ); + + return result; + } +} diff --git a/lib/data_layer/repositories/nostr_band_repository_impl.dart b/lib/data_layer/repositories/nostr_band_repository_impl.dart new file mode 100644 index 00000000..d62f62e7 --- /dev/null +++ b/lib/data_layer/repositories/nostr_band_repository_impl.dart @@ -0,0 +1,22 @@ +import 'package:camelus/data_layer/data_sources/api_nostr_band_data_source.dart'; +import 'package:camelus/domain_layer/entities/nostr_band_hashtags.dart'; +import 'package:camelus/domain_layer/entities/nostr_band_people.dart'; +import 'package:camelus/domain_layer/repositories/nostr_band_repository.dart'; + +class NostrBandRepositoryImpl implements NostrBandRepository { + final ApiNostrBandDataSource apiNostrBandDataSource; + + NostrBandRepositoryImpl({ + required this.apiNostrBandDataSource, + }); + + @override + Future getTrendingProfiles() async { + return await apiNostrBandDataSource.getTrendingProfiles(); + } + + @override + Future getTrendingHashtags() async { + return await apiNostrBandDataSource.getTrendingHashtags(); + } +} diff --git a/lib/data_layer/repositories/note_repository_impl.dart b/lib/data_layer/repositories/note_repository_impl.dart new file mode 100644 index 00000000..9fa51f25 --- /dev/null +++ b/lib/data_layer/repositories/note_repository_impl.dart @@ -0,0 +1,139 @@ +import 'dart:developer'; + +import 'package:ndk/entities.dart' as ndk_entities; +import 'package:ndk/ndk.dart' as ndk; + +import '../../domain_layer/entities/nostr_note.dart'; +import '../../domain_layer/repositories/note_repository.dart'; +import '../data_sources/dart_ndk_source.dart'; +import '../models/nostr_note_model.dart'; + +class NoteRepositoryImpl implements NoteRepository { + final DartNdkSource dartNdkSource; + final ndk.EventVerifier eventVerifier; + + NoteRepositoryImpl({ + required this.dartNdkSource, + required this.eventVerifier, + }); + + @override + Stream getAllNotes() { + ndk.Filter filter = ndk.Filter( + authors: [], + kinds: [ndk_entities.Nip01Event.TEXT_NODE_KIND], + ); + + final response = dartNdkSource.dartNdk.requests + .query(filters: [filter], name: 'getAllNotes-'); + + return response.stream.map( + (event) => NostrNoteModel.fromNDKEvent(event), + ); + } + + @override + Stream getTextNote(String noteId) { + ndk.Filter filter = ndk.Filter( + ids: [noteId], + kinds: [ndk_entities.Nip01Event.TEXT_NODE_KIND], + ); + + final response = dartNdkSource.dartNdk.requests + .query(filters: [filter], name: 'getTextNote-'); + + return response.stream.map( + (event) => NostrNoteModel.fromNDKEvent(event), + ); + } + + /// Get all notes by a list of authors using a query + @override + Stream getTextNotesByAuthors({ + required List authors, + required String requestId, + int? since, + int? until, + int? limit, + List? eTags, + }) { + ndk.Filter filter = ndk.Filter( + authors: authors, + kinds: [ndk_entities.Nip01Event.TEXT_NODE_KIND], + since: since, + until: until, + limit: limit, + eTags: eTags, + ); + + final response = dartNdkSource.dartNdk.requests.query( + filters: [filter], + name: requestId, + cacheRead: true, + cacheWrite: true, + ); + + return response.stream.map( + (event) => NostrNoteModel.fromNDKEvent(event), + ); + } + + /// Get all notes by a list of authors using a subscription + @override + Stream subscribeTextNotesByAuthors({ + required List authors, + required String requestId, + int? since, + int? until, + int? limit, + List? eTags, + }) { + ndk.Filter filter = ndk.Filter( + authors: authors, + kinds: [ndk_entities.Nip01Event.TEXT_NODE_KIND], + since: since, + until: until, + limit: limit, + eTags: eTags, + ); + + final response = dartNdkSource.dartNdk.requests.subscription( + filters: [filter], + name: requestId, + cacheRead: true, + cacheWrite: true, + ); + + return response.stream.map( + (event) => NostrNoteModel.fromNDKEvent(event), + ); + } + + @override + Future closeSubscription(String subscriptionId) async { + await dartNdkSource.dartNdk.requests.closeSubscription(subscriptionId); + } + + @override + Stream subscribeReplyNotes({ + required String rootNoteId, + required String requestId, + }) { + ndk.Filter filter = ndk.Filter( + eTags: [rootNoteId], + kinds: [ndk_entities.Nip01Event.TEXT_NODE_KIND], + ); + + final response = dartNdkSource.dartNdk.requests.subscription( + filters: [filter], + name: requestId, + //todo: bug in the NDK when using cacheRead and subscription + // cacheRead: true, + // cacheWrite: true, + ); + + return response.stream.map( + (event) => NostrNoteModel.fromNDKEvent(event), + ); + } +} diff --git a/lib/db/dao/note_dao.dart b/lib/db/dao/note_dao.dart deleted file mode 100644 index 0ea7b56a..00000000 --- a/lib/db/dao/note_dao.dart +++ /dev/null @@ -1,222 +0,0 @@ -import 'dart:async'; -import 'dart:developer'; - -import 'package:camelus/db/entities/db_note_view.dart'; -import 'package:camelus/db/entities/db_tag.dart'; -import 'package:camelus/models/nostr_note.dart'; -import 'package:floor/floor.dart'; -import '../entities/db_note.dart'; - -@dao -abstract class NoteDao { - @Query('SELECT * FROM Note') - Stream> findAllNotesAsStream(); - - @Query('SELECT * FROM noteView') - Future> findAllNotes(); - - @Query('SELECT * FROM noteView WHERE kind = :kind') - Future> findAllNotesByKind(int kind); - - @Query(''' - SELECT * FROM ( - SELECT Note.*, GROUP_CONCAT(Tag.type) as tag_types, GROUP_CONCAT(Tag.value) as tag_values, GROUP_CONCAT(Tag.recommended_relay) as tag_recommended_relays, GROUP_CONCAT(Tag.marker) as tag_markers, GROUP_CONCAT(Tag.tag_index) as tag_index - FROM Note - LEFT JOIN Tag ON Note.id = Tag.note_id - GROUP BY Note.id - ) AS noteView - WHERE kind = :kind - ''') - Stream> findAllNotesByKindStream(int kind); - - @Query('SELECT * FROM noteView WHERE id = :id') - Future> findNote(String id); - - @Query("SELECT * FROM noteView WHERE noteView.pubkey IN (:pubkeys)") - Future> findPubkeyNotes(List pubkeys); - - @Query( - "SELECT * FROM noteView WHERE noteView.pubkey IN (:pubkeys) ORDER BY created_at DESC") - Stream> findPubkeyNotesStream(List pubkeys); - - @Query( - "SELECT * FROM noteView WHERE noteView.pubkey IN (:pubkeys) AND kind = (:kind) ORDER BY created_at DESC") - Future> findPubkeyNotesByKind( - List pubkeys, int kind); - - @Query( - "SELECT * FROM noteView WHERE noteView.pubkey IN (:pubkeys) AND kind = (:kind) ORDER BY created_at DESC ") - Stream> findPubkeyNotesByKindStream( - List pubkeys, int kind); - - /// floor gets confused when streaming from a view, so instead use this to notify and then get notes from the view - @Query( - "SELECT * FROM Note WHERE Note.pubkey IN (:pubkeys) AND kind = (:kind) ORDER BY created_at DESC ") - Stream> findPubkeyNotesByKindStreamNotifyOnly( - List pubkeys, int kind); - - @Query( - "SELECT * FROM noteView WHERE noteView.pubkey IN (:pubkeys) AND kind = (:kind) AND created_at > (:timestamp) ORDER BY created_at DESC") - Stream> findPubkeyNotesStreamByKindAndTimestamp( - List pubkeys, int kind, int timestamp); - - // find root notes - @Query(""" - SELECT * FROM noteView - WHERE noteView.pubkey - IN (:pubkeys) - AND kind = (:kind) - AND (NOT (',' || tag_types || ',' LIKE '%,e,%') - OR (tag_types IS NULL)) - ORDER BY created_at DESC - """) - Future> findPubkeyRootNotesByKind( - List pubkeys, int kind); - - // copied view because otherwise floor change/stream detection gets confused - @Query(""" - SELECT * FROM ( - SELECT Note.*, GROUP_CONCAT(Tag.type) as tag_types, GROUP_CONCAT(Tag.value) as tag_values, GROUP_CONCAT(Tag.recommended_relay) as tag_recommended_relays, GROUP_CONCAT(Tag.marker) as tag_markers, GROUP_CONCAT(Tag.tag_index) as tag_index - FROM Note - LEFT JOIN Tag ON Note.id = Tag.note_id - GROUP BY Note.id - ) AS noteView - WHERE noteView.pubkey IN (:pubkeys) - AND kind = (:kind) - AND NOT (',' || tag_types || ',' LIKE '%,e,%') - OR (tag_types IS NULL AND kind = (:kind)) - IN (:pubkeys) - ORDER BY created_at DESC - """) - Stream> findPubkeyRootNotesByKindStreamNotifyOnly( - List pubkeys, int kind); - - // event view - @Query(""" - SELECT * FROM noteView - WHERE noteView.id = :id - AND kind = :kind - OR instr(',' || tag_values || ',', :id) > 0 - ORDER BY created_at ASC - """) - Future> findRepliesByIdAndByKind(String id, int kind); - - // event view - @Query(""" - SELECT * FROM ( - SELECT Note.*, GROUP_CONCAT(Tag.type) as tag_types, GROUP_CONCAT(Tag.value) as tag_values, GROUP_CONCAT(Tag.recommended_relay) as tag_recommended_relays, GROUP_CONCAT(Tag.marker) as tag_markers, GROUP_CONCAT(Tag.tag_index) as tag_index - FROM Note - LEFT JOIN Tag ON Note.id = Tag.note_id - GROUP BY Note.id - ) AS noteView - WHERE noteView.id = :id - AND kind = :kind - OR instr(',' || tag_values || ',', :id) > 0 - ORDER BY created_at ASC - """) - Stream> findRepliesByIdAndByKindStream(String id, int kind); - - @Query(""" - SELECT * FROM noteView - WHERE (',' || tag_types || ',' LIKE '%,t,%') - AND (',' || tag_values || ',' LIKE :tag) - AND kind = :kind - ORDER BY created_at DESC - """) - Future> findTagByKind( - int kind, - String tag, - ); - - @Query(""" - SELECT * FROM ( - SELECT Note.*, GROUP_CONCAT(Tag.type) as tag_types, GROUP_CONCAT(Tag.value) as tag_values, GROUP_CONCAT(Tag.recommended_relay) as tag_recommended_relays, GROUP_CONCAT(Tag.marker) as tag_markers, GROUP_CONCAT(Tag.tag_index) as tag_index - FROM Note - LEFT JOIN Tag ON Note.id = Tag.note_id - GROUP BY Note.id - ) AS noteView - WHERE (',' || tag_types || ',' LIKE '%,t,%') - AND (',' || tag_values || ',' LIKE :tag) - AND kind = :kind - ORDER BY created_at DESC - """) - Stream> findTagByKindStream(int kind, String tag); - - @Query('SELECT content FROM note') - Stream> findAllNotesContentStream(); - - @Query('SELECT * FROM Note WHERE id = :id') - Stream findNoteByIdStream(int id); - - @Insert(onConflict: OnConflictStrategy.ignore) - Future insertNote(DbNote note); - - @Insert(onConflict: OnConflictStrategy.ignore) - Future> insertNotes(List notes); - - @Insert(onConflict: OnConflictStrategy.ignore) - Future> insertTags(List tags); - - @Query('DELETE FROM Note') - Future deleteAllNotes(); - - @Query('DELETE FROM Note WHERE id = :id') - Future deleteNoteById(int id); - - @Query('DELETE FROM Note WHERE id IN (:ids)') - Future deleteNotesByIds(List ids); - - @Query('DELETE FROM Note WHERE kind = :kind') - Future deleteNotesByKind(int kind); - - @transaction - Future insertNostrNote(NostrNote nostrNote) async { - try { - await insertNote(nostrNote.toDbNote()); - await insertTags(nostrNote.toDbTag()); - } catch (e) { - // problably already exists - } - } - - @transaction - Future insertNostrNotes(List nostrNotes) async { - log('inserting ${nostrNotes.length} notes'); - try { - await insertNotes(nostrNotes.map((e) => e.toDbNote()).toList()); - await insertTags( - nostrNotes.map((e) => e.toDbTag()).expand((x) => x).toList()); - } catch (e) { - // problably already exists - log(e.toString()); - } - } - - List toInsertNotes = []; - Timer? insertNotesTimer; - Future stackInsertNotes(List notes) async { - // stack insert after 100 notes or 1 seconds - toInsertNotes.addAll(notes); - - toInsertNotes = toInsertNotes.toSet().toList(); - - if (insertNotesTimer != null) { - insertNotesTimer!.cancel(); - } - insertNotesTimer = Timer(const Duration(milliseconds: 400), () async { - var copy = [...toInsertNotes]; - toInsertNotes = []; - await insertNostrNotes(copy); - return; - }); - - if (toInsertNotes.length >= 20) { - insertNotesTimer!.cancel(); - var copy = [...toInsertNotes]; - toInsertNotes = []; - await insertNostrNotes(copy); - return; - } - return; - } -} diff --git a/lib/db/dao/tag_dao.dart b/lib/db/dao/tag_dao.dart deleted file mode 100644 index f2d7ea6e..00000000 --- a/lib/db/dao/tag_dao.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:camelus/db/entities/db_tag.dart'; -import 'package:floor/floor.dart'; - -@dao -abstract class TagDao { - @Query('SELECT * FROM Tag') - Stream> findAllNotesAsStream(); - - @Query('SELECT * FROM Tag ') - Future> findAllNotes(); - - @Query('SELECT * FROM Tag WHERE note_id = :noteId') - Stream findNoteByNoteIdStream(int noteId); - - @insert - Future insertTag(DbTag tag); - - @insert - Future> insertTags(List tags); -} diff --git a/lib/db/database.dart b/lib/db/database.dart deleted file mode 100644 index 70fd7686..00000000 --- a/lib/db/database.dart +++ /dev/null @@ -1,19 +0,0 @@ -// required package imports -import 'dart:async'; -import 'package:floor/floor.dart'; -import 'package:sqflite/sqflite.dart' as sqflite; - -import 'package:camelus/models/nostr_note.dart'; -import 'dao/note_dao.dart'; -import 'dao/tag_dao.dart'; -import 'entities/db_note.dart'; -import 'entities/db_tag.dart'; -import 'entities/db_note_view.dart'; - -part 'database.g.dart'; // the generated code will be there - -@Database(version: 1, entities: [DbNote, DbTag], views: [DbNoteView]) -abstract class AppDatabase extends FloorDatabase { - NoteDao get noteDao; - TagDao get tagDao; -} diff --git a/lib/db/database.g.dart b/lib/db/database.g.dart deleted file mode 100644 index d3a4d682..00000000 --- a/lib/db/database.g.dart +++ /dev/null @@ -1,693 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'database.dart'; - -// ************************************************************************** -// FloorGenerator -// ************************************************************************** - -// ignore: avoid_classes_with_only_static_members -class $FloorAppDatabase { - /// Creates a database builder for a persistent database. - /// Once a database is built, you should keep a reference to it and re-use it. - static _$AppDatabaseBuilder databaseBuilder(String name) => - _$AppDatabaseBuilder(name); - - /// Creates a database builder for an in memory database. - /// Information stored in an in memory database disappears when the process is killed. - /// Once a database is built, you should keep a reference to it and re-use it. - static _$AppDatabaseBuilder inMemoryDatabaseBuilder() => - _$AppDatabaseBuilder(null); -} - -class _$AppDatabaseBuilder { - _$AppDatabaseBuilder(this.name); - - final String? name; - - final List _migrations = []; - - Callback? _callback; - - /// Adds migrations to the builder. - _$AppDatabaseBuilder addMigrations(List migrations) { - _migrations.addAll(migrations); - return this; - } - - /// Adds a database [Callback] to the builder. - _$AppDatabaseBuilder addCallback(Callback callback) { - _callback = callback; - return this; - } - - /// Creates the database and initializes it. - Future build() async { - final path = name != null - ? await sqfliteDatabaseFactory.getDatabasePath(name!) - : ':memory:'; - final database = _$AppDatabase(); - database.database = await database.open( - path, - _migrations, - _callback, - ); - return database; - } -} - -class _$AppDatabase extends AppDatabase { - _$AppDatabase([StreamController? listener]) { - changeListener = listener ?? StreamController.broadcast(); - } - - NoteDao? _noteDaoInstance; - - TagDao? _tagDaoInstance; - - Future open( - String path, - List migrations, [ - Callback? callback, - ]) async { - final databaseOptions = sqflite.OpenDatabaseOptions( - version: 1, - onConfigure: (database) async { - await database.execute('PRAGMA foreign_keys = ON'); - await callback?.onConfigure?.call(database); - }, - onOpen: (database) async { - await callback?.onOpen?.call(database); - }, - onUpgrade: (database, startVersion, endVersion) async { - await MigrationAdapter.runMigrations( - database, startVersion, endVersion, migrations); - - await callback?.onUpgrade?.call(database, startVersion, endVersion); - }, - onCreate: (database, version) async { - await database.execute( - 'CREATE TABLE IF NOT EXISTS `Note` (`id` TEXT NOT NULL, `pubkey` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `kind` INTEGER NOT NULL, `content` TEXT NOT NULL, `sig` TEXT NOT NULL, PRIMARY KEY (`id`))'); - await database.execute( - 'CREATE TABLE IF NOT EXISTS `Tag` (`note_id` TEXT NOT NULL, `tag_index` INTEGER NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, `recommended_relay` TEXT, `marker` TEXT, FOREIGN KEY (`note_id`) REFERENCES `Note` (`id`) ON UPDATE CASCADE ON DELETE CASCADE, PRIMARY KEY (`note_id`, `value`))'); - await database - .execute('CREATE INDEX `index_Note_kind` ON `Note` (`kind`)'); - await database - .execute('CREATE INDEX `index_Note_pubkey` ON `Note` (`pubkey`)'); - await database.execute( - 'CREATE INDEX `index_Note_created_at` ON `Note` (`created_at`)'); - await database.execute( - 'CREATE VIEW IF NOT EXISTS `noteView` AS SELECT Note.*, GROUP_CONCAT(Tag.type) as tag_types, GROUP_CONCAT(Tag.value) as tag_values, GROUP_CONCAT(Tag.recommended_relay) as tag_recommended_relays, GROUP_CONCAT(Tag.marker) as tag_markers, GROUP_CONCAT(Tag.tag_index) as tag_index FROM Note LEFT JOIN Tag ON Note.id = Tag.note_id GROUP BY Note.id;'); - - await callback?.onCreate?.call(database, version); - }, - ); - return sqfliteDatabaseFactory.openDatabase(path, options: databaseOptions); - } - - @override - NoteDao get noteDao { - return _noteDaoInstance ??= _$NoteDao(database, changeListener); - } - - @override - TagDao get tagDao { - return _tagDaoInstance ??= _$TagDao(database, changeListener); - } -} - -class _$NoteDao extends NoteDao { - _$NoteDao( - this.database, - this.changeListener, - ) : _queryAdapter = QueryAdapter(database, changeListener), - _dbNoteInsertionAdapter = InsertionAdapter( - database, - 'Note', - (DbNote item) => { - 'id': item.id, - 'pubkey': item.pubkey, - 'created_at': item.created_at, - 'kind': item.kind, - 'content': item.content, - 'sig': item.sig - }, - changeListener), - _dbTagInsertionAdapter = InsertionAdapter( - database, - 'Tag', - (DbTag item) => { - 'note_id': item.note_id, - 'tag_index': item.tag_index, - 'type': item.type, - 'value': item.value, - 'recommended_relay': item.recommended_relay, - 'marker': item.marker - }, - changeListener); - - final sqflite.DatabaseExecutor database; - - final StreamController changeListener; - - final QueryAdapter _queryAdapter; - - final InsertionAdapter _dbNoteInsertionAdapter; - - final InsertionAdapter _dbTagInsertionAdapter; - - @override - Stream> findAllNotesAsStream() { - return _queryAdapter.queryListStream('SELECT * FROM Note', - mapper: (Map row) => DbNote( - row['id'] as String, - row['pubkey'] as String, - row['created_at'] as int, - row['kind'] as int, - row['content'] as String, - row['sig'] as String), - queryableName: 'Note', - isView: false); - } - - @override - Future> findAllNotes() async { - return _queryAdapter.queryList('SELECT * FROM noteView', - mapper: (Map row) => DbNoteView( - id: row['id'] as String, - pubkey: row['pubkey'] as String, - created_at: row['created_at'] as int, - kind: row['kind'] as int, - content: row['content'] as String, - sig: row['sig'] as String, - tag_index: row['tag_index'] as String?, - tag_types: row['tag_types'] as String?, - tag_values: row['tag_values'] as String?, - tag_recommended_relays: row['tag_recommended_relays'] as String?, - tag_markers: row['tag_markers'] as String?)); - } - - @override - Future> findAllNotesByKind(int kind) async { - return _queryAdapter.queryList('SELECT * FROM noteView WHERE kind = ?1', - mapper: (Map row) => DbNoteView( - id: row['id'] as String, - pubkey: row['pubkey'] as String, - created_at: row['created_at'] as int, - kind: row['kind'] as int, - content: row['content'] as String, - sig: row['sig'] as String, - tag_index: row['tag_index'] as String?, - tag_types: row['tag_types'] as String?, - tag_values: row['tag_values'] as String?, - tag_recommended_relays: row['tag_recommended_relays'] as String?, - tag_markers: row['tag_markers'] as String?), - arguments: [kind]); - } - - @override - Stream> findAllNotesByKindStream(int kind) { - return _queryAdapter.queryListStream( - 'SELECT * FROM ( SELECT Note.*, GROUP_CONCAT(Tag.type) as tag_types, GROUP_CONCAT(Tag.value) as tag_values, GROUP_CONCAT(Tag.recommended_relay) as tag_recommended_relays, GROUP_CONCAT(Tag.marker) as tag_markers, GROUP_CONCAT(Tag.tag_index) as tag_index FROM Note LEFT JOIN Tag ON Note.id = Tag.note_id GROUP BY Note.id ) AS noteView WHERE kind = ?1', - mapper: (Map row) => DbNoteView( - id: row['id'] as String, - pubkey: row['pubkey'] as String, - created_at: row['created_at'] as int, - kind: row['kind'] as int, - content: row['content'] as String, - sig: row['sig'] as String, - tag_index: row['tag_index'] as String?, - tag_types: row['tag_types'] as String?, - tag_values: row['tag_values'] as String?, - tag_recommended_relays: row['tag_recommended_relays'] as String?, - tag_markers: row['tag_markers'] as String?), - arguments: [kind], - queryableName: 'Note', - isView: true); - } - - @override - Future> findNote(String id) async { - return _queryAdapter.queryList('SELECT * FROM noteView WHERE id = ?1', - mapper: (Map row) => DbNoteView( - id: row['id'] as String, - pubkey: row['pubkey'] as String, - created_at: row['created_at'] as int, - kind: row['kind'] as int, - content: row['content'] as String, - sig: row['sig'] as String, - tag_index: row['tag_index'] as String?, - tag_types: row['tag_types'] as String?, - tag_values: row['tag_values'] as String?, - tag_recommended_relays: row['tag_recommended_relays'] as String?, - tag_markers: row['tag_markers'] as String?), - arguments: [id]); - } - - @override - Future> findPubkeyNotes(List pubkeys) async { - const offset = 1; - final sqliteVariablesForPubkeys = - Iterable.generate(pubkeys.length, (i) => '?${i + offset}') - .join(','); - return _queryAdapter.queryList( - 'SELECT * FROM noteView WHERE noteView.pubkey IN ($sqliteVariablesForPubkeys)', - mapper: (Map row) => DbNoteView( - id: row['id'] as String, - pubkey: row['pubkey'] as String, - created_at: row['created_at'] as int, - kind: row['kind'] as int, - content: row['content'] as String, - sig: row['sig'] as String, - tag_index: row['tag_index'] as String?, - tag_types: row['tag_types'] as String?, - tag_values: row['tag_values'] as String?, - tag_recommended_relays: row['tag_recommended_relays'] as String?, - tag_markers: row['tag_markers'] as String?), - arguments: [...pubkeys]); - } - - @override - Stream> findPubkeyNotesStream(List pubkeys) { - const offset = 1; - final sqliteVariablesForPubkeys = - Iterable.generate(pubkeys.length, (i) => '?${i + offset}') - .join(','); - return _queryAdapter.queryListStream( - 'SELECT * FROM noteView WHERE noteView.pubkey IN ($sqliteVariablesForPubkeys) ORDER BY created_at DESC', - mapper: (Map row) => DbNoteView( - id: row['id'] as String, - pubkey: row['pubkey'] as String, - created_at: row['created_at'] as int, - kind: row['kind'] as int, - content: row['content'] as String, - sig: row['sig'] as String, - tag_index: row['tag_index'] as String?, - tag_types: row['tag_types'] as String?, - tag_values: row['tag_values'] as String?, - tag_recommended_relays: row['tag_recommended_relays'] as String?, - tag_markers: row['tag_markers'] as String?), - arguments: [...pubkeys], - queryableName: 'noteView', - isView: true); - } - - @override - Future> findPubkeyNotesByKind( - List pubkeys, - int kind, - ) async { - const offset = 2; - final sqliteVariablesForPubkeys = - Iterable.generate(pubkeys.length, (i) => '?${i + offset}') - .join(','); - return _queryAdapter.queryList( - 'SELECT * FROM noteView WHERE noteView.pubkey IN ($sqliteVariablesForPubkeys) AND kind = (?1) ORDER BY created_at DESC', - mapper: (Map row) => DbNoteView( - id: row['id'] as String, - pubkey: row['pubkey'] as String, - created_at: row['created_at'] as int, - kind: row['kind'] as int, - content: row['content'] as String, - sig: row['sig'] as String, - tag_index: row['tag_index'] as String?, - tag_types: row['tag_types'] as String?, - tag_values: row['tag_values'] as String?, - tag_recommended_relays: row['tag_recommended_relays'] as String?, - tag_markers: row['tag_markers'] as String?), - arguments: [kind, ...pubkeys]); - } - - @override - Stream> findPubkeyNotesByKindStream( - List pubkeys, - int kind, - ) { - const offset = 2; - final sqliteVariablesForPubkeys = - Iterable.generate(pubkeys.length, (i) => '?${i + offset}') - .join(','); - return _queryAdapter.queryListStream( - 'SELECT * FROM noteView WHERE noteView.pubkey IN ($sqliteVariablesForPubkeys) AND kind = (?1) ORDER BY created_at DESC', - mapper: (Map row) => DbNoteView( - id: row['id'] as String, - pubkey: row['pubkey'] as String, - created_at: row['created_at'] as int, - kind: row['kind'] as int, - content: row['content'] as String, - sig: row['sig'] as String, - tag_index: row['tag_index'] as String?, - tag_types: row['tag_types'] as String?, - tag_values: row['tag_values'] as String?, - tag_recommended_relays: row['tag_recommended_relays'] as String?, - tag_markers: row['tag_markers'] as String?), - arguments: [kind, ...pubkeys], - queryableName: 'noteView', - isView: true); - } - - @override - Stream> findPubkeyNotesByKindStreamNotifyOnly( - List pubkeys, - int kind, - ) { - const offset = 2; - final sqliteVariablesForPubkeys = - Iterable.generate(pubkeys.length, (i) => '?${i + offset}') - .join(','); - return _queryAdapter.queryListStream( - 'SELECT * FROM Note WHERE Note.pubkey IN ($sqliteVariablesForPubkeys) AND kind = (?1) ORDER BY created_at DESC', - mapper: (Map row) => DbNoteView( - id: row['id'] as String, - pubkey: row['pubkey'] as String, - created_at: row['created_at'] as int, - kind: row['kind'] as int, - content: row['content'] as String, - sig: row['sig'] as String, - tag_index: row['tag_index'] as String?, - tag_types: row['tag_types'] as String?, - tag_values: row['tag_values'] as String?, - tag_recommended_relays: row['tag_recommended_relays'] as String?, - tag_markers: row['tag_markers'] as String?), - arguments: [kind, ...pubkeys], - queryableName: 'Note', - isView: true); - } - - @override - Stream> findPubkeyNotesStreamByKindAndTimestamp( - List pubkeys, - int kind, - int timestamp, - ) { - const offset = 3; - final sqliteVariablesForPubkeys = - Iterable.generate(pubkeys.length, (i) => '?${i + offset}') - .join(','); - return _queryAdapter.queryListStream( - 'SELECT * FROM noteView WHERE noteView.pubkey IN ($sqliteVariablesForPubkeys) AND kind = (?1) AND created_at > (?2) ORDER BY created_at DESC', - mapper: (Map row) => DbNoteView( - id: row['id'] as String, - pubkey: row['pubkey'] as String, - created_at: row['created_at'] as int, - kind: row['kind'] as int, - content: row['content'] as String, - sig: row['sig'] as String, - tag_index: row['tag_index'] as String?, - tag_types: row['tag_types'] as String?, - tag_values: row['tag_values'] as String?, - tag_recommended_relays: row['tag_recommended_relays'] as String?, - tag_markers: row['tag_markers'] as String?), - arguments: [kind, timestamp, ...pubkeys], - queryableName: 'noteView', - isView: true); - } - - @override - Future> findPubkeyRootNotesByKind( - List pubkeys, - int kind, - ) async { - const offset = 2; - final sqliteVariablesForPubkeys = - Iterable.generate(pubkeys.length, (i) => '?${i + offset}') - .join(','); - return _queryAdapter.queryList( - 'SELECT * FROM noteView WHERE noteView.pubkey IN ($sqliteVariablesForPubkeys) AND kind = (?1) AND (NOT (\',\' || tag_types || \',\' LIKE \'%,e,%\') OR (tag_types IS NULL)) ORDER BY created_at DESC', - mapper: (Map row) => DbNoteView(id: row['id'] as String, pubkey: row['pubkey'] as String, created_at: row['created_at'] as int, kind: row['kind'] as int, content: row['content'] as String, sig: row['sig'] as String, tag_index: row['tag_index'] as String?, tag_types: row['tag_types'] as String?, tag_values: row['tag_values'] as String?, tag_recommended_relays: row['tag_recommended_relays'] as String?, tag_markers: row['tag_markers'] as String?), - arguments: [kind, ...pubkeys]); - } - - @override - Stream> findPubkeyRootNotesByKindStreamNotifyOnly( - List pubkeys, - int kind, - ) { - const offset = 2; - final sqliteVariablesForPubkeys = - Iterable.generate(pubkeys.length, (i) => '?${i + offset}') - .join(','); - return _queryAdapter.queryListStream( - 'SELECT * FROM ( SELECT Note.*, GROUP_CONCAT(Tag.type) as tag_types, GROUP_CONCAT(Tag.value) as tag_values, GROUP_CONCAT(Tag.recommended_relay) as tag_recommended_relays, GROUP_CONCAT(Tag.marker) as tag_markers, GROUP_CONCAT(Tag.tag_index) as tag_index FROM Note LEFT JOIN Tag ON Note.id = Tag.note_id GROUP BY Note.id ) AS noteView WHERE noteView.pubkey IN ($sqliteVariablesForPubkeys) AND kind = (?1) AND NOT (\',\' || tag_types || \',\' LIKE \'%,e,%\') OR (tag_types IS NULL AND kind = (?1)) IN ($sqliteVariablesForPubkeys) ORDER BY created_at DESC', - mapper: (Map row) => DbNoteView( - id: row['id'] as String, - pubkey: row['pubkey'] as String, - created_at: row['created_at'] as int, - kind: row['kind'] as int, - content: row['content'] as String, - sig: row['sig'] as String, - tag_index: row['tag_index'] as String?, - tag_types: row['tag_types'] as String?, - tag_values: row['tag_values'] as String?, - tag_recommended_relays: row['tag_recommended_relays'] as String?, - tag_markers: row['tag_markers'] as String?), - arguments: [kind, ...pubkeys], - queryableName: 'Note', - isView: true); - } - - @override - Future> findRepliesByIdAndByKind( - String id, - int kind, - ) async { - return _queryAdapter.queryList( - 'SELECT * FROM noteView WHERE noteView.id = ?1 AND kind = ?2 OR instr(\',\' || tag_values || \',\', ?1) > 0 ORDER BY created_at ASC', - mapper: (Map row) => DbNoteView(id: row['id'] as String, pubkey: row['pubkey'] as String, created_at: row['created_at'] as int, kind: row['kind'] as int, content: row['content'] as String, sig: row['sig'] as String, tag_index: row['tag_index'] as String?, tag_types: row['tag_types'] as String?, tag_values: row['tag_values'] as String?, tag_recommended_relays: row['tag_recommended_relays'] as String?, tag_markers: row['tag_markers'] as String?), - arguments: [id, kind]); - } - - @override - Stream> findRepliesByIdAndByKindStream( - String id, - int kind, - ) { - return _queryAdapter.queryListStream( - 'SELECT * FROM ( SELECT Note.*, GROUP_CONCAT(Tag.type) as tag_types, GROUP_CONCAT(Tag.value) as tag_values, GROUP_CONCAT(Tag.recommended_relay) as tag_recommended_relays, GROUP_CONCAT(Tag.marker) as tag_markers, GROUP_CONCAT(Tag.tag_index) as tag_index FROM Note LEFT JOIN Tag ON Note.id = Tag.note_id GROUP BY Note.id ) AS noteView WHERE noteView.id = ?1 AND kind = ?2 OR instr(\',\' || tag_values || \',\', ?1) > 0 ORDER BY created_at ASC', - mapper: (Map row) => DbNoteView( - id: row['id'] as String, - pubkey: row['pubkey'] as String, - created_at: row['created_at'] as int, - kind: row['kind'] as int, - content: row['content'] as String, - sig: row['sig'] as String, - tag_index: row['tag_index'] as String?, - tag_types: row['tag_types'] as String?, - tag_values: row['tag_values'] as String?, - tag_recommended_relays: row['tag_recommended_relays'] as String?, - tag_markers: row['tag_markers'] as String?), - arguments: [id, kind], - queryableName: 'Note', - isView: true); - } - - @override - Future> findTagByKind( - int kind, - String tag, - ) async { - return _queryAdapter.queryList( - 'SELECT * FROM noteView WHERE (\',\' || tag_types || \',\' LIKE \'%,t,%\') AND (\',\' || tag_values || \',\' LIKE ?2) AND kind = ?1 ORDER BY created_at DESC', - mapper: (Map row) => DbNoteView(id: row['id'] as String, pubkey: row['pubkey'] as String, created_at: row['created_at'] as int, kind: row['kind'] as int, content: row['content'] as String, sig: row['sig'] as String, tag_index: row['tag_index'] as String?, tag_types: row['tag_types'] as String?, tag_values: row['tag_values'] as String?, tag_recommended_relays: row['tag_recommended_relays'] as String?, tag_markers: row['tag_markers'] as String?), - arguments: [kind, tag]); - } - - @override - Stream> findTagByKindStream( - int kind, - String tag, - ) { - return _queryAdapter.queryListStream( - 'SELECT * FROM ( SELECT Note.*, GROUP_CONCAT(Tag.type) as tag_types, GROUP_CONCAT(Tag.value) as tag_values, GROUP_CONCAT(Tag.recommended_relay) as tag_recommended_relays, GROUP_CONCAT(Tag.marker) as tag_markers, GROUP_CONCAT(Tag.tag_index) as tag_index FROM Note LEFT JOIN Tag ON Note.id = Tag.note_id GROUP BY Note.id ) AS noteView WHERE (\',\' || tag_types || \',\' LIKE \'%,t,%\') AND (\',\' || tag_values || \',\' LIKE ?2) AND kind = ?1 ORDER BY created_at DESC', - mapper: (Map row) => DbNoteView( - id: row['id'] as String, - pubkey: row['pubkey'] as String, - created_at: row['created_at'] as int, - kind: row['kind'] as int, - content: row['content'] as String, - sig: row['sig'] as String, - tag_index: row['tag_index'] as String?, - tag_types: row['tag_types'] as String?, - tag_values: row['tag_values'] as String?, - tag_recommended_relays: row['tag_recommended_relays'] as String?, - tag_markers: row['tag_markers'] as String?), - arguments: [kind, tag], - queryableName: 'Note', - isView: true); - } - - @override - Stream> findAllNotesContentStream() { - return _queryAdapter.queryListStream('SELECT content FROM note', - mapper: (Map row) => row.values.first as String, - queryableName: 'note', - isView: false); - } - - @override - Stream findNoteByIdStream(int id) { - return _queryAdapter.queryStream('SELECT * FROM Note WHERE id = ?1', - mapper: (Map row) => DbNote( - row['id'] as String, - row['pubkey'] as String, - row['created_at'] as int, - row['kind'] as int, - row['content'] as String, - row['sig'] as String), - arguments: [id], - queryableName: 'Note', - isView: false); - } - - @override - Future deleteAllNotes() async { - await _queryAdapter.queryNoReturn('DELETE FROM Note'); - } - - @override - Future deleteNoteById(int id) async { - await _queryAdapter - .queryNoReturn('DELETE FROM Note WHERE id = ?1', arguments: [id]); - } - - @override - Future deleteNotesByIds(List ids) async { - const offset = 1; - final sqliteVariablesForIds = - Iterable.generate(ids.length, (i) => '?${i + offset}') - .join(','); - await _queryAdapter.queryNoReturn( - 'DELETE FROM Note WHERE id IN ($sqliteVariablesForIds)', - arguments: [...ids]); - } - - @override - Future deleteNotesByKind(int kind) async { - await _queryAdapter - .queryNoReturn('DELETE FROM Note WHERE kind = ?1', arguments: [kind]); - } - - @override - Future insertNote(DbNote note) async { - await _dbNoteInsertionAdapter.insert(note, OnConflictStrategy.ignore); - } - - @override - Future> insertNotes(List notes) { - return _dbNoteInsertionAdapter.insertListAndReturnIds( - notes, OnConflictStrategy.ignore); - } - - @override - Future> insertTags(List tags) { - return _dbTagInsertionAdapter.insertListAndReturnIds( - tags, OnConflictStrategy.ignore); - } - - @override - Future insertNostrNote(NostrNote nostrNote) async { - if (database is sqflite.Transaction) { - await super.insertNostrNote(nostrNote); - } else { - await (database as sqflite.Database) - .transaction((transaction) async { - final transactionDatabase = _$AppDatabase(changeListener) - ..database = transaction; - await transactionDatabase.noteDao.insertNostrNote(nostrNote); - }); - } - } - - @override - Future insertNostrNotes(List nostrNotes) async { - if (database is sqflite.Transaction) { - await super.insertNostrNotes(nostrNotes); - } else { - await (database as sqflite.Database) - .transaction((transaction) async { - final transactionDatabase = _$AppDatabase(changeListener) - ..database = transaction; - await transactionDatabase.noteDao.insertNostrNotes(nostrNotes); - }); - } - } -} - -class _$TagDao extends TagDao { - _$TagDao( - this.database, - this.changeListener, - ) : _queryAdapter = QueryAdapter(database, changeListener), - _dbTagInsertionAdapter = InsertionAdapter( - database, - 'Tag', - (DbTag item) => { - 'note_id': item.note_id, - 'tag_index': item.tag_index, - 'type': item.type, - 'value': item.value, - 'recommended_relay': item.recommended_relay, - 'marker': item.marker - }, - changeListener); - - final sqflite.DatabaseExecutor database; - - final StreamController changeListener; - - final QueryAdapter _queryAdapter; - - final InsertionAdapter _dbTagInsertionAdapter; - - @override - Stream> findAllNotesAsStream() { - return _queryAdapter.queryListStream('SELECT * FROM Tag', - mapper: (Map row) => DbTag( - note_id: row['note_id'] as String, - tag_index: row['tag_index'] as int, - type: row['type'] as String, - value: row['value'] as String, - recommended_relay: row['recommended_relay'] as String?, - marker: row['marker'] as String?), - queryableName: 'Tag', - isView: false); - } - - @override - Future> findAllNotes() async { - return _queryAdapter.queryList('SELECT * FROM Tag', - mapper: (Map row) => DbTag( - note_id: row['note_id'] as String, - tag_index: row['tag_index'] as int, - type: row['type'] as String, - value: row['value'] as String, - recommended_relay: row['recommended_relay'] as String?, - marker: row['marker'] as String?)); - } - - @override - Stream findNoteByNoteIdStream(int noteId) { - return _queryAdapter.queryStream('SELECT * FROM Tag WHERE note_id = ?1', - mapper: (Map row) => DbTag( - note_id: row['note_id'] as String, - tag_index: row['tag_index'] as int, - type: row['type'] as String, - value: row['value'] as String, - recommended_relay: row['recommended_relay'] as String?, - marker: row['marker'] as String?), - arguments: [noteId], - queryableName: 'Tag', - isView: false); - } - - @override - Future insertTag(DbTag tag) async { - await _dbTagInsertionAdapter.insert(tag, OnConflictStrategy.abort); - } - - @override - Future> insertTags(List tags) { - return _dbTagInsertionAdapter.insertListAndReturnIds( - tags, OnConflictStrategy.abort); - } -} diff --git a/lib/db/entities/db_note.dart b/lib/db/entities/db_note.dart deleted file mode 100644 index 24c10284..00000000 --- a/lib/db/entities/db_note.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:floor/floor.dart'; - -@Entity(tableName: 'Note', indices: [ - Index(value: ['kind']), - Index(value: ['pubkey']), - Index(value: ['created_at']) -]) -class DbNote { - @primaryKey - final String id; - - final String pubkey; - - // ignore: non_constant_identifier_names - final int created_at; - - @ColumnInfo(name: 'kind') - final int kind; - - //final List tags; - /// => forin key @see tag.dart - - final String content; - - final String sig; - - DbNote( - this.id, this.pubkey, this.created_at, this.kind, this.content, this.sig); - - @override - String toString() { - return 'DbNote{id: $id, pubkey: $pubkey, created_at: $created_at, kind: $kind, content: $content, sig: $sig}'; - } -} diff --git a/lib/db/entities/db_note_view.dart b/lib/db/entities/db_note_view.dart deleted file mode 100644 index 5118f2d0..00000000 --- a/lib/db/entities/db_note_view.dart +++ /dev/null @@ -1,22 +0,0 @@ -// ignore_for_file: non_constant_identifier_names - -import 'package:camelus/db/entities/db_note_view_base.dart'; -import 'package:floor/floor.dart'; - -@DatabaseView( - 'SELECT Note.*, GROUP_CONCAT(Tag.type) as tag_types, GROUP_CONCAT(Tag.value) as tag_values, GROUP_CONCAT(Tag.recommended_relay) as tag_recommended_relays, GROUP_CONCAT(Tag.marker) as tag_markers, GROUP_CONCAT(Tag.tag_index) as tag_index FROM Note LEFT JOIN Tag ON Note.id = Tag.note_id GROUP BY Note.id;', - viewName: 'noteView') -class DbNoteView extends DbNoteViewBase { - DbNoteView( - {required super.id, - required super.pubkey, - required super.created_at, - required super.kind, - required super.content, - required super.sig, - super.tag_index, - super.tag_types, - super.tag_values, - super.tag_recommended_relays, - super.tag_markers}); -} diff --git a/lib/db/entities/db_note_view_base.dart b/lib/db/entities/db_note_view_base.dart deleted file mode 100644 index 0e7b38df..00000000 --- a/lib/db/entities/db_note_view_base.dart +++ /dev/null @@ -1,103 +0,0 @@ -// ignore_for_file: non_constant_identifier_names - -import 'package:camelus/models/nostr_note.dart'; -import 'package:camelus/models/nostr_tag.dart'; - -abstract class DbNoteViewBase { - String id; - String pubkey; - int created_at; - int kind; - String content; - String sig; - String? tag_index; - String? tag_types; - String? tag_values; - String? tag_recommended_relays; - String? tag_markers; - - DbNoteViewBase( - {required this.id, - required this.pubkey, - required this.created_at, - required this.kind, - required this.content, - required this.sig, - this.tag_index, - this.tag_types, - this.tag_values, - this.tag_recommended_relays, - this.tag_markers}); - - @override - String toString() { - return 'DbNostrNote{id: $id, pubkey: $pubkey, created_at: $created_at, kind: $kind, content: $content, sig: $sig, tag_types: $tag_types, tag_values: $tag_values, tag_recommended_relays: $tag_recommended_relays, tag_markers: $tag_markers}'; - } - - NostrNote toNostrNote() { - List mytags = []; - if (tag_values != null && tag_values!.isNotEmpty) { - mytags = toNostrTags( - tag_intexs: tag_index ?? '', - tag_types: tag_types!, - tag_values: tag_values!, - tag_recommended_relays: tag_recommended_relays ?? '', - tag_markers: tag_markers ?? ''); - } - - return NostrNote( - id: id, - pubkey: pubkey, - created_at: created_at, - kind: kind, - content: content, - sig: sig, - tags: mytags); - } - - List toNostrTags( - {required String tag_intexs, - required String tag_types, - required String tag_values, - required String tag_recommended_relays, - required String tag_markers}) { - List tags = []; - List tag_index_list = tag_intexs.split(','); - List tag_types_list = tag_types.split(','); - List tag_values_list = tag_values.split(','); - List tag_recommended_relays_list = - tag_recommended_relays.split(','); - List tag_markers_list = tag_markers.split(','); - //cast to int - List tag_index_list_int = - tag_index_list.map((e) => int.parse(e)).toList(); - // crate a list but put them in the order provided by tag_index - - for (int i = 0; i < tag_index_list_int.length; i++) { - int posIndex = tag_index_list_int.indexOf(i); - if (posIndex == -1) continue; // skip if not found (should not happen) - - Map newNostrTag = { - "type": tag_types_list[posIndex], - "value": tag_values_list[posIndex], - }; - - if (tag_recommended_relays_list.length > posIndex && - tag_recommended_relays_list[posIndex].isNotEmpty) { - newNostrTag["recommended_relay"] = - tag_recommended_relays_list[posIndex]; - } - if (tag_markers_list.length > posIndex && - tag_markers_list[posIndex].isNotEmpty) { - newNostrTag["marker"] = tag_markers_list[posIndex]; - } - - tags.add(NostrTag( - type: newNostrTag["type"], - value: newNostrTag["value"], - recommended_relay: newNostrTag["recommended_relay"], - marker: newNostrTag["marker"])); - } - return tags; - } -} diff --git a/lib/db/entities/db_tag.dart b/lib/db/entities/db_tag.dart deleted file mode 100644 index 1001c15c..00000000 --- a/lib/db/entities/db_tag.dart +++ /dev/null @@ -1,42 +0,0 @@ -// ignore_for_file: non_constant_identifier_names - -import 'package:camelus/db/entities/db_note.dart'; -import 'package:floor/floor.dart'; - -@Entity( - tableName: 'Tag', - primaryKeys: ['note_id', 'value'], - foreignKeys: [ - ForeignKey( - childColumns: ['note_id'], - parentColumns: ['id'], - entity: DbNote, - onDelete: ForeignKeyAction.cascade, - onUpdate: ForeignKeyAction.cascade, - ) - ], -) -class DbTag { - // forin key - @ColumnInfo(name: 'note_id') - final String note_id; - - @ColumnInfo(name: 'tag_index') - final int tag_index; - - final String type; - - final String value; - - final String? recommended_relay; - - final String? marker; - - DbTag( - {required this.note_id, - required this.tag_index, - required this.type, - required this.value, - this.recommended_relay, - this.marker}); -} diff --git a/lib/domain_layer/entities/app_update.dart b/lib/domain_layer/entities/app_update.dart new file mode 100644 index 00000000..632f0df8 --- /dev/null +++ b/lib/domain_layer/entities/app_update.dart @@ -0,0 +1,17 @@ +class AppUpdate { + int currentVersion; + final int latestVersion; + bool get isUpdateAvailable => currentVersion < latestVersion; + + final String title; + final String body; + final String url; + + AppUpdate({ + required this.currentVersion, + required this.latestVersion, + required this.title, + required this.body, + required this.url, + }); +} diff --git a/lib/domain_layer/entities/contact_list.dart b/lib/domain_layer/entities/contact_list.dart new file mode 100644 index 00000000..7c989081 --- /dev/null +++ b/lib/domain_layer/entities/contact_list.dart @@ -0,0 +1,29 @@ +class ContactList { + late String pubKey; + + List contacts = []; + List contactRelays = []; + List petnames = []; + + List followedTags = []; + List followedCommunities = []; + List followedEvents = []; + + List sources = []; + + int createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; + int? loadedTimestamp; + + ContactList({ + required this.pubKey, + required this.contacts, + required this.contactRelays, + required this.petnames, + required this.followedTags, + required this.followedCommunities, + required this.followedEvents, + required this.sources, + required this.createdAt, + required this.loadedTimestamp, + }); +} diff --git a/lib/domain_layer/entities/feed_event_view_model.dart b/lib/domain_layer/entities/feed_event_view_model.dart new file mode 100644 index 00000000..967709bc --- /dev/null +++ b/lib/domain_layer/entities/feed_event_view_model.dart @@ -0,0 +1,22 @@ +import 'nostr_note.dart'; +import 'tree_node.dart'; + +class FeedEventViewModel { + NostrNote? rootNote; + List> comments; + + FeedEventViewModel({ + required this.rootNote, + required this.comments, + }); + + copyWith({ + NostrNote? rootNote, + List>? comments, + }) { + return FeedEventViewModel( + rootNote: rootNote ?? this.rootNote, + comments: comments ?? this.comments, + ); + } +} diff --git a/lib/domain_layer/entities/feed_view_model.dart b/lib/domain_layer/entities/feed_view_model.dart new file mode 100644 index 00000000..d181bea5 --- /dev/null +++ b/lib/domain_layer/entities/feed_view_model.dart @@ -0,0 +1,31 @@ +import 'nostr_note.dart'; + +class FeedViewModel { + List timelineRootNotes; + List newRootNotes; + + List timelineRootAndReplyNotes; + List newRootAndReplyNotes; + + FeedViewModel({ + required this.timelineRootNotes, + required this.newRootNotes, + required this.timelineRootAndReplyNotes, + required this.newRootAndReplyNotes, + }); + + copyWith({ + List? timelineRootNotes, + List? newRootNotes, + List? timelineRootAndReplyNotes, + List? newRootAndReplyNotes, + }) { + return FeedViewModel( + timelineRootNotes: timelineRootNotes ?? this.timelineRootNotes, + newRootNotes: newRootNotes ?? this.newRootNotes, + timelineRootAndReplyNotes: + timelineRootAndReplyNotes ?? this.timelineRootAndReplyNotes, + newRootAndReplyNotes: newRootAndReplyNotes ?? this.newRootAndReplyNotes, + ); + } +} diff --git a/lib/domain_layer/entities/generated_private_key.dart b/lib/domain_layer/entities/generated_private_key.dart new file mode 100644 index 00000000..7689b187 --- /dev/null +++ b/lib/domain_layer/entities/generated_private_key.dart @@ -0,0 +1,18 @@ +import '../../helpers/helpers.dart'; + +class GeneratedPrivateKey { + final String mnemonicSentence; + final List mnemonicWords; + final String privateKey; + final String publicKey; + + String get privKeyHr => Helpers().encodeBech32(privateKey, 'nsec'); + String get publicKeyHr => Helpers().encodeBech32(publicKey, 'npub'); + + GeneratedPrivateKey({ + required this.mnemonicSentence, + required this.mnemonicWords, + required this.privateKey, + required this.publicKey, + }); +} diff --git a/lib/domain_layer/entities/key_pair.dart b/lib/domain_layer/entities/key_pair.dart new file mode 100644 index 00000000..afaf5153 --- /dev/null +++ b/lib/domain_layer/entities/key_pair.dart @@ -0,0 +1,34 @@ +class KeyPair { + /// [privateKey] is a 32-bytes hex-encoded string + final String privateKey; + + /// [publicKey] is a 32-bytes hex-encoded string + final String publicKey; + + /// [privateKeyHr] is a human readable private key e.g. nsec + final String privateKeyHr; + + /// [publicKeyHr] is a human readable public key e.g. npub + final String publicKeyHr; + + KeyPair({ + required this.privateKey, + required this.publicKey, + required this.privateKeyHr, + required this.publicKeyHr, + }); + + Map toJson() => { + 'privateKey': privateKey, + 'publicKey': publicKey, + 'privateKeyHr': privateKeyHr, + 'publicKeyHr': publicKeyHr, + }; + + factory KeyPair.fromJson(Map json) => KeyPair( + privateKey: json['privateKey'], + publicKey: json['publicKey'], + privateKeyHr: json['privateKeyHr'], + publicKeyHr: json['publicKeyHr'], + ); +} diff --git a/lib/domain_layer/entities/mem_file.dart b/lib/domain_layer/entities/mem_file.dart new file mode 100644 index 00000000..4d3ab3f7 --- /dev/null +++ b/lib/domain_layer/entities/mem_file.dart @@ -0,0 +1,13 @@ +import 'dart:typed_data'; + +class MemFile { + Uint8List bytes; + final String mimeType; + final String name; + + MemFile({ + required this.bytes, + required this.mimeType, + required this.name, + }); +} diff --git a/lib/domain_layer/entities/nip05.dart b/lib/domain_layer/entities/nip05.dart new file mode 100644 index 00000000..422891d9 --- /dev/null +++ b/lib/domain_layer/entities/nip05.dart @@ -0,0 +1,14 @@ +class Nip05 { + String nip05; + + bool valid; + int? lastCheck; + List? relays; + + Nip05({ + required this.nip05, + this.valid = false, + this.lastCheck, + this.relays = const [], + }); +} diff --git a/lib/domain_layer/entities/nostr_band_hashtags.dart b/lib/domain_layer/entities/nostr_band_hashtags.dart new file mode 100644 index 00000000..81e9ae84 --- /dev/null +++ b/lib/domain_layer/entities/nostr_band_hashtags.dart @@ -0,0 +1,61 @@ +class NostrBandHashtags { + final String type; + final List hashtags; + final Map relays; + + NostrBandHashtags({ + required this.type, + required this.hashtags, + required this.relays, + }); +} + +class Hashtags { + final String hashtag; + final int threadsCount; + final List threads; + + Hashtags({ + required this.hashtag, + required this.threadsCount, + required this.threads, + }); +} + +class Threads { + final String id; + final int kind; + final String pubkey; + final int createdAt; + final String content; + final int replies; + final int upvotes; + final int downvotes; + final int reposts; + final int zappers; + final int zapAmount; + final String replyToId; + final String rootId; + final String lang; + final List relays; + final Map author; + + Threads({ + required this.id, + required this.kind, + required this.pubkey, + required this.createdAt, + required this.content, + required this.replies, + required this.upvotes, + required this.downvotes, + required this.reposts, + required this.zappers, + required this.zapAmount, + required this.replyToId, + required this.rootId, + required this.lang, + required this.relays, + required this.author, + }); +} diff --git a/lib/domain_layer/entities/nostr_band_people.dart b/lib/domain_layer/entities/nostr_band_people.dart new file mode 100644 index 00000000..a2c69beb --- /dev/null +++ b/lib/domain_layer/entities/nostr_band_people.dart @@ -0,0 +1,20 @@ +import 'package:camelus/domain_layer/entities/nostr_note.dart'; + +class NostrBandPeople { + final List profiles; + + NostrBandPeople({required this.profiles}); +} + +class Profiles { + String pubkey; + int newFollowersCount; + List relays; + NostrNote profile; + + Profiles( + {required this.pubkey, + required this.newFollowersCount, + required this.relays, + required this.profile}); +} diff --git a/lib/models/nostr_note.dart b/lib/domain_layer/entities/nostr_note.dart similarity index 64% rename from lib/models/nostr_note.dart rename to lib/domain_layer/entities/nostr_note.dart index a9742558..b37ae05a 100644 --- a/lib/models/nostr_note.dart +++ b/lib/domain_layer/entities/nostr_note.dart @@ -1,6 +1,4 @@ -import 'package:camelus/db/entities/db_note.dart'; -import 'package:camelus/db/entities/db_tag.dart'; -import 'package:camelus/models/nostr_tag.dart'; +import 'package:camelus/domain_layer/entities/nostr_tag.dart'; class NostrNote { final String id; @@ -10,6 +8,8 @@ class NostrNote { final int kind; final String content; final String sig; + // ignore: non_constant_identifier_names + final bool? sig_valid; final List tags; NostrNote({ @@ -21,6 +21,7 @@ class NostrNote { required this.content, required this.sig, required this.tags, + this.sig_valid, }); List get relayHints => _extractRelayHints(); @@ -46,59 +47,11 @@ class NostrNote { tags: []); } - factory NostrNote.fromJson(Map json) { - List tagsJson = json['tags'] ?? []; - List> tags = []; - //cast using for loop - for (List tag in tagsJson) { - tags.add(tag.cast()); - } - - return NostrNote( - id: json['id'], - pubkey: json['pubkey'], - created_at: json['created_at'], - kind: json['kind'], - content: json['content'], - sig: json['sig'], - tags: tags.map((tag) => NostrTag.fromJson(tag)).toList(), - ); - } - - Map toJson() => { - 'id': id, - 'pubkey': pubkey, - 'created_at': created_at, - 'kind': kind, - 'content': content, - 'sig': sig, - 'tags': tags - }; - @override String toString() { return 'NostrNote{id: $id, pubkey: $pubkey, created_at: $created_at, kind: $kind, content: $content, sig: $sig, tags: $tags}'; } - DbNote toDbNote() { - return DbNote(id, pubkey, created_at, kind, content, sig); - } - - // ignore: non_constant_identifier_names - List toDbTag() { - List tags = []; - for (int i = 0; i < this.tags.length; i++) { - tags.add(DbTag( - note_id: id, - tag_index: i, - type: this.tags[i].type, - value: this.tags[i].value, - recommended_relay: this.tags[i].recommended_relay, - marker: this.tags[i].marker)); - } - return tags; - } - List get getTagPubkeys { List mytags = []; for (NostrTag tag in tags) { @@ -159,4 +112,18 @@ class NostrNote { } return null; } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! NostrNote) return false; + + final NostrNote otherNote = other; + return id == otherNote.id; + } + + @override + int get hashCode { + return id.hashCode; + } } diff --git a/lib/models/nostr_tag.dart b/lib/domain_layer/entities/nostr_tag.dart similarity index 51% rename from lib/models/nostr_tag.dart rename to lib/domain_layer/entities/nostr_tag.dart index 8d4fbe1d..963f5160 100644 --- a/lib/models/nostr_tag.dart +++ b/lib/domain_layer/entities/nostr_tag.dart @@ -15,30 +15,6 @@ class NostrTag { this.recommended_relay, this.marker}); - // ["e", <32-bytes hex of the id of another event>, , ] - factory NostrTag.fromJson(List json) { - // chec - - Map newTag = { - "type": json[0], - "value": json[1], - }; - - if (json.length > 2) { - newTag["recommended_relay"] = json[2]; - } - - if (json.length > 3) { - newTag["marker"] = json[3]; - } - - return NostrTag( - type: newTag["type"], - value: newTag["value"], - recommended_relay: newTag["recommended_relay"], - marker: newTag["marker"]); - } - List toList() { List raw = [type, value]; diff --git a/lib/domain_layer/entities/onboarding_user_info.dart b/lib/domain_layer/entities/onboarding_user_info.dart new file mode 100644 index 00000000..2cf44d5f --- /dev/null +++ b/lib/domain_layer/entities/onboarding_user_info.dart @@ -0,0 +1,29 @@ +import 'key_pair.dart'; +import 'mem_file.dart'; + +class OnboardingUserInfo { + String? name = ''; + MemFile? picture; + MemFile? banner; + String? about = ''; + String? nip05; + String? website = ''; + bool nip46 = false; + KeyPair keyPair; + List followPubkeys = []; + String lud06 = ''; + String lud16 = ''; + + OnboardingUserInfo({ + this.name, + this.picture, + this.banner, + this.about, + this.nip05, + this.website, + this.nip46 = false, + this.lud06 = '', + this.lud16 = '', + required this.keyPair, + }); +} diff --git a/lib/domain_layer/entities/relay.dart b/lib/domain_layer/entities/relay.dart new file mode 100644 index 00000000..dcb28ee5 --- /dev/null +++ b/lib/domain_layer/entities/relay.dart @@ -0,0 +1,11 @@ +class Relay { + final String url; + bool read; + bool write; + + Relay({ + required this.url, + required this.read, + required this.write, + }); +} diff --git a/lib/domain_layer/entities/tree_node.dart b/lib/domain_layer/entities/tree_node.dart new file mode 100644 index 00000000..8c47d695 --- /dev/null +++ b/lib/domain_layer/entities/tree_node.dart @@ -0,0 +1,48 @@ +class TreeNode { + final T value; + TreeNode? parent; + final List> _children; + + bool get isFirstChild => parent?.children.first == this; + bool get isLastChild => parent?.children.last == this; + bool get hasSiblings => parent != null && parent!.children.length > 1; + + bool get hasParent => parent != null; + + bool get parentHasChildren => parent?.hasChildren ?? false; + bool get hasChildren => _children.isNotEmpty; + bool get isLeaf => _children.isEmpty; + List> get children => List.unmodifiable(_children); + + TreeNode(this.value) : _children = >[]; + + void addChild(TreeNode child) { + _children.add(child); + child.parent = this; + } + + void addChildren(List> children) { + for (var child in children) { + addChild(child); + } + } + + void removeChild(TreeNode child) { + _children.remove(child); + child.parent = null; + } + + void printTree([String prefix = '']) { + print('$prefix${value.toString()}'); + for (var i = 0; i < _children.length; i++) { + var child = _children[i]; + var isLastChild = i == _children.length - 1; + child.printTree('$prefix${isLastChild ? '└── ' : '├── '}'); + } + } + + @override + String toString() { + return value.toString(); + } +} diff --git a/lib/domain_layer/entities/user_metadata.dart b/lib/domain_layer/entities/user_metadata.dart new file mode 100644 index 00000000..d24eac9a --- /dev/null +++ b/lib/domain_layer/entities/user_metadata.dart @@ -0,0 +1,30 @@ +class UserMetadata { + String eventId; + + String pubkey; + + int lastFetch; + + String? picture; + String? banner; + String? name; + String? nip05; + String? about; + String? website; + String? lud06; + String? lud16; + + UserMetadata({ + required this.eventId, + required this.pubkey, + required this.lastFetch, + this.picture, + this.banner, + this.name, + this.nip05, + this.about, + this.website, + this.lud06, + this.lud16, + }); +} diff --git a/lib/domain_layer/repositories/app_db.dart b/lib/domain_layer/repositories/app_db.dart new file mode 100644 index 00000000..85f0d06f --- /dev/null +++ b/lib/domain_layer/repositories/app_db.dart @@ -0,0 +1,6 @@ +abstract class AppDb { + Future save({required String key, required String value}); + Future read(String key); + Future delete(String key); + Future clear(); +} diff --git a/lib/domain_layer/repositories/app_update_repository.dart b/lib/domain_layer/repositories/app_update_repository.dart new file mode 100644 index 00000000..5e76b96b --- /dev/null +++ b/lib/domain_layer/repositories/app_update_repository.dart @@ -0,0 +1,5 @@ +import '../entities/app_update.dart'; + +abstract class AppUpdateRepository { + Future checkAppUpdate(); +} diff --git a/lib/domain_layer/repositories/database_repository.dart b/lib/domain_layer/repositories/database_repository.dart new file mode 100644 index 00000000..944512c8 --- /dev/null +++ b/lib/domain_layer/repositories/database_repository.dart @@ -0,0 +1,6 @@ +import 'package:camelus/domain_layer/entities/nip05.dart'; + +abstract class DatabaseRepository { + Future getNip05(String nip05); + Future setNip05(Nip05 nip05); +} diff --git a/lib/domain_layer/repositories/edit_relays_repository.dart b/lib/domain_layer/repositories/edit_relays_repository.dart new file mode 100644 index 00000000..83fb18e5 --- /dev/null +++ b/lib/domain_layer/repositories/edit_relays_repository.dart @@ -0,0 +1,8 @@ +import '../entities/relay.dart'; + +abstract class EditRelaysRepository { + Future> getRelays(String pubkey); + Future saveRelays(String pubkey, List relays); + Future> getRelayHintsInbox(String pubkey); + Future> getRelayHintsOutbox(String pubkey); +} diff --git a/lib/domain_layer/repositories/follow_repository.dart b/lib/domain_layer/repositories/follow_repository.dart new file mode 100644 index 00000000..4f0cf668 --- /dev/null +++ b/lib/domain_layer/repositories/follow_repository.dart @@ -0,0 +1,19 @@ +import 'package:camelus/domain_layer/entities/contact_list.dart'; + +abstract class FollowRepository { + FollowRepository(); + + Future followUser(String npub); + + Future unfollowUser(String npub); + + Future setFollowing(ContactList contactList); + + Future isFollowing(String npub); + + Future getContacts(String npub, {int? timeout}); + + Stream getContactsStream(String npub); + + Stream> getFollowers(String npub); +} diff --git a/lib/domain_layer/repositories/metadata_repository.dart b/lib/domain_layer/repositories/metadata_repository.dart new file mode 100644 index 00000000..95092f54 --- /dev/null +++ b/lib/domain_layer/repositories/metadata_repository.dart @@ -0,0 +1,9 @@ +import '../entities/user_metadata.dart'; + +abstract class MetadataRepository { + Stream getMetadataByPubkey(String pubkey); + + /// broadcasts the given [metadata] \ + /// [returns] the broadcasted metadata when complete + Future broadcastMetadata(UserMetadata metadata); +} diff --git a/lib/domain_layer/repositories/nip05_repository.dart b/lib/domain_layer/repositories/nip05_repository.dart new file mode 100644 index 00000000..239f300d --- /dev/null +++ b/lib/domain_layer/repositories/nip05_repository.dart @@ -0,0 +1,6 @@ +import '../entities/nip05.dart'; + +abstract class Nip05Repository { + /// makes a network request to get the Nip05 object + Future requestNip05(String nip05, String pubkey); +} diff --git a/lib/domain_layer/repositories/nostr_band_repository.dart b/lib/domain_layer/repositories/nostr_band_repository.dart new file mode 100644 index 00000000..c8f4cbdd --- /dev/null +++ b/lib/domain_layer/repositories/nostr_band_repository.dart @@ -0,0 +1,7 @@ +import 'package:camelus/domain_layer/entities/nostr_band_hashtags.dart'; +import 'package:camelus/domain_layer/entities/nostr_band_people.dart'; + +abstract class NostrBandRepository { + Future getTrendingProfiles(); + Future getTrendingHashtags(); +} diff --git a/lib/domain_layer/repositories/note_repository.dart b/lib/domain_layer/repositories/note_repository.dart new file mode 100644 index 00000000..e7ae1980 --- /dev/null +++ b/lib/domain_layer/repositories/note_repository.dart @@ -0,0 +1,33 @@ +import '../entities/nostr_note.dart'; + +abstract class NoteRepository { + Stream getAllNotes(); + + Stream getTextNote(String noteId); + + /// returns the replies to a root note, without the root note + Stream subscribeReplyNotes({ + required String rootNoteId, + required String requestId, + }); + + Stream getTextNotesByAuthors({ + required List authors, + required String requestId, + int? since, + int? until, + int? limit, + List? eTags, + }); + + Stream subscribeTextNotesByAuthors({ + required List authors, + required String requestId, + int? since, + int? until, + int? limit, + List? eTags, + }); + + Future closeSubscription(String subscriptionId); +} diff --git a/lib/domain_layer/repositories/upload_file_repository.dart b/lib/domain_layer/repositories/upload_file_repository.dart new file mode 100644 index 00000000..cccb0be1 --- /dev/null +++ b/lib/domain_layer/repositories/upload_file_repository.dart @@ -0,0 +1,8 @@ +import 'dart:io'; + +import '../entities/mem_file.dart'; + +abstract class FileUploadRepository { + Future uploadImageFile(File file); + Future uploadImage(MemFile file); +} diff --git a/lib/domain_layer/usecases/app_auth.dart b/lib/domain_layer/usecases/app_auth.dart new file mode 100644 index 00000000..02f27a79 --- /dev/null +++ b/lib/domain_layer/usecases/app_auth.dart @@ -0,0 +1,111 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:amberflutter/amberflutter.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:ndk/ndk.dart'; +import 'package:ndk/shared/nips/nip19/nip19.dart'; + +import '../entities/key_pair.dart'; + +/// This class is used to store and retrive user information from secure storage. \ +/// the storage keys [nostrKeys] and [amber] are used to store the user's keypair and amber public key respectively. +class AppAuth { + static FlutterSecureStorage secureStorage = const FlutterSecureStorage(); + static final amber = Amberflutter(); + + /// logs in with amber and sets the storage flag for amber + static Future amberRegister() async { + final amberSigner = await _amberLogin(); + await secureStorage.write(key: "amber", value: amberSigner.publicKey); + + return amberSigner; + } + + static Future _amberLogin() async { + final installed = await amber.isAppInstalled(); + if (!installed) { + throw Exception('Amber is not installed'); + } + final amberValue = await amber.getPublicKey( + permissions: [ + const Permission( + type: "nip04_encrypt", + ), + const Permission( + type: "nip04_decrypt", + ), + const Permission(type: "sign_event", kind: 0), + const Permission(type: "sign_event", kind: 1), + const Permission(type: "sign_event", kind: 2), + const Permission(type: "sign_event", kind: 3), + const Permission(type: "sign_event", kind: 4), + const Permission(type: "sign_event", kind: 5), + const Permission(type: "sign_event", kind: 6), + const Permission(type: "sign_event", kind: 7), + // nip 65 + const Permission(type: "sign_event", kind: 10002), + ], + ); + + final npub = amberValue['signature'] ?? ''; + final pubkeyHex = Nip19.decode(npub); + + final amberFlutterDS = AmberFlutterDS(amber); + final amberSigner = + AmberEventSigner(publicKey: pubkeyHex, amberFlutterDS: amberFlutterDS); + return amberSigner; + } + + static Future _setupKeys() async { + var nostrKeysString = await secureStorage.read(key: "nostrKeys"); + if (nostrKeysString == null) { + return null; + } + final myKeyPair = KeyPair.fromJson(json.decode(nostrKeysString)); + return myKeyPair; + } + + /// gets the current used event signer based on storage + /// null if no valid event signer is found => user needs to register + static Future getEventSigner() async { + final myKeyPair = await _setupKeys(); + + if (myKeyPair != null) { + final signer = Bip340EventSigner( + privateKey: myKeyPair.privateKey, + publicKey: myKeyPair.publicKey, + ); + return signer; + } + + // no amber on other platforms + if (!Platform.isAndroid) { + return null; + } + // check if amber is used + final amberInstalled = await amber.isAppInstalled(); + + if (!amberInstalled) { + return null; + } + final amberPubkey = await secureStorage.read(key: "amber"); + + // not registered with amber + if (amberPubkey == null) { + return null; + } + + // ok to login with amber + return await _amberLogin(); + } + + /// deltes the keys from storage. + /// This is used to log out the user + static Future clearKeys() async { + secureStorage.delete( + key: "nostrKeys", + ); + secureStorage.delete(key: "amber"); + } +} diff --git a/lib/domain_layer/usecases/check_app_update.dart b/lib/domain_layer/usecases/check_app_update.dart new file mode 100644 index 00000000..0f435d32 --- /dev/null +++ b/lib/domain_layer/usecases/check_app_update.dart @@ -0,0 +1,12 @@ +import '../entities/app_update.dart'; +import '../repositories/app_update_repository.dart'; + +class CheckAppUpdate { + final AppUpdateRepository appUpdateRepository; + + CheckAppUpdate(this.appUpdateRepository); + + Future call() async { + return await appUpdateRepository.checkAppUpdate(); + } +} diff --git a/lib/domain_layer/usecases/edit_relays.dart b/lib/domain_layer/usecases/edit_relays.dart new file mode 100644 index 00000000..60942f1d --- /dev/null +++ b/lib/domain_layer/usecases/edit_relays.dart @@ -0,0 +1,48 @@ +import 'package:camelus/domain_layer/entities/relay.dart'; +import 'package:camelus/domain_layer/repositories/edit_relays_repository.dart'; + +class EditRelays { + final EditRelaysRepository _relayRepository; + final String? selfPubkey; + + EditRelays(this._relayRepository, this.selfPubkey); + + _checkSelfPubkey() { + if (selfPubkey == null) { + throw Exception("selfPubkey is null"); + } + } + + Future> getRelays(String pubkey) async { + return _relayRepository.getRelays(pubkey); + } + + Future> getRelaysSelf() async { + _checkSelfPubkey(); + return getRelays(selfPubkey!); + } + + Future saveRelays(String pubkey, List relays) async { + return _relayRepository.saveRelays(pubkey, relays); + } + + Future> getRelayHintsInbox(String pubkey) async { + return _relayRepository.getRelayHintsInbox(pubkey); + } + + Future> getRelayHintsOutbox(String pubkey) async { + return _relayRepository.getRelayHintsOutbox(pubkey); + } + + /// get the inobx relay hints for the current user + Future> getRelayHintsInboxSelf() async { + _checkSelfPubkey(); + return _relayRepository.getRelayHintsInbox(selfPubkey!); + } + + /// get the outbox relay hints for the current user + Future> getRelayHintsOutboxSelf() async { + _checkSelfPubkey(); + return _relayRepository.getRelayHintsOutbox(selfPubkey!); + } +} diff --git a/lib/domain_layer/usecases/event_feed.dart b/lib/domain_layer/usecases/event_feed.dart new file mode 100644 index 00000000..43e4e129 --- /dev/null +++ b/lib/domain_layer/usecases/event_feed.dart @@ -0,0 +1,130 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:camelus/helpers/helpers.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../entities/nostr_note.dart'; +import '../entities/tree_node.dart'; +import '../repositories/note_repository.dart'; + +class EventFeed { + final NoteRepository _noteRepository; + + final String repliesFetchId = "replies-${Helpers().getRandomString(5)}"; + + /// the complete build tree of replies => new updates = new state + final StreamController>> _repliesTreeController = + StreamController>>(); + Stream>> get repliesTreeStream => + _repliesTreeController.stream; + + /// raw replies + final StreamController _replyNotesController = + StreamController(); + Stream get replyNotesStream => _replyNotesController.stream; + + /// root note + final StreamController _rootNoteController = + StreamController(); + Stream get rootNoteStream => _rootNoteController.stream; + + EventFeed(this._noteRepository); + + Future subscribeToReplyNotes({ + required String rootNoteId, + }) async { + final replyNotes = _noteRepository.subscribeReplyNotes( + requestId: repliesFetchId, + rootNoteId: rootNoteId, + ); + + replyNotes + .bufferTime(const Duration(milliseconds: 500)) + .where((events) => events.isNotEmpty) + .listen((events) { + for (final event in events) { + _replyNotesController.add(event); + } + + final tree = buildRepliesTree( + rootNoteId: rootNoteId, + replies: events, + ); + + _repliesTreeController.add(tree); + }); + } + + Future subscribeToRootNote({ + required String noteId, + }) async { + final rootNote = _noteRepository.getTextNote( + noteId, + ); + + rootNote.listen((event) { + _rootNoteController.add(event); + }); + } + + /// build a tree from the replies \ + /// [returns] a list of first level replies \ + /// the cildren are replies of replies + static List> buildRepliesTree({ + required String rootNoteId, + required List replies, + }) { + final List workingList = List.from(replies, growable: true); + workingList.sort((a, b) => a.created_at.compareTo(b.created_at)); + final List> tree = []; + + // find top level replies + for (var i = 0; i < workingList.length; i++) { + final reply = workingList[i]; + + if (reply.getDirectReply?.value == rootNoteId) { + tree.add(TreeNode(reply)); + workingList.remove(reply); + i--; // Adjust index after removal + } + } + + // build the tree + for (final node in tree) { + _buildSubtree(workingList: workingList, parent: node); + } + + return tree; + } + + /// recursive function to build the tree + /// + static _buildSubtree({ + required List workingList, + required TreeNode parent, + }) { + for (var i = 0; i < workingList.length; i++) { + final reply = workingList[i]; + + if (reply.getDirectReply?.value == parent.value.id) { + final child = TreeNode(reply); + parent.addChild(child); + workingList.remove(reply); + i--; // Adjust index after removal + _buildSubtree(workingList: workingList, parent: child); + } + } + } + + /// clean up everything including closing subscriptions + Future dispose() async { + final List futures = []; + futures.add(_noteRepository.closeSubscription(repliesFetchId)); + futures.add(_rootNoteController.close()); + futures.add(_replyNotesController.close()); + futures.add(_repliesTreeController.close()); + + await Future.wait(futures); + } +} diff --git a/lib/domain_layer/usecases/file_upload.dart b/lib/domain_layer/usecases/file_upload.dart new file mode 100644 index 00000000..811988e5 --- /dev/null +++ b/lib/domain_layer/usecases/file_upload.dart @@ -0,0 +1,18 @@ +import 'dart:io'; + +import '../entities/mem_file.dart'; +import '../repositories/upload_file_repository.dart'; + +class FileUpload { + final FileUploadRepository fileUploadRepository; + + FileUpload(this.fileUploadRepository); + + Future uploadImageFile(File file) async { + return await fileUploadRepository.uploadImageFile(file); + } + + Future uploadImage(MemFile file) async { + return await fileUploadRepository.uploadImage(file); + } +} diff --git a/lib/domain_layer/usecases/follow.dart b/lib/domain_layer/usecases/follow.dart new file mode 100644 index 00000000..6c6c78cd --- /dev/null +++ b/lib/domain_layer/usecases/follow.dart @@ -0,0 +1,79 @@ +import 'package:camelus/domain_layer/entities/contact_list.dart'; + +import '../entities/nostr_tag.dart'; +import '../repositories/follow_repository.dart'; + +class Follow { + final String? selfPubkey; + + final FollowRepository followRepository; + + Follow({ + required this.selfPubkey, + required this.followRepository, + }); + + _checkSelfPubkey() { + if (selfPubkey == null) { + throw Exception("selfPubkey is null"); + } + } + + Future followUser(String npub) async { + return followRepository.followUser(npub); + } + + Future unfollowUser(String npub) async { + return followRepository.unfollowUser(npub); + } + + /// overrides the previous contact list and sets a new one with the given pubkeys + Future setContacts(List pubkeys) async { + _checkSelfPubkey(); + return followRepository.setFollowing( + ContactList( + pubKey: selfPubkey!, + contacts: pubkeys, + contactRelays: [], + createdAt: 0, + followedCommunities: [], + followedEvents: [], + followedTags: [], + petnames: [], + sources: [], + loadedTimestamp: null, + ), + ); + } + + Future isFollowing(String npub) async { + throw UnimplementedError(); + } + + Future getContacts(String npub, {int? timeout}) { + return followRepository.getContacts(npub, timeout: timeout); + } + + Future getContactsSelf() { + _checkSelfPubkey(); + return getContacts(selfPubkey!); + } + + Stream getContactsStream(String npub) { + return followRepository.getContactsStream(npub); + } + + Stream getContactsStreamSelf() { + _checkSelfPubkey(); + return getContactsStream(selfPubkey!); + } + + Stream> getFollowers(String npub) { + throw UnimplementedError(); + } + + Stream> getFollowersSelf() { + _checkSelfPubkey(); + return getFollowers(selfPubkey!); + } +} diff --git a/lib/domain_layer/usecases/generate_private_key.dart b/lib/domain_layer/usecases/generate_private_key.dart new file mode 100644 index 00000000..8cf71ef2 --- /dev/null +++ b/lib/domain_layer/usecases/generate_private_key.dart @@ -0,0 +1,43 @@ +import 'dart:typed_data'; + +import 'package:bip32/bip32.dart' as bip32; +import 'package:bip39_mnemonic/bip39_mnemonic.dart'; +import 'package:hex/hex.dart'; + +import '../../helpers/bip340.dart'; +import '../entities/generated_private_key.dart'; + +class GeneratePrivateKey { + static GeneratedPrivateKey generateKey() { + final mnemonic = Mnemonic.generate( + Language.english, + entropyLength: 256, + ); + + final sentence = mnemonic.sentence; + final words = mnemonic.words; + final privateKey = _getPrivkeyFromSeed(mnemonic); + final publicKey = Bip340().getPublicKey(privateKey); + + final myKey = GeneratedPrivateKey( + mnemonicSentence: sentence, + mnemonicWords: words, + privateKey: privateKey, + publicKey: publicKey, + ); + return myKey; + } + + /// [returns] private key as hex string + static String _getPrivkeyFromSeed(Mnemonic myMnemonic) { + final Uint8List seedBytes = Uint8List.fromList(myMnemonic.entropy); + bip32.BIP32 node = bip32.BIP32.fromSeed(seedBytes); + + // m/44'/1237'/'/0/0 + bip32.BIP32 child = node.derivePath("m/44'/1237'/0'/0/0"); + + final privkeyHex = HEX.encode(child.privateKey!); + + return privkeyHex; + } +} diff --git a/lib/domain_layer/usecases/get_nostr_band_hashtags.dart b/lib/domain_layer/usecases/get_nostr_band_hashtags.dart new file mode 100644 index 00000000..a2571b21 --- /dev/null +++ b/lib/domain_layer/usecases/get_nostr_band_hashtags.dart @@ -0,0 +1,17 @@ +import 'package:camelus/domain_layer/entities/nostr_band_people.dart'; +import 'package:camelus/domain_layer/repositories/nostr_band_repository.dart'; +import '../entities/nostr_band_hashtags.dart'; + +class GetNostrBand { + final NostrBandRepository nostrBandRepository; + + GetNostrBand(this.nostrBandRepository); + + Future getTrendingHashtags() async { + return await nostrBandRepository.getTrendingHashtags(); + } + + Future getTrendingPeople() async { + return await nostrBandRepository.getTrendingProfiles(); + } +} diff --git a/lib/domain_layer/usecases/get_notes.dart b/lib/domain_layer/usecases/get_notes.dart new file mode 100644 index 00000000..2183e8a5 --- /dev/null +++ b/lib/domain_layer/usecases/get_notes.dart @@ -0,0 +1,63 @@ +import 'dart:developer'; + +import 'package:camelus/domain_layer/usecases/follow.dart'; + +import '../entities/nostr_note.dart'; +import '../repositories/note_repository.dart'; + +class GetNotes { + final NoteRepository _noteRepository; + final Follow _follow; + + GetNotes(this._noteRepository, this._follow); + + Stream getAllNotes() { + return _noteRepository.getAllNotes(); + } + + Future closeFeed(String requestId) { + throw UnimplementedError(); + } + + // todo: check if possible to close the subscription when the stream closes + Stream getNote(String noteId) { + return _noteRepository.getTextNote(noteId); + } + + Future closeNote() { + throw UnimplementedError(); + } + + /// returns a stream of notes for given events (usually the root note id) + Stream> getThreadFeed({ + required List eventIds, + required String requestId, + int? since, + int? until, + int? limit, + }) { + throw UnimplementedError(); + } + + /// returns a stream of notes for a given npub (follows of that npub) with replies + Stream> getNpubWithRepliesFeed({ + required String npub, + required String requestId, + int? since, + int? until, + int? limit, + }) { + throw UnimplementedError(); + } + + /// returns a stream of notes for a given npub (follows of that npub) with replies + Stream> getHashtagFeed({ + required List hashtags, + required String requestId, + int? since, + int? until, + int? limit, + }) { + throw UnimplementedError(); + } +} diff --git a/lib/domain_layer/usecases/get_user_metadata.dart b/lib/domain_layer/usecases/get_user_metadata.dart new file mode 100644 index 00000000..d5bed276 --- /dev/null +++ b/lib/domain_layer/usecases/get_user_metadata.dart @@ -0,0 +1,16 @@ +import '../entities/user_metadata.dart'; +import '../repositories/metadata_repository.dart'; + +class GetUserMetadata { + final MetadataRepository _metadataRepository; + + GetUserMetadata(this._metadataRepository); + + Stream getMetadataByPubkey(String pubkey) { + return _metadataRepository.getMetadataByPubkey(pubkey); + } + + Future broadcastMetadata(UserMetadata metadata) { + return _metadataRepository.broadcastMetadata(metadata); + } +} diff --git a/lib/domain_layer/usecases/main_feed.dart b/lib/domain_layer/usecases/main_feed.dart new file mode 100644 index 00000000..635534ae --- /dev/null +++ b/lib/domain_layer/usecases/main_feed.dart @@ -0,0 +1,141 @@ +import 'dart:async'; +import 'dart:developer'; + +import '../entities/nostr_note.dart'; +import '../repositories/note_repository.dart'; +import 'follow.dart'; + +/// +/// idea is to combine multiple streams here into the feed stream +/// the feed stream gets then sorted on the ui in an intervall to prevent huge layout shifts +/// +/// there could be one update stream and one for scrolling +/// +/// + +class MainFeed { + final NoteRepository _noteRepository; + final Follow _follow; + + final String userFeedFreshId = "fresh"; + final String userFeedTimelineFetchId = "timeline"; + + // root streams + final StreamController _rootNotesController = + StreamController(); + Stream get rootNotesStream => _rootNotesController.stream; + + final StreamController _newRootNotesController = + StreamController(); + Stream get newRootNotesStream => _newRootNotesController.stream; + + // root and reply streams + final StreamController _rootAndReplyNotesController = + StreamController(); + Stream get rootAndReplyNotesStream => + _rootAndReplyNotesController.stream; + + final StreamController _newRootAndReplyNotesController = + StreamController(); + Stream get newRootAndReplyNotesStream => + _newRootAndReplyNotesController.stream; + + MainFeed(this._noteRepository, this._follow); + + Future subscribeToFreshNotes({ + required String npub, + required int since, + }) async { + final contactList = await _follow.getContactsSelf(); + + if (contactList == null) { + log("no contact list found for $npub"); + return; + } + + final newNotesStream = _noteRepository.subscribeTextNotesByAuthors( + authors: contactList.contacts, + requestId: userFeedFreshId, + since: since, + ); + + newNotesStream.listen((event) { + _newRootAndReplyNotesController.add(event); + if (event.isRoot) { + _newRootNotesController.add(event); + } + }); + } + + /// load later timelineevents then + void loadMore({ + required int oltherThen, + required String pubkey, + }) { + fetchFeedEvents( + npub: pubkey, + requestId: "loadMore-", + limit: 20, + until: oltherThen - 1, // -1 to not get dublicates + ); + } + + void fetchFeedEvents({ + required String npub, + required String requestId, + int? since, + int? until, + int? limit, + List? eTags, + }) async { + // get contacts of user + + final contactList = await _follow.getContacts(npub, timeout: 10); + + if (contactList == null) { + log("no contact list found for $npub"); + return; + } + + final mynotesStream = _noteRepository.getTextNotesByAuthors( + authors: contactList.contacts, + requestId: requestId, + since: since, + until: until, + limit: limit, + eTags: eTags, + ); + + mynotesStream.listen((event) { + _rootAndReplyNotesController.add(event); + if (event.isRoot) { + _rootNotesController.add(event); + } + }); + } + + /// integrate new root notes into main feed + void integrateRootNotes(List events) { + for (final event in events) { + _rootNotesController.add(event); + } + } + + void integrateRootAndReplyNotes(List events) { + for (final event in events) { + _rootAndReplyNotesController.add(event); + } + } + + /// clean up everything including closing subscriptions + Future dispose() async { + final List futures = []; + futures.add(_noteRepository.closeSubscription(userFeedTimelineFetchId)); + futures.add(_rootNotesController.close()); + futures.add(_newRootNotesController.close()); + futures.add(_rootAndReplyNotesController.close()); + futures.add(_newRootAndReplyNotesController.close()); + + await Future.wait(futures); + } +} diff --git a/lib/domain_layer/usecases/moderation.dart b/lib/domain_layer/usecases/moderation.dart new file mode 100644 index 00000000..6725bbf0 --- /dev/null +++ b/lib/domain_layer/usecases/moderation.dart @@ -0,0 +1,18 @@ +class Moderation { + Future muteUser(String npub) async { + throw UnimplementedError(); + } + + Future unmuteUser(String npub) async { + throw UnimplementedError(); + } + + Future isMuted(String npub) async { + throw UnimplementedError(); + } + + /// returns a stream of mutet users by given npub + Stream> getMuted(String npub) { + throw UnimplementedError(); + } +} diff --git a/lib/domain_layer/usecases/onboard.dart b/lib/domain_layer/usecases/onboard.dart new file mode 100644 index 00000000..9e148681 --- /dev/null +++ b/lib/domain_layer/usecases/onboard.dart @@ -0,0 +1,13 @@ +import 'package:camelus/domain_layer/entities/onboarding_user_info.dart'; + +class Onboard { + final OnboardingUserInfo signUpInfo; + + Onboard({ + required this.signUpInfo, + }); + + _uploadPicture() { + throw UnimplementedError(); + } +} diff --git a/lib/domain_layer/usecases/profile_feed.dart b/lib/domain_layer/usecases/profile_feed.dart new file mode 100644 index 00000000..2585e50e --- /dev/null +++ b/lib/domain_layer/usecases/profile_feed.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import '../entities/nostr_note.dart'; +import '../repositories/note_repository.dart'; + +/// used to get the feeds for a user profile +class ProfileFeed { + final NoteRepository _noteRepository; + + final String userFeedFreshId = "profile-fresh"; + final String userFeedTimelineFetchId = "profile-timeline"; + + // root streams + final StreamController _rootNotesController = + StreamController(); + Stream get rootNotesStream => _rootNotesController.stream; + + final StreamController _newRootNotesController = + StreamController(); + Stream get newRootNotesStream => _newRootNotesController.stream; + + // root and reply streams + final StreamController _rootAndReplyNotesController = + StreamController(); + Stream get rootAndReplyNotesStream => + _rootAndReplyNotesController.stream; + + final StreamController _newRootAndReplyNotesController = + StreamController(); + Stream get newRootAndReplyNotesStream => + _newRootAndReplyNotesController.stream; + + ProfileFeed( + this._noteRepository, + ); + + Future subscribeToFreshNotes({ + required String npub, + required int since, + }) async { + final newNotesStream = _noteRepository.subscribeTextNotesByAuthors( + authors: [npub], + requestId: userFeedFreshId, + since: since, + ); + + newNotesStream.listen((event) { + _newRootAndReplyNotesController.add(event); + if (event.isRoot) { + _newRootNotesController.add(event); + } + }); + } + + /// load later timelineevents then + void loadMore({ + required int oltherThen, + required String pubkey, + }) { + fetchFeedEvents( + npub: pubkey, + requestId: "loadMore-profile-", + limit: 20, + until: oltherThen - 1, // -1 to not get dublicates + ); + } + + void fetchFeedEvents({ + required String npub, + required String requestId, + int? since, + int? until, + int? limit, + List? eTags, + }) async { + // get contacts of user + final mynotesStream = _noteRepository.getTextNotesByAuthors( + authors: [npub], + requestId: requestId, + since: since, + until: until, + limit: limit, + eTags: eTags, + ); + + mynotesStream.listen((event) { + _rootAndReplyNotesController.add(event); + if (event.isRoot) { + _rootNotesController.add(event); + } + }); + } + + /// integrate new root notes into main feed + void integrateRootNotes(List events) { + for (final event in events) { + _rootNotesController.add(event); + } + } + + void integrateRootAndReplyNotes(List events) { + for (final event in events) { + _rootAndReplyNotesController.add(event); + } + } + + /// clean up everything including closing subscriptions + Future dispose() async { + final List futures = []; + futures.add(_noteRepository.closeSubscription(userFeedTimelineFetchId)); + futures.add(_rootNotesController.close()); + futures.add(_newRootNotesController.close()); + futures.add(_rootAndReplyNotesController.close()); + futures.add(_newRootAndReplyNotesController.close()); + + await Future.wait(futures); + } +} diff --git a/lib/domain_layer/usecases/verify_nip05.dart b/lib/domain_layer/usecases/verify_nip05.dart new file mode 100644 index 00000000..eed381ed --- /dev/null +++ b/lib/domain_layer/usecases/verify_nip05.dart @@ -0,0 +1,49 @@ +import 'package:camelus/domain_layer/repositories/database_repository.dart'; +import 'package:camelus/domain_layer/repositories/nip05_repository.dart'; + +import '../entities/nip05.dart'; + +class VerifyNip05 { + final List _inFlight = []; + + final DatabaseRepository _database; + final Nip05Repository _nip05Repository; + + VerifyNip05(this._database, this._nip05Repository); + + Future check(String nip05, String pubkey) async { + if (nip05.isEmpty || pubkey.isEmpty) { + throw Exception("nip05 or pubkey empty"); + } + + var databaseResult = await _database.getNip05(nip05); + + if (databaseResult != null) { + int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + int lastCheck = databaseResult.lastCheck ?? 0; + if (now - lastCheck < 60 * 60 * 24) { + return databaseResult; + } + } + + if (_inFlight.contains(nip05)) { + // wait for result + var maxRetries = 10; + while (_inFlight.contains(nip05)) { + await Future.delayed(const Duration(milliseconds: 500)); + if (maxRetries-- < 0) { + continue; + } + } + var rawResult = await _database.getNip05(nip05); + return rawResult; + } + + _inFlight.add(nip05); + + var result = await _nip05Repository.requestNip05(nip05, pubkey); + _inFlight.remove(nip05); + + return result; + } +} diff --git a/lib/helpers/bip340.dart b/lib/helpers/bip340.dart index f9ef339e..d6a59491 100644 --- a/lib/helpers/bip340.dart +++ b/lib/helpers/bip340.dart @@ -1,6 +1,8 @@ import 'package:bip340/bip340.dart' as bip340; import 'package:camelus/helpers/helpers.dart'; +import '../domain_layer/entities/key_pair.dart'; + class Bip340 { final _helpers = Helpers(); @@ -17,6 +19,7 @@ class Bip340 { /// [publicKey] is a 32-bytes hex-encoded string /// true if the signature is valid otherwise false bool verify(String message, String signature, String? publicKey) { + if (publicKey == null) return false; return bip340.verify(publicKey, message, signature); } @@ -34,36 +37,10 @@ class Bip340 { final privKeyHr = _helpers.encodeBech32(privKey, 'nsec'); final pubKeyHr = _helpers.encodeBech32(pubKey, 'npub'); - return KeyPair(privKey, pubKey, privKeyHr, pubKeyHr); + return KeyPair( + privateKey: privKey, + publicKey: pubKey, + privateKeyHr: privKeyHr, + publicKeyHr: pubKeyHr); } } - -class KeyPair { - /// [privateKey] is a 32-bytes hex-encoded string - final String privateKey; - - /// [publicKey] is a 32-bytes hex-encoded string - final String publicKey; - - /// [privateKeyHr] is a human readable private key e.g. nsec - final String privateKeyHr; - - /// [publicKeyHr] is a human readable public key e.g. npub - final String publicKeyHr; - - KeyPair(this.privateKey, this.publicKey, this.privateKeyHr, this.publicKeyHr); - - Map toJson() => { - 'privateKey': privateKey, - 'publicKey': publicKey, - 'privateKeyHr': privateKeyHr, - 'publicKeyHr': publicKeyHr, - }; - - factory KeyPair.fromJson(Map json) => KeyPair( - json['privateKey'], - json['publicKey'], - json['privateKeyHr'], - json['publicKeyHr'], - ); -} diff --git a/lib/helpers/nevent_helper.dart b/lib/helpers/nevent_helper.dart index f834f732..dbbd58d3 100644 --- a/lib/helpers/nevent_helper.dart +++ b/lib/helpers/nevent_helper.dart @@ -56,4 +56,49 @@ class NeventHelper { Uint8List.fromList(HEX.decode(authorPubkey)); return TLV(type: 2, length: 32, value: authorPubkeyBytes); } + + /// Decodes a bech32 string into a map + /// throws if bech32 string is invalid + /// + Map bech32ToMap(String bech32) { + final List dataString = _helper.decodeBech32(bech32); + final Uint8List dataBytes = Uint8List.fromList(HEX.decode(dataString[0])); + final List tlvList = TlvUtils.decode(dataBytes); + + final Map map = _generateMapFromTlvList(tlvList); + + return map; + } + + /// Generates a map from a list of TLV objects + Map _generateMapFromTlvList(List tlvList) { + final Map map = {}; + + for (var i = 0; i < tlvList.length; i++) { + final TLV tlv = tlvList[i]; + if (tlv.type == 0) { + map['eventId'] = HEX.encode(tlv.value); + } else if (tlv.type == 1) { + map['relays'] = _generateRelaysFromTlvList(tlvList); + } else if (tlv.type == 2) { + map['authorPubkey'] = HEX.encode(tlv.value); + } + } + + return map; + } + + /// Generates a list of relays from a list of TLV objects + List _generateRelaysFromTlvList(List tlvList) { + final List relays = []; + + for (var i = 0; i < tlvList.length; i++) { + final TLV tlv = tlvList[i]; + if (tlv.type == 1) { + relays.add(ascii.decode(tlv.value)); + } + } + + return relays; + } } diff --git a/lib/helpers/nip04_encryption.dart b/lib/helpers/nip04_encryption.dart new file mode 100644 index 00000000..f01c6da3 --- /dev/null +++ b/lib/helpers/nip04_encryption.dart @@ -0,0 +1,162 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; +import 'package:pointycastle/export.dart'; +import 'dart:convert' as convert; +import 'package:kepler/kepler.dart'; + +/// from https://github.com/aniketambore/nostr_tools credits to him +class Nip04Encryption { + /// Decrypts a cipher text message encrypted using AES-256-CBC encryption with a + /// randomly generated initialization vector (IV). + /// + /// Parameters: + /// - privKey: The private key to use for decryption. + /// - pubKey: The public key to use for decryption. + /// - cipherText: The cipher text message to decrypt, in the format + /// "{cipherText}?iv={iv}", where {cipherText} is the Base64-encoded encrypted + /// message and {iv} is the Base64-encoded IV used for encryption. + /// + /// Returns: + /// - The decrypted plain text message. + String decrypt(String privKey, String pubKey, String cipherText) { + // Split the cipher text into the encrypted message and the IV. + final parts = cipherText.split("?iv="); + if (parts.length != 2) { + throw ArgumentError("[!] Invalid cipher text format"); + } + + // Decode the encrypted message and the IV from Base64. + final encodedText = base64.decode(parts[0]); + final iv = base64.decode(parts[1]); + + // Generate the shared secret and use the first 32 bytes as the encryption key. + final secretIV = Kepler.byteSecret(privKey, '02$pubKey'); + final key = Uint8List.fromList(secretIV[0]); + + // Define the decryption parameters using the key and IV. + final params = PaddedBlockCipherParameters( + ParametersWithIV(KeyParameter(key), iv), + null, + ); + + // Initialize the AES-256-CBC cipher with PKCS7 padding. + final cipherImpl = + PaddedBlockCipherImpl(PKCS7Padding(), CBCBlockCipher(AESEngine())); + + // Initialize the cipher with the decryption parameters. + cipherImpl.init(false, params); + + // Allocate space for the decrypted output buffer. + final outputDecodedText = Uint8List.view( + Uint8List(encodedText.length).buffer, + 0, + encodedText.length, + ); + + // Decrypt the encrypted message in blocks and write the decrypted bytes to + // the output buffer. + var offset = 0; + while (offset < encodedText.length) { + offset += cipherImpl.processBlock( + encodedText, + offset, + outputDecodedText, + offset, + ); + } + + // Determine the amount of padding added to the decrypted message. + final padCount = outputDecodedText[outputDecodedText.length - 1]; + + // Strip the padding from the decrypted bytes and convert them to a string. + final unpaddedDecodedText = utf8.decode( + outputDecodedText.sublist(0, outputDecodedText.length - padCount), + ); + + // Return the decrypted plain text message. + return unpaddedDecodedText; + } + + /// Encrypts a plain text message using AES-256-CBC encryption with a randomly + /// generated initialization vector (IV). + /// + /// Parameters: + /// - privKey: The private key to use for encryption. (hex) + /// - pubKey: The public key to use for encryption. (hex) + /// - text: The plain text message to encrypt. + /// + /// Returns: + /// - The encrypted message in the format "{cipherText}?iv={iv}", where + /// {cipherText} is the Base64-encoded encrypted message and {iv} is the + /// Base64-encoded IV used for encryption. + + String encrypt(String privKey, String pubKey, String text) { + Uint8List uintInputText = const convert.Utf8Encoder().convert(text); + + // Generate the shared secret and use the first 32 bytes as the encryption key. + final secretIV = Kepler.byteSecret(privKey, '02$pubKey'); + final key = Uint8List.fromList(secretIV[0]); + + // generate iv https://stackoverflow.com/questions/63630661/aes-engine-not-initialised-with-pointycastle-securerandom + // Generate a random 16-byte initialization vector (IV) using the Fortuna + // random number generator. + FortunaRandom fr = FortunaRandom(); + final sGen = Random.secure(); + fr.seed(KeyParameter( + Uint8List.fromList(List.generate(32, (_) => sGen.nextInt(255))))); + final iv = fr.nextBytes(16); + + // Define the encryption parameters using the key and IV. + CipherParameters params = PaddedBlockCipherParameters( + ParametersWithIV(KeyParameter(key), iv), null); + + // Initialize the AES-256-CBC cipher with PKCS7 padding. + PaddedBlockCipherImpl cipherImpl = + PaddedBlockCipherImpl(PKCS7Padding(), CBCBlockCipher(AESEngine())); + + // Initialize the cipher with the encryption parameters. + cipherImpl.init( + true, // means to encrypt + params + as PaddedBlockCipherParameters, + ); + + // Allocate space for the encrypted output buffer. + final outputEncodedText = Uint8List.view( + Uint8List(uintInputText.length + 16).buffer, + 0, + uintInputText.length + 16, + ); + + // Encrypt the plain text message in blocks and write the encrypted bytes to + // the output buffer. + var offset = 0; + while (offset < uintInputText.length - 16) { + offset += cipherImpl.processBlock( + uintInputText, + offset, + outputEncodedText, + offset, + ); + } + + // Add padding and write the remaining encrypted bytes to the output buffer. + offset += + cipherImpl.doFinal(uintInputText, offset, outputEncodedText, offset); + + // Extract the encrypted bytes from the output buffer and create a new + // Uint8List containing only the encrypted bytes. + final Uint8List finalEncodedText = outputEncodedText.sublist(0, offset); + + // Encode the IV as a Base64 string. + String stringIv = convert.base64.encode(iv); + + // Encode the encrypted bytes as a Base64 string and append the IV to the end + final cipherText = + "${convert.base64.encode(finalEncodedText)}?iv=$stringIv"; + + // Return the encrypted message. + return cipherText; + } +} diff --git a/lib/helpers/search.dart b/lib/helpers/search.dart deleted file mode 100644 index c4e976f2..00000000 --- a/lib/helpers/search.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'dart:convert'; -import 'dart:developer'; - -import 'package:camelus/db/database.dart'; -import 'package:camelus/models/nostr_note.dart'; -import 'package:camelus/services/nostr/metadata/nip_05.dart'; -import 'package:http/http.dart' as http; - -class Search { - final AppDatabase _db; - - late Map _usersMetadata; - - Search( - this._db, - ) { - _init(); - _initStream(); - } - - // keep memory up to date with db - void _initStream() { - _db.noteDao.findAllNotesByKindStream(0).listen((event) { - List notesMetadata = - event.map((e) => e.toNostrNote()).toList(); - - for (var note in notesMetadata) { - try { - _usersMetadata[note.pubkey] = jsonDecode(note.content); - } catch (e) { - log("error decoding user metadata: $e"); - } - } - }); - } - - void _init() async { - // keep users metadata in memory - var notesDb = await _db.noteDao.findAllNotesByKind(0); - List notesMetadata = - notesDb.map((e) => e.toNostrNote()).toList(); - - _usersMetadata = {}; - for (var note in notesMetadata) { - try { - _usersMetadata[note.pubkey] = jsonDecode(note.content); - } catch (e) { - log("error decoding user metadata: $e"); - } - } - } - - List> searchUsersMetadata(String query) { - //search in notes title and content - List> results = []; - - for (var entry in _usersMetadata.entries) { - var name = entry.value['name'] ?? ''; - var nip05 = entry.value['nip05'] ?? ''; - - if (name.toLowerCase().contains(query.toLowerCase()) || - nip05.toLowerCase().contains(query.toLowerCase())) { - // check if already in results - if (results.any((element) => element['pubkey'] == entry.key)) { - continue; - } - results.add({...entry.value, "pubkey": entry.key}); - } - } - - return results; - } - - Future?> searchNip05(String nip05) async { - String username = nip05.split("@")[0]; - try { - String url = nip05.split("@")[1]; - } catch (e) { - return null; - } - - http.Client client = http.Client(); - Map response; - try { - response = await Nip05.rawNip05Request(nip05, client); - } catch (e) { - log("error serachNip05 nip05: $e"); - return null; - } - - Map names = response["names"]; - Map relays = response["relays"] ?? {}; - - if (names[username] == null) { - return null; - } - - String myPubkey = names[username]; - List myRelays; - try { - myRelays = List.from(relays[myPubkey]); - } catch (e) { - myRelays = []; - } - - return { - "nip05": nip05, - "pubkey": myPubkey, - "relays": myRelays, - }; - } -} diff --git a/lib/main.dart b/lib/main.dart index 7a6d2fb1..260723e3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,102 +1,145 @@ -import 'package:camelus/providers/key_pair_provider.dart'; -import 'package:camelus/routes/nostr/blockedUsers/blocked_users.dart'; -import 'package:camelus/routes/nostr/hashtag_view/hashtag_view_page.dart'; -import 'package:camelus/routes/nostr/settings/settings_page.dart'; -import 'package:flutter/material.dart'; -import 'package:camelus/routes/nostr/event_view/event_view_page.dart'; -import 'package:camelus/routes/nostr/onboarding/onboarding.dart'; -import 'package:camelus/routes/nostr/profile/profile_page.dart'; +import 'dart:ui'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_mentions/flutter_mentions.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ndk/ndk.dart'; +//import 'package:device_preview/device_preview.dart'; -import 'routes/home_page.dart'; +import 'domain_layer/usecases/app_auth.dart'; +import 'presentation_layer/providers/event_signer_provider.dart'; +import 'presentation_layer/routes/home_page.dart'; +import 'presentation_layer/routes/nostr/blockedUsers/blocked_users.dart'; +import 'presentation_layer/routes/nostr/event_view/event_view_page.dart'; +import 'presentation_layer/routes/nostr/hashtag_view/hashtag_view_page.dart'; +import 'presentation_layer/routes/nostr/onboarding/onboarding.dart'; +import 'presentation_layer/routes/nostr/profile/profile_page.dart'; +import 'presentation_layer/routes/nostr/settings/settings_page.dart'; import 'theme.dart' as theme; -//! first is route, second is pubkey -Future> _getInitialData() async { - var wrapper = await ProviderContainer().read(keyPairProvider.future); +const devDeviceFrame = true; - if (wrapper.keyPair == null) { +/// first is route, second is pubkey +Future> _getInitialData() async { + final mySigner = await AppAuth.getEventSigner(); + + if (mySigner == null) { var initialRoute = '/onboarding'; - return [initialRoute, ""]; + + return [initialRoute, null]; } - return ['/', wrapper.keyPair!.publicKey]; + return ['/', mySigner]; } Future main() async { WidgetsFlutterBinding.ensureInitialized(); var initalData = await _getInitialData(); - runApp(MyApp(initialRoute: initalData[0], pubkey: initalData[1])); + // currently incompatible with recent flutter sdk https://github.com/aloisdeniel/flutter_device_preview/issues/244 + // if (kDebugMode && devDeviceFrame) { + // runApp( + // DevicePreview( + // enabled: kDebugMode, + // builder: (context) => + // MyApp(initialRoute: initalData[0], pubkey: initalData[1]), + // ), + // ); + // return; + // } + + final mySigner = initalData[1] as EventSigner?; + + // Create a ProviderContainer + final providerContainer = ProviderContainer(); + + // we have a signer, so we can set it + if (mySigner != null) { + providerContainer.read(eventSignerProvider.notifier).setSigner(mySigner); + } + + runApp( + UncontrolledProviderScope( + container: providerContainer, + child: MyApp( + initialRoute: initalData[0], + pubkey: mySigner?.getPublicKey() ?? '', + ), + ), + ); } class MyApp extends StatelessWidget { final String initialRoute; final String pubkey; - const MyApp({Key? key, required this.initialRoute, required this.pubkey}) - : super(key: key); + + const MyApp({ + super.key, + required this.initialRoute, + required this.pubkey, + }); // This widget is the root of your application. @override Widget build(BuildContext context) { return Portal( - child: ProviderScope( - child: MaterialApp( - debugShowCheckedModeBanner: false, - title: 'camelus', - theme: theme.themeMap["DARK"], - initialRoute: initialRoute, - onGenerateRoute: (RouteSettings settings) { - switch (settings.name) { - case '/': - return MaterialPageRoute( - builder: (context) => - //HomePage(pubkey: snapshot.data![1]), - HomePage( - pubkey: - pubkey), //snapshot.data![1]), //pubkey: snapshot.data![1] - ); - - case '/onboarding': - return MaterialPageRoute( - builder: (context) => const NostrOnboarding(), - ); - - case '/settings': - return MaterialPageRoute( - builder: (context) => const SettingsPage(), - ); - case '/nostr/event': - return MaterialPageRoute( - builder: (context) => EventViewPage( - rootId: (settings.arguments - as Map)['root'] as String, - scrollIntoView: (settings.arguments - as Map)['scrollIntoView'] - as String?), - ); - case '/nostr/profile': - return MaterialPageRoute( - builder: (context) => - ProfilePage(pubkey: settings.arguments as String), - ); - case '/nostr/hastag': - return MaterialPageRoute( - builder: (context) => - HastagViewPage(hashtag: settings.arguments as String), - ); - case '/nostr/blockedUsers': - return MaterialPageRoute( - builder: (context) => const BlockedUsers(), - ); - } - assert(false, 'Need to implement ${settings.name}'); - return null; + child: MaterialApp( + scrollBehavior: const MaterialScrollBehavior().copyWith( + scrollbars: false, + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, }, ), + debugShowCheckedModeBanner: false, + title: 'camelus', + theme: theme.themeMap["DARK"], + initialRoute: initialRoute, + onGenerateRoute: (RouteSettings settings) { + switch (settings.name) { + case '/': + return CupertinoPageRoute(builder: (context) { + return HomePage(pubkey: pubkey); + }); + + case '/onboarding': + return MaterialPageRoute( + builder: (context) => const NostrOnboarding(), + ); + + case '/settings': + return MaterialPageRoute( + builder: (context) => const SettingsPage(), + ); + case '/nostr/event': + return CupertinoPageRoute( + builder: (context) => EventViewPage( + rootNoteId: (settings.arguments + as Map)['root'] as String, + openNoteId: (settings.arguments + as Map)['scrollIntoView'] as String?), + ); + + case '/nostr/profile': + return MaterialPageRoute( + builder: (context) => + ProfilePage(pubkey: settings.arguments as String), + ); + case '/nostr/hastag': + return MaterialPageRoute( + builder: (context) => + HastagViewPage(hashtag: settings.arguments as String), + ); + case '/nostr/blockedUsers': + return MaterialPageRoute( + builder: (context) => const BlockedUsers(), + ); + } + assert(false, 'Need to implement ${settings.name}'); + return null; + }, ), ); } diff --git a/lib/models/Tweet.dart b/lib/models/Tweet.dart deleted file mode 100644 index e43f1bd7..00000000 --- a/lib/models/Tweet.dart +++ /dev/null @@ -1,279 +0,0 @@ -import 'package:camelus/models/socket_control.dart'; - -class Tweet { - String id; - String pubkey; - String userFirstName; - String userUserName; - String userProfilePic; - String content; - List imageLinks; - int tweetedAt; - int likesCount; - int commentsCount; - int retweetsCount; - List tags; - List replies; - bool isReply = false; - Map> relayHints = {}; - - Tweet( - {required this.id, - required this.pubkey, - required this.userFirstName, - required this.userUserName, - required this.userProfilePic, - required this.content, - required this.imageLinks, - required this.tweetedAt, - required this.tags, - required this.likesCount, - required this.commentsCount, - required this.retweetsCount, - required this.replies, - this.relayHints = const {}, - this.isReply = false}); - - factory Tweet.fromJson(Map json) { - return Tweet( - id: json['id'], - pubkey: json['pubkey'], - userFirstName: json['userFirstName'], - userUserName: json['userUserName'], - userProfilePic: json['userProfilePic'], - content: json['tweet'], - imageLinks: json['imageLinks'].cast(), - tweetedAt: json['tweetedAt'], - tags: json['tags'], - replies: json['replies'], - likesCount: json['likesCount'], - commentsCount: json['commentsCount'], - retweetsCount: json['retweetsCount'], - isReply: json['isReply'], - relayHints: {} //json['relayHints'] ?? {}, - ); - } - - Map toJson() => { - 'id': id, - 'pubkey': pubkey, - 'userFirstName': userFirstName, - 'userUserName': userUserName, - 'userProfilePic': userProfilePic, - 'tweet': content, - 'imageLinks': imageLinks, - 'tweetedAt': tweetedAt, - 'tags': tags, - 'replies': replies, - 'likesCount': likesCount, - 'commentsCount': commentsCount, - 'retweetsCount': retweetsCount, - 'isReply': isReply, - 'relayHints': relayHints - }; - - factory Tweet.fromNostrEvent(dynamic eventMap, SocketControl socketControl) { - int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - // extract media links from content and remove from content - String content = eventMap["content"]; - List imageLinks = []; - RegExp exp = RegExp(r"(https?:\/\/[^\s]+)"); - Iterable matches = exp.allMatches(content); - for (var match in matches) { - var link = match.group(0); - if (link!.endsWith(".jpg") || - link.endsWith(".jpeg") || - link.endsWith(".png") || - link.endsWith(".webp") || - link.endsWith(".gif")) { - imageLinks.add(link); - content = content.replaceAll(link, ""); - } - } - - // check if it is a reply - var isReply = false; - for (var t in eventMap["tags"]) { - if (t[0] == "e") { - isReply = true; - } - } - - Map> myRelayHints = { - socketControl.connectionUrl: { - "lastFetched": now, - } - }; - - return Tweet( - id: eventMap["id"], - pubkey: eventMap["pubkey"], - userFirstName: "name", - userUserName: eventMap["pubkey"], - userProfilePic: "", - content: content, - imageLinks: imageLinks, - tweetedAt: eventMap["created_at"], - tags: eventMap["tags"], - replies: [], - likesCount: 0, - commentsCount: 0, - retweetsCount: 0, - relayHints: myRelayHints, - isReply: isReply); - } - - void updateRelayHintLastFetched(String relayUrl) { - var now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - if (relayHints[relayUrl] == null) { - relayHints[relayUrl] = {}; - } - - relayHints[relayUrl]!["lastFetched"] = now; - } -} - -List tweets = [ - Tweet( - id: '1', - pubkey: "pubkey", - userFirstName: 'Lute', - userUserName: 'Lute100', - userProfilePic: 'assets/images/profile.webp', - content: 'I still don\'t understand why... lorem ipsum', - imageLinks: ['assets/images/content.png'], - tweetedAt: 0, - tags: [], - replies: [], - likesCount: 2, - commentsCount: 3, - retweetsCount: 5, - ), - Tweet( - id: '2', - pubkey: "pubkey", - userFirstName: 'Lute', - userUserName: 'Lute100', - userProfilePic: 'assets/images/profile.webp', - content: 'I still don\'t understand why... lorem ipsum', - imageLinks: ['assets/images/CaptainJackSparrow.jpg'], - tweetedAt: 0, - tags: [], - replies: [], - likesCount: 2, - commentsCount: 3, - retweetsCount: 5), - Tweet( - id: '3', - pubkey: "pubkey", - userFirstName: 'Lute', - userUserName: 'Lute100', - userProfilePic: 'assets/images/profile.webp', - content: 'I still don\'t understand why... lorem ipsum', - imageLinks: [], - tweetedAt: 0, - tags: [], - replies: [], - likesCount: 2, - commentsCount: 3, - retweetsCount: 5), - Tweet( - id: '4', - pubkey: "pubkey", - userFirstName: 'Lute', - userUserName: 'Lute100', - userProfilePic: 'assets/images/CaptainJackSparrow.jpg', - content: 'I still don\'t understand why... lorem ipsum', - imageLinks: ['assets/images/content.png'], - tweetedAt: 0, - tags: [], - replies: [], - likesCount: 2, - commentsCount: 3, - retweetsCount: 5), - Tweet( - id: '5', - pubkey: "pubkey", - userFirstName: 'Lute', - userUserName: 'Lute100', - userProfilePic: 'assets/images/CaptainJackSparrow.jpg', - content: 'I still don\'t understand why... lorem ipsum', - imageLinks: ['assets/images/content.png'], - tweetedAt: 0, - tags: [], - replies: [], - likesCount: 2, - commentsCount: 3, - retweetsCount: 5), - Tweet( - id: '6', - pubkey: "pubkey", - userFirstName: 'Lute', - userUserName: 'Lute100', - userProfilePic: 'assets/images/CaptainJackSparrow.jpg', - content: 'I still don\'t understand why... lorem ipsum', - imageLinks: ['assets/images/content.png'], - tweetedAt: 0, - tags: [], - replies: [], - likesCount: 2, - commentsCount: 3, - retweetsCount: 5), - Tweet( - id: '7', - pubkey: "pubkey", - userFirstName: 'Lute', - userUserName: 'Lute100', - userProfilePic: 'assets/images/CaptainJackSparrow.jpg', - content: 'I still don\'t understand why... lorem ipsum', - imageLinks: ['assets/images/content.png'], - tweetedAt: 0, - tags: [], - replies: [], - likesCount: 2, - commentsCount: 3, - retweetsCount: 5), - Tweet( - id: '8', - pubkey: "pubkey", - userFirstName: 'Lute', - userUserName: 'Lute100', - userProfilePic: 'assets/images/CaptainJackSparrow.jpg', - content: 'I still don\'t understand why... lorem ipsum', - imageLinks: ['assets/images/content.png'], - tweetedAt: 0, - tags: [], - replies: [], - likesCount: 2, - commentsCount: 3, - retweetsCount: 5), - Tweet( - id: '9', - pubkey: "pubkey", - userFirstName: 'Lute', - userUserName: 'Lute100', - userProfilePic: 'assets/images/CaptainJackSparrow.jpg', - content: 'I still don\'t understand why... lorem ipsum', - imageLinks: ['assets/images/content.png'], - tweetedAt: 0, - tags: [], - replies: [], - likesCount: 2, - commentsCount: 3, - retweetsCount: 5), - Tweet( - id: '10', - pubkey: "pubkey", - userFirstName: 'Lute', - userUserName: 'Lute100', - userProfilePic: 'assets/images/CaptainJackSparrow.jpg', - content: 'I still don\'t understand why... lorem ipsum', - imageLinks: ['assets/images/content.png'], - tweetedAt: 0, - tags: [], - replies: [], - likesCount: 2, - commentsCount: 3, - retweetsCount: 5), -]; diff --git a/lib/models/api_nostr_band_hashtags.dart b/lib/models/api_nostr_band_hashtags.dart deleted file mode 100644 index eedc6082..00000000 --- a/lib/models/api_nostr_band_hashtags.dart +++ /dev/null @@ -1,155 +0,0 @@ -class ApiNostrBandHashtags { - final String type; - final List hashtags; - final Map relays; - - ApiNostrBandHashtags({ - required this.type, - required this.hashtags, - required this.relays, - }); - - factory ApiNostrBandHashtags.fromJson(Map json) { - List hashtagsJson = json['hashtags'] ?? []; - List hashtags = []; - //cast using for loop - for (Map hashtag in hashtagsJson) { - var myHashtag = Hashtags.fromJson(hashtag); - // filter out some hashtags - // todo sentiment analysis - if (myHashtag.hashtag.contains('nude')) continue; - if (myHashtag.hashtag.contains('nsfw')) continue; - hashtags.add(Hashtags.fromJson(hashtag)); - } - - return ApiNostrBandHashtags( - type: json['type'], - hashtags: hashtags, - relays: json['relays'], - ); - } - - Map toJson() { - final Map data = {}; - data['type'] = type; - data['hashtags'] = hashtags.map((v) => v.toJson()).toList(); - data['relays'] = relays; - return data; - } -} - -class Hashtags { - final String hashtag; - final int threadsCount; - final List threads; - - Hashtags({ - required this.hashtag, - required this.threadsCount, - required this.threads, - }); - - factory Hashtags.fromJson(Map json) { - List threadsJson = json['threads'] ?? []; - List threads = []; - //cast using for loop - for (Map thread in threadsJson) { - threads.add(Threads.fromJson(thread)); - } - - return Hashtags( - hashtag: json['hashtag'], - threadsCount: json['threads_count'], - threads: threads, - ); - } - - Map toJson() { - final Map data = {}; - data['hashtag'] = hashtag; - data['threads_count'] = threadsCount; - data['threads'] = threads.map((v) => v.toJson()).toList(); - return data; - } -} - -class Threads { - final String id; - final int kind; - final String pubkey; - final int createdAt; - final String content; - final int replies; - final int upvotes; - final int downvotes; - final int reposts; - final int zappers; - final int zapAmount; - final String replyToId; - final String rootId; - final String lang; - final List relays; - final Map author; - - Threads({ - required this.id, - required this.kind, - required this.pubkey, - required this.createdAt, - required this.content, - required this.replies, - required this.upvotes, - required this.downvotes, - required this.reposts, - required this.zappers, - required this.zapAmount, - required this.replyToId, - required this.rootId, - required this.lang, - required this.relays, - required this.author, - }); - - factory Threads.fromJson(Map json) { - return Threads( - id: json['id'] ?? '', - kind: json['kind'] ?? -1, - pubkey: json['pubkey'] ?? '', - createdAt: json['created_at'] ?? -1, - content: json['content'] ?? '', - replies: json['replies'] ?? 0, - upvotes: json['upvotes'] ?? 0, - downvotes: json['downvotes'] ?? 0, - reposts: json['reposts'] ?? 0, - zappers: json['zappers'] ?? 0, - zapAmount: json['zap_amount'] ?? 0, - replyToId: json['reply_to_id'] ?? '', - rootId: json['root_id'] ?? '', - lang: json['lang'] ?? '', - relays: json['relays'].cast() ?? [], - author: json['author'] ?? '', - ); - } - - Map toJson() { - final Map data = {}; - data['id'] = id; - data['kind'] = kind; - data['pubkey'] = pubkey; - data['created_at'] = createdAt; - data['content'] = content; - data['replies'] = replies; - data['upvotes'] = upvotes; - data['downvotes'] = downvotes; - data['reposts'] = reposts; - data['zappers'] = zappers; - data['zap_amount'] = zapAmount; - data['reply_to_id'] = replyToId; - data['root_id'] = rootId; - data['lang'] = lang; - data['relays'] = relays; - data['author'] = author; - - return data; - } -} diff --git a/lib/models/api_nostr_band_people.dart b/lib/models/api_nostr_band_people.dart deleted file mode 100644 index 428a582d..00000000 --- a/lib/models/api_nostr_band_people.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:camelus/models/nostr_note.dart'; - -class ApiNostrBandPeople { - final List profiles; - - ApiNostrBandPeople({required this.profiles}); - - factory ApiNostrBandPeople.fromJson(Map json) { - List profilesJson = json['profiles'] ?? []; - List profiles = []; - //cast using for loop - for (Map profile in profilesJson) { - profiles.add(Profiles.fromJson(profile)); - } - - return ApiNostrBandPeople( - profiles: profiles, - ); - } - - Map toJson() { - final Map data = {}; - data['profiles'] = profiles.map((v) => v.toJson()).toList(); - return data; - } -} - -class Profiles { - String pubkey; - int newFollowersCount; - List relays; - NostrNote profile; - - Profiles( - {required this.pubkey, - required this.newFollowersCount, - required this.relays, - required this.profile}); - - factory Profiles.fromJson(Map json) { - List relaysJson = json['relays'] ?? []; - List relays = []; - //cast using for loop - for (String relay in relaysJson) { - relays.add(relay); - } - - return Profiles( - pubkey: json['pubkey'], - newFollowersCount: json['new_followers_count'], - relays: relays, - profile: NostrNote.fromJson(json['profile']), - ); - } - - Map toJson() { - final Map data = {}; - data['pubkey'] = pubkey; - data['new_followers_count'] = newFollowersCount; - data['relays'] = relays; - data['profile'] = profile.toJson(); - return data; - } -} diff --git a/lib/models/nostr_request_event.dart b/lib/models/nostr_request_event.dart deleted file mode 100644 index cbed1ed6..00000000 --- a/lib/models/nostr_request_event.dart +++ /dev/null @@ -1,92 +0,0 @@ -// ignore_for_file: non_constant_identifier_names - -import 'dart:convert'; - -import 'package:camelus/helpers/bip340.dart'; -import 'package:camelus/models/nostr_request.dart'; -import 'package:camelus/models/nostr_tag.dart'; -import 'package:crypto/crypto.dart'; -import 'package:hex/hex.dart'; - -class NostrRequestEvent implements NostrRequest { - final String type = "EVENT"; - @override - final String subscriptionId = "EVENT"; - final NostrRequestEventBody body; - - NostrRequestEvent({ - required this.body, - }); - - @override - String toRawList() { - return jsonEncode([type, body.toMap()]); - } - - @override - String toString() { - return toRawList().toString(); - } -} - -class NostrRequestEventBody { - late String id; - String pubkey; - int? created_at; - int kind; - List tags; - String content; - late String sig; - String privateKey; - - NostrRequestEventBody({ - required this.pubkey, - required this.kind, - required this.tags, - required this.content, - required this.privateKey, - this.created_at, - }) { - _signEvent(); - } - - _signEvent() { - created_at ??= DateTime.now().millisecondsSinceEpoch ~/ 1000; - - var tagsList = tags.map((e) => e.toList()).toList(); - var calcId = [ - 0, - pubkey, - created_at, - kind, - tagsList, - content, - ]; - - // serialize - String calcIdJson = jsonEncode(calcId); - // hash - Digest myId = sha256.convert(utf8.encode(calcIdJson)); - - id = myId.toString(); - // hex encode - String idHex = HEX.encode(myId.bytes); - - // sign - sig = Bip340().sign(idHex, privateKey); - } - - Map toMap() { - var body = { - "id": id, - "pubkey": pubkey, - "created_at": created_at, - "kind": kind, - "tags": tags.map((e) => e.toList()).toList(), - "content": content, - "sig": sig, - }; - - return body; - } -} diff --git a/lib/models/socket_control.dart b/lib/models/socket_control.dart deleted file mode 100644 index 3b771040..00000000 --- a/lib/models/socket_control.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -class SocketControl { - late WebSocket socket; - String id; - String connectionUrl; - Map requestInFlight = {}; - Map completers = {}; - Map streamControllers = {}; - Map additionalData = {}; - bool socketIsRdy = false; - bool socketIsFailing = false; - int socketFailingAttempts = 0; - int socketReceivedEventsCount = 0; - SocketControl(this.id, this.connectionUrl); -} diff --git a/lib/objectbox-model.json b/lib/objectbox-model.json new file mode 100644 index 00000000..4c5c60a5 --- /dev/null +++ b/lib/objectbox-model.json @@ -0,0 +1,270 @@ +{ + "_note1": "KEEP THIS FILE! Check it into a version control system (VCS) like git.", + "_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.", + "_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.", + "entities": [ + { + "id": "1:2806451883182902305", + "lastPropertyId": "11:2242751383943128321", + "name": "DbContactList", + "properties": [ + { + "id": "1:8374817962340320695", + "name": "dbId", + "type": 6, + "flags": 1 + }, + { + "id": "2:118913156440761227", + "name": "pubKey", + "type": 9 + }, + { + "id": "3:3988977713679332869", + "name": "contacts", + "type": 30 + }, + { + "id": "4:7505788900917168131", + "name": "contactRelays", + "type": 30 + }, + { + "id": "5:3802357609372255783", + "name": "petnames", + "type": 30 + }, + { + "id": "6:9126320631476430358", + "name": "followedTags", + "type": 30 + }, + { + "id": "7:6459494981709908887", + "name": "followedCommunities", + "type": 30 + }, + { + "id": "8:1170388422710485888", + "name": "followedEvents", + "type": 30 + }, + { + "id": "9:6328239475695679116", + "name": "createdAt", + "type": 6 + }, + { + "id": "10:3268815470426490725", + "name": "loadedTimestamp", + "type": 6 + }, + { + "id": "11:2242751383943128321", + "name": "sources", + "type": 30 + } + ], + "relations": [] + }, + { + "id": "2:7045118365482088719", + "lastPropertyId": "13:625565655505635253", + "name": "DbMetadata", + "properties": [ + { + "id": "1:4330169555137470934", + "name": "dbId", + "type": 6, + "flags": 1 + }, + { + "id": "2:3464231542098784192", + "name": "pubKey", + "type": 9 + }, + { + "id": "3:5392082724079921345", + "name": "name", + "type": 9 + }, + { + "id": "4:34763608585221932", + "name": "displayName", + "type": 9 + }, + { + "id": "5:7195627064586611851", + "name": "picture", + "type": 9 + }, + { + "id": "6:6392999448563320747", + "name": "banner", + "type": 9 + }, + { + "id": "7:5403911428383475473", + "name": "website", + "type": 9 + }, + { + "id": "8:8521496281434389408", + "name": "about", + "type": 9 + }, + { + "id": "9:4624017895028219975", + "name": "nip05", + "type": 9 + }, + { + "id": "10:2625185019158752804", + "name": "lud16", + "type": 9 + }, + { + "id": "11:5133118682109775824", + "name": "lud06", + "type": 9 + }, + { + "id": "12:7319656810309498589", + "name": "updatedAt", + "type": 6 + }, + { + "id": "13:625565655505635253", + "name": "refreshedTimestamp", + "type": 6 + } + ], + "relations": [] + }, + { + "id": "3:5777745579062928149", + "lastPropertyId": "10:4549740388197145640", + "name": "DbNip01Event", + "properties": [ + { + "id": "1:8889142161852307322", + "name": "dbId", + "type": 6, + "flags": 1 + }, + { + "id": "2:2607671529139599209", + "name": "nostrId", + "type": 9 + }, + { + "id": "3:1742261979313979521", + "name": "pubKey", + "type": 9 + }, + { + "id": "4:1912458014025441431", + "name": "createdAt", + "type": 6 + }, + { + "id": "5:7665287738979309858", + "name": "kind", + "type": 6 + }, + { + "id": "6:5998738483503256564", + "name": "sources", + "type": 30 + }, + { + "id": "7:3052396182256255852", + "name": "content", + "type": 9 + }, + { + "id": "8:2256270652353437209", + "name": "sig", + "type": 9 + }, + { + "id": "9:5695875884701701406", + "name": "validSig", + "type": 1 + }, + { + "id": "10:4549740388197145640", + "name": "dbTags", + "type": 30 + } + ], + "relations": [] + }, + { + "id": "4:1057608640111619122", + "lastPropertyId": "4:4009580019178375753", + "name": "DbTag", + "properties": [ + { + "id": "1:271290166028924796", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:1088413477167386291", + "name": "key", + "type": 9 + }, + { + "id": "3:801123517618886949", + "name": "value", + "type": 9 + }, + { + "id": "4:4009580019178375753", + "name": "marker", + "type": 9 + } + ], + "relations": [] + }, + { + "id": "5:6462764810841603998", + "lastPropertyId": "3:7709695228344755827", + "name": "DbKeyValue", + "properties": [ + { + "id": "1:9167192968750793916", + "name": "dbId", + "type": 6, + "flags": 1 + }, + { + "id": "2:2800279101748505891", + "name": "key", + "type": 9, + "flags": 2080, + "indexId": "1:6758634951668169698" + }, + { + "id": "3:7709695228344755827", + "name": "value", + "type": 9 + } + ], + "relations": [] + } + ], + "lastEntityId": "5:6462764810841603998", + "lastIndexId": "1:6758634951668169698", + "lastRelationId": "0:0", + "lastSequenceId": "0:0", + "modelVersion": 5, + "modelVersionParserMinimum": 5, + "retiredEntityUids": [], + "retiredIndexUids": [], + "retiredPropertyUids": [], + "retiredRelationUids": [], + "version": 1 +} \ No newline at end of file diff --git a/lib/objectbox.g.dart b/lib/objectbox.g.dart new file mode 100644 index 00000000..e0c7c9ce --- /dev/null +++ b/lib/objectbox.g.dart @@ -0,0 +1,815 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// This code was generated by ObjectBox. To update it run the generator again +// with `dart run build_runner build`. +// See also https://docs.objectbox.io/getting-started#generate-objectbox-code + +// ignore_for_file: camel_case_types, depend_on_referenced_packages +// coverage:ignore-file + +import 'dart:typed_data'; + +import 'package:flat_buffers/flat_buffers.dart' as fb; +import 'package:objectbox/internal.dart' + as obx_int; // generated code can access "internal" functionality +import 'package:objectbox/objectbox.dart' as obx; +import 'package:objectbox_flutter_libs/objectbox_flutter_libs.dart'; + +import 'data_layer/db/object_box_camelus/schema/db_key_value.dart'; +import 'data_layer/db/object_box_ndk/schema/db_contact_list.dart'; +import 'data_layer/db/object_box_ndk/schema/db_metadata.dart'; +import 'data_layer/db/object_box_ndk/schema/db_nip_01_event.dart'; + +export 'package:objectbox/objectbox.dart'; // so that callers only have to import this file + +final _entities = [ + obx_int.ModelEntity( + id: const obx_int.IdUid(1, 2806451883182902305), + name: 'DbContactList', + lastPropertyId: const obx_int.IdUid(11, 2242751383943128321), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 8374817962340320695), + name: 'dbId', + type: 6, + flags: 1), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 118913156440761227), + name: 'pubKey', + type: 9, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 3988977713679332869), + name: 'contacts', + type: 30, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 7505788900917168131), + name: 'contactRelays', + type: 30, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(5, 3802357609372255783), + name: 'petnames', + type: 30, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(6, 9126320631476430358), + name: 'followedTags', + type: 30, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(7, 6459494981709908887), + name: 'followedCommunities', + type: 30, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(8, 1170388422710485888), + name: 'followedEvents', + type: 30, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(9, 6328239475695679116), + name: 'createdAt', + type: 6, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(10, 3268815470426490725), + name: 'loadedTimestamp', + type: 6, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(11, 2242751383943128321), + name: 'sources', + type: 30, + flags: 0) + ], + relations: [], + backlinks: []), + obx_int.ModelEntity( + id: const obx_int.IdUid(2, 7045118365482088719), + name: 'DbMetadata', + lastPropertyId: const obx_int.IdUid(13, 625565655505635253), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 4330169555137470934), + name: 'dbId', + type: 6, + flags: 1), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 3464231542098784192), + name: 'pubKey', + type: 9, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 5392082724079921345), + name: 'name', + type: 9, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 34763608585221932), + name: 'displayName', + type: 9, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(5, 7195627064586611851), + name: 'picture', + type: 9, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(6, 6392999448563320747), + name: 'banner', + type: 9, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(7, 5403911428383475473), + name: 'website', + type: 9, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(8, 8521496281434389408), + name: 'about', + type: 9, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(9, 4624017895028219975), + name: 'nip05', + type: 9, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(10, 2625185019158752804), + name: 'lud16', + type: 9, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(11, 5133118682109775824), + name: 'lud06', + type: 9, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(12, 7319656810309498589), + name: 'updatedAt', + type: 6, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(13, 625565655505635253), + name: 'refreshedTimestamp', + type: 6, + flags: 0) + ], + relations: [], + backlinks: []), + obx_int.ModelEntity( + id: const obx_int.IdUid(3, 5777745579062928149), + name: 'DbNip01Event', + lastPropertyId: const obx_int.IdUid(10, 4549740388197145640), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 8889142161852307322), + name: 'dbId', + type: 6, + flags: 1), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 2607671529139599209), + name: 'nostrId', + type: 9, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 1742261979313979521), + name: 'pubKey', + type: 9, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 1912458014025441431), + name: 'createdAt', + type: 6, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(5, 7665287738979309858), + name: 'kind', + type: 6, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(6, 5998738483503256564), + name: 'sources', + type: 30, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(7, 3052396182256255852), + name: 'content', + type: 9, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(8, 2256270652353437209), + name: 'sig', + type: 9, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(9, 5695875884701701406), + name: 'validSig', + type: 1, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(10, 4549740388197145640), + name: 'dbTags', + type: 30, + flags: 0) + ], + relations: [], + backlinks: []), + obx_int.ModelEntity( + id: const obx_int.IdUid(4, 1057608640111619122), + name: 'DbTag', + lastPropertyId: const obx_int.IdUid(4, 4009580019178375753), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 271290166028924796), + name: 'id', + type: 6, + flags: 1), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 1088413477167386291), + name: 'key', + type: 9, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 801123517618886949), + name: 'value', + type: 9, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 4009580019178375753), + name: 'marker', + type: 9, + flags: 0) + ], + relations: [], + backlinks: []), + obx_int.ModelEntity( + id: const obx_int.IdUid(5, 6462764810841603998), + name: 'DbKeyValue', + lastPropertyId: const obx_int.IdUid(3, 7709695228344755827), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 9167192968750793916), + name: 'dbId', + type: 6, + flags: 1), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 2800279101748505891), + name: 'key', + type: 9, + flags: 2080, + indexId: const obx_int.IdUid(1, 6758634951668169698)), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 7709695228344755827), + name: 'value', + type: 9, + flags: 0) + ], + relations: [], + backlinks: []) +]; + +/// Shortcut for [obx.Store.new] that passes [getObjectBoxModel] and for Flutter +/// apps by default a [directory] using `defaultStoreDirectory()` from the +/// ObjectBox Flutter library. +/// +/// Note: for desktop apps it is recommended to specify a unique [directory]. +/// +/// See [obx.Store.new] for an explanation of all parameters. +/// +/// For Flutter apps, also calls `loadObjectBoxLibraryAndroidCompat()` from +/// the ObjectBox Flutter library to fix loading the native ObjectBox library +/// on Android 6 and older. +Future openStore( + {String? directory, + int? maxDBSizeInKB, + int? maxDataSizeInKB, + int? fileMode, + int? maxReaders, + bool queriesCaseSensitiveDefault = true, + String? macosApplicationGroup}) async { + await loadObjectBoxLibraryAndroidCompat(); + return obx.Store(getObjectBoxModel(), + directory: directory ?? (await defaultStoreDirectory()).path, + maxDBSizeInKB: maxDBSizeInKB, + maxDataSizeInKB: maxDataSizeInKB, + fileMode: fileMode, + maxReaders: maxReaders, + queriesCaseSensitiveDefault: queriesCaseSensitiveDefault, + macosApplicationGroup: macosApplicationGroup); +} + +/// Returns the ObjectBox model definition for this project for use with +/// [obx.Store.new]. +obx_int.ModelDefinition getObjectBoxModel() { + final model = obx_int.ModelInfo( + entities: _entities, + lastEntityId: const obx_int.IdUid(5, 6462764810841603998), + lastIndexId: const obx_int.IdUid(1, 6758634951668169698), + lastRelationId: const obx_int.IdUid(0, 0), + lastSequenceId: const obx_int.IdUid(0, 0), + retiredEntityUids: const [], + retiredIndexUids: const [], + retiredPropertyUids: const [], + retiredRelationUids: const [], + modelVersion: 5, + modelVersionParserMinimum: 5, + version: 1); + + final bindings = { + DbContactList: obx_int.EntityDefinition( + model: _entities[0], + toOneRelations: (DbContactList object) => [], + toManyRelations: (DbContactList object) => {}, + getId: (DbContactList object) => object.dbId, + setId: (DbContactList object, int id) { + object.dbId = id; + }, + objectToFB: (DbContactList object, fb.Builder fbb) { + final pubKeyOffset = fbb.writeString(object.pubKey); + final contactsOffset = fbb.writeList( + object.contacts.map(fbb.writeString).toList(growable: false)); + final contactRelaysOffset = fbb.writeList(object.contactRelays + .map(fbb.writeString) + .toList(growable: false)); + final petnamesOffset = fbb.writeList( + object.petnames.map(fbb.writeString).toList(growable: false)); + final followedTagsOffset = fbb.writeList( + object.followedTags.map(fbb.writeString).toList(growable: false)); + final followedCommunitiesOffset = fbb.writeList(object + .followedCommunities + .map(fbb.writeString) + .toList(growable: false)); + final followedEventsOffset = fbb.writeList(object.followedEvents + .map(fbb.writeString) + .toList(growable: false)); + final sourcesOffset = fbb.writeList( + object.sources.map(fbb.writeString).toList(growable: false)); + fbb.startTable(12); + fbb.addInt64(0, object.dbId); + fbb.addOffset(1, pubKeyOffset); + fbb.addOffset(2, contactsOffset); + fbb.addOffset(3, contactRelaysOffset); + fbb.addOffset(4, petnamesOffset); + fbb.addOffset(5, followedTagsOffset); + fbb.addOffset(6, followedCommunitiesOffset); + fbb.addOffset(7, followedEventsOffset); + fbb.addInt64(8, object.createdAt); + fbb.addInt64(9, object.loadedTimestamp); + fbb.addOffset(10, sourcesOffset); + fbb.finish(fbb.endTable()); + return object.dbId; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final pubKeyParam = const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 6, ''); + final contactsParam = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false) + .vTableGet(buffer, rootOffset, 8, []); + final object = DbContactList( + pubKey: pubKeyParam, contacts: contactsParam) + ..dbId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0) + ..contactRelays = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false) + .vTableGet(buffer, rootOffset, 10, []) + ..petnames = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false) + .vTableGet(buffer, rootOffset, 12, []) + ..followedTags = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false) + .vTableGet(buffer, rootOffset, 14, []) + ..followedCommunities = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false) + .vTableGet(buffer, rootOffset, 16, []) + ..followedEvents = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false) + .vTableGet(buffer, rootOffset, 18, []) + ..createdAt = + const fb.Int64Reader().vTableGet(buffer, rootOffset, 20, 0) + ..loadedTimestamp = + const fb.Int64Reader().vTableGetNullable(buffer, rootOffset, 22) + ..sources = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false) + .vTableGet(buffer, rootOffset, 24, []); + + return object; + }), + DbMetadata: obx_int.EntityDefinition( + model: _entities[1], + toOneRelations: (DbMetadata object) => [], + toManyRelations: (DbMetadata object) => {}, + getId: (DbMetadata object) => object.dbId, + setId: (DbMetadata object, int id) { + object.dbId = id; + }, + objectToFB: (DbMetadata object, fb.Builder fbb) { + final pubKeyOffset = fbb.writeString(object.pubKey); + final nameOffset = + object.name == null ? null : fbb.writeString(object.name!); + final displayNameOffset = object.displayName == null + ? null + : fbb.writeString(object.displayName!); + final pictureOffset = + object.picture == null ? null : fbb.writeString(object.picture!); + final bannerOffset = + object.banner == null ? null : fbb.writeString(object.banner!); + final websiteOffset = + object.website == null ? null : fbb.writeString(object.website!); + final aboutOffset = + object.about == null ? null : fbb.writeString(object.about!); + final nip05Offset = + object.nip05 == null ? null : fbb.writeString(object.nip05!); + final lud16Offset = + object.lud16 == null ? null : fbb.writeString(object.lud16!); + final lud06Offset = + object.lud06 == null ? null : fbb.writeString(object.lud06!); + fbb.startTable(14); + fbb.addInt64(0, object.dbId); + fbb.addOffset(1, pubKeyOffset); + fbb.addOffset(2, nameOffset); + fbb.addOffset(3, displayNameOffset); + fbb.addOffset(4, pictureOffset); + fbb.addOffset(5, bannerOffset); + fbb.addOffset(6, websiteOffset); + fbb.addOffset(7, aboutOffset); + fbb.addOffset(8, nip05Offset); + fbb.addOffset(9, lud16Offset); + fbb.addOffset(10, lud06Offset); + fbb.addInt64(11, object.updatedAt); + fbb.addInt64(12, object.refreshedTimestamp); + fbb.finish(fbb.endTable()); + return object.dbId; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final pubKeyParam = const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 6, ''); + final nameParam = const fb.StringReader(asciiOptimization: true) + .vTableGetNullable(buffer, rootOffset, 8); + final displayNameParam = + const fb.StringReader(asciiOptimization: true) + .vTableGetNullable(buffer, rootOffset, 10); + final pictureParam = const fb.StringReader(asciiOptimization: true) + .vTableGetNullable(buffer, rootOffset, 12); + final bannerParam = const fb.StringReader(asciiOptimization: true) + .vTableGetNullable(buffer, rootOffset, 14); + final websiteParam = const fb.StringReader(asciiOptimization: true) + .vTableGetNullable(buffer, rootOffset, 16); + final aboutParam = const fb.StringReader(asciiOptimization: true) + .vTableGetNullable(buffer, rootOffset, 18); + final nip05Param = const fb.StringReader(asciiOptimization: true) + .vTableGetNullable(buffer, rootOffset, 20); + final lud16Param = const fb.StringReader(asciiOptimization: true) + .vTableGetNullable(buffer, rootOffset, 22); + final lud06Param = const fb.StringReader(asciiOptimization: true) + .vTableGetNullable(buffer, rootOffset, 24); + final updatedAtParam = + const fb.Int64Reader().vTableGetNullable(buffer, rootOffset, 26); + final refreshedTimestampParam = + const fb.Int64Reader().vTableGetNullable(buffer, rootOffset, 28); + final object = DbMetadata( + pubKey: pubKeyParam, + name: nameParam, + displayName: displayNameParam, + picture: pictureParam, + banner: bannerParam, + website: websiteParam, + about: aboutParam, + nip05: nip05Param, + lud16: lud16Param, + lud06: lud06Param, + updatedAt: updatedAtParam, + refreshedTimestamp: refreshedTimestampParam) + ..dbId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); + + return object; + }), + DbNip01Event: obx_int.EntityDefinition( + model: _entities[2], + toOneRelations: (DbNip01Event object) => [], + toManyRelations: (DbNip01Event object) => {}, + getId: (DbNip01Event object) => object.dbId, + setId: (DbNip01Event object, int id) { + object.dbId = id; + }, + objectToFB: (DbNip01Event object, fb.Builder fbb) { + final nostrIdOffset = fbb.writeString(object.nostrId); + final pubKeyOffset = fbb.writeString(object.pubKey); + final sourcesOffset = fbb.writeList( + object.sources.map(fbb.writeString).toList(growable: false)); + final contentOffset = fbb.writeString(object.content); + final sigOffset = fbb.writeString(object.sig); + final dbTagsOffset = fbb.writeList( + object.dbTags.map(fbb.writeString).toList(growable: false)); + fbb.startTable(11); + fbb.addInt64(0, object.dbId); + fbb.addOffset(1, nostrIdOffset); + fbb.addOffset(2, pubKeyOffset); + fbb.addInt64(3, object.createdAt); + fbb.addInt64(4, object.kind); + fbb.addOffset(5, sourcesOffset); + fbb.addOffset(6, contentOffset); + fbb.addOffset(7, sigOffset); + fbb.addBool(8, object.validSig); + fbb.addOffset(9, dbTagsOffset); + fbb.finish(fbb.endTable()); + return object.dbId; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final pubKeyParam = const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 8, ''); + final kindParam = + const fb.Int64Reader().vTableGet(buffer, rootOffset, 12, 0); + final dbTagsParam = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false) + .vTableGet(buffer, rootOffset, 22, []); + final contentParam = const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 16, ''); + final createdAtParam = + const fb.Int64Reader().vTableGet(buffer, rootOffset, 10, 0); + final object = DbNip01Event( + pubKey: pubKeyParam, + kind: kindParam, + dbTags: dbTagsParam, + content: contentParam, + createdAt: createdAtParam) + ..dbId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0) + ..nostrId = const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 6, '') + ..sources = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false) + .vTableGet(buffer, rootOffset, 14, []) + ..sig = const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 18, '') + ..validSig = + const fb.BoolReader().vTableGetNullable(buffer, rootOffset, 20); + + return object; + }), + DbTag: obx_int.EntityDefinition( + model: _entities[3], + toOneRelations: (DbTag object) => [], + toManyRelations: (DbTag object) => {}, + getId: (DbTag object) => object.id, + setId: (DbTag object, int id) { + object.id = id; + }, + objectToFB: (DbTag object, fb.Builder fbb) { + final keyOffset = fbb.writeString(object.key); + final valueOffset = fbb.writeString(object.value); + final markerOffset = + object.marker == null ? null : fbb.writeString(object.marker!); + fbb.startTable(5); + fbb.addInt64(0, object.id); + fbb.addOffset(1, keyOffset); + fbb.addOffset(2, valueOffset); + fbb.addOffset(3, markerOffset); + fbb.finish(fbb.endTable()); + return object.id; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final keyParam = const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 6, ''); + final valueParam = const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 8, ''); + final markerParam = const fb.StringReader(asciiOptimization: true) + .vTableGetNullable(buffer, rootOffset, 10); + final object = DbTag( + key: keyParam, value: valueParam, marker: markerParam) + ..id = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); + + return object; + }), + DbKeyValue: obx_int.EntityDefinition( + model: _entities[4], + toOneRelations: (DbKeyValue object) => [], + toManyRelations: (DbKeyValue object) => {}, + getId: (DbKeyValue object) => object.dbId, + setId: (DbKeyValue object, int id) { + object.dbId = id; + }, + objectToFB: (DbKeyValue object, fb.Builder fbb) { + final keyOffset = fbb.writeString(object.key); + final valueOffset = fbb.writeString(object.value); + fbb.startTable(4); + fbb.addInt64(0, object.dbId); + fbb.addOffset(1, keyOffset); + fbb.addOffset(2, valueOffset); + fbb.finish(fbb.endTable()); + return object.dbId; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final keyParam = const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 6, ''); + final valueParam = const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 8, ''); + final object = DbKeyValue(key: keyParam, value: valueParam) + ..dbId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); + + return object; + }) + }; + + return obx_int.ModelDefinition(model, bindings); +} + +/// [DbContactList] entity fields to define ObjectBox queries. +class DbContactList_ { + /// See [DbContactList.dbId]. + static final dbId = + obx.QueryIntegerProperty(_entities[0].properties[0]); + + /// See [DbContactList.pubKey]. + static final pubKey = + obx.QueryStringProperty(_entities[0].properties[1]); + + /// See [DbContactList.contacts]. + static final contacts = + obx.QueryStringVectorProperty(_entities[0].properties[2]); + + /// See [DbContactList.contactRelays]. + static final contactRelays = + obx.QueryStringVectorProperty(_entities[0].properties[3]); + + /// See [DbContactList.petnames]. + static final petnames = + obx.QueryStringVectorProperty(_entities[0].properties[4]); + + /// See [DbContactList.followedTags]. + static final followedTags = + obx.QueryStringVectorProperty(_entities[0].properties[5]); + + /// See [DbContactList.followedCommunities]. + static final followedCommunities = + obx.QueryStringVectorProperty(_entities[0].properties[6]); + + /// See [DbContactList.followedEvents]. + static final followedEvents = + obx.QueryStringVectorProperty(_entities[0].properties[7]); + + /// See [DbContactList.createdAt]. + static final createdAt = + obx.QueryIntegerProperty(_entities[0].properties[8]); + + /// See [DbContactList.loadedTimestamp]. + static final loadedTimestamp = + obx.QueryIntegerProperty(_entities[0].properties[9]); + + /// See [DbContactList.sources]. + static final sources = + obx.QueryStringVectorProperty(_entities[0].properties[10]); +} + +/// [DbMetadata] entity fields to define ObjectBox queries. +class DbMetadata_ { + /// See [DbMetadata.dbId]. + static final dbId = + obx.QueryIntegerProperty(_entities[1].properties[0]); + + /// See [DbMetadata.pubKey]. + static final pubKey = + obx.QueryStringProperty(_entities[1].properties[1]); + + /// See [DbMetadata.name]. + static final name = + obx.QueryStringProperty(_entities[1].properties[2]); + + /// See [DbMetadata.displayName]. + static final displayName = + obx.QueryStringProperty(_entities[1].properties[3]); + + /// See [DbMetadata.picture]. + static final picture = + obx.QueryStringProperty(_entities[1].properties[4]); + + /// See [DbMetadata.banner]. + static final banner = + obx.QueryStringProperty(_entities[1].properties[5]); + + /// See [DbMetadata.website]. + static final website = + obx.QueryStringProperty(_entities[1].properties[6]); + + /// See [DbMetadata.about]. + static final about = + obx.QueryStringProperty(_entities[1].properties[7]); + + /// See [DbMetadata.nip05]. + static final nip05 = + obx.QueryStringProperty(_entities[1].properties[8]); + + /// See [DbMetadata.lud16]. + static final lud16 = + obx.QueryStringProperty(_entities[1].properties[9]); + + /// See [DbMetadata.lud06]. + static final lud06 = + obx.QueryStringProperty(_entities[1].properties[10]); + + /// See [DbMetadata.updatedAt]. + static final updatedAt = + obx.QueryIntegerProperty(_entities[1].properties[11]); + + /// See [DbMetadata.refreshedTimestamp]. + static final refreshedTimestamp = + obx.QueryIntegerProperty(_entities[1].properties[12]); +} + +/// [DbNip01Event] entity fields to define ObjectBox queries. +class DbNip01Event_ { + /// See [DbNip01Event.dbId]. + static final dbId = + obx.QueryIntegerProperty(_entities[2].properties[0]); + + /// See [DbNip01Event.nostrId]. + static final nostrId = + obx.QueryStringProperty(_entities[2].properties[1]); + + /// See [DbNip01Event.pubKey]. + static final pubKey = + obx.QueryStringProperty(_entities[2].properties[2]); + + /// See [DbNip01Event.createdAt]. + static final createdAt = + obx.QueryIntegerProperty(_entities[2].properties[3]); + + /// See [DbNip01Event.kind]. + static final kind = + obx.QueryIntegerProperty(_entities[2].properties[4]); + + /// See [DbNip01Event.sources]. + static final sources = + obx.QueryStringVectorProperty(_entities[2].properties[5]); + + /// See [DbNip01Event.content]. + static final content = + obx.QueryStringProperty(_entities[2].properties[6]); + + /// See [DbNip01Event.sig]. + static final sig = + obx.QueryStringProperty(_entities[2].properties[7]); + + /// See [DbNip01Event.validSig]. + static final validSig = + obx.QueryBooleanProperty(_entities[2].properties[8]); + + /// See [DbNip01Event.dbTags]. + static final dbTags = + obx.QueryStringVectorProperty(_entities[2].properties[9]); +} + +/// [DbTag] entity fields to define ObjectBox queries. +class DbTag_ { + /// See [DbTag.id]. + static final id = obx.QueryIntegerProperty(_entities[3].properties[0]); + + /// See [DbTag.key]. + static final key = obx.QueryStringProperty(_entities[3].properties[1]); + + /// See [DbTag.value]. + static final value = + obx.QueryStringProperty(_entities[3].properties[2]); + + /// See [DbTag.marker]. + static final marker = + obx.QueryStringProperty(_entities[3].properties[3]); +} + +/// [DbKeyValue] entity fields to define ObjectBox queries. +class DbKeyValue_ { + /// See [DbKeyValue.dbId]. + static final dbId = + obx.QueryIntegerProperty(_entities[4].properties[0]); + + /// See [DbKeyValue.key]. + static final key = + obx.QueryStringProperty(_entities[4].properties[1]); + + /// See [DbKeyValue.value]. + static final value = + obx.QueryStringProperty(_entities[4].properties[2]); +} diff --git a/lib/atoms/back_button_round.dart b/lib/presentation_layer/atoms/back_button_round.dart similarity index 100% rename from lib/atoms/back_button_round.dart rename to lib/presentation_layer/atoms/back_button_round.dart diff --git a/lib/presentation_layer/atoms/camer_upload.dart b/lib/presentation_layer/atoms/camer_upload.dart new file mode 100644 index 00000000..04c11a92 --- /dev/null +++ b/lib/presentation_layer/atoms/camer_upload.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +import '../../config/palette.dart'; + +class CameraUpload extends StatelessWidget { + final double size; + const CameraUpload({ + super.key, + this.size = 50, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: const BoxDecoration( + color: Palette.extraLightGray, + shape: BoxShape.circle, + ), + child: Stack( + alignment: Alignment.center, + children: [ + Icon( + Icons.camera_alt, + size: size / 3, + color: Palette.darkGray, + ), + Positioned( + bottom: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Palette.darkGray, + shape: BoxShape.circle, + ), + child: Icon( + Icons.add, + color: Colors.white, + size: size / 6, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation_layer/atoms/crop_avatar.dart b/lib/presentation_layer/atoms/crop_avatar.dart new file mode 100644 index 00000000..463edf98 --- /dev/null +++ b/lib/presentation_layer/atoms/crop_avatar.dart @@ -0,0 +1,78 @@ +import 'dart:typed_data'; + +import 'package:camelus/presentation_layer/atoms/long_button.dart'; +import 'package:camelus/config/palette.dart'; +import 'package:flutter/material.dart'; +import 'package:crop_your_image/crop_your_image.dart'; + +class CropAvatar extends StatefulWidget { + final Uint8List _imageData; + + final double aspectRatio; + + final bool roundUi; + + CropAvatar({ + super.key, + this.aspectRatio = 1, + this.roundUi = true, + required Uint8List imageData, + }) : _imageData = imageData; + + @override + State createState() => _CropAvatarState(); +} + +class _CropAvatarState extends State { + final _controller = CropController(); + bool _loading = false; + + @override + build(context) { + return Stack( + children: [ + Crop( + baseColor: Palette.background, + aspectRatio: widget.aspectRatio, + //radius: 150, + + interactive: true, + + withCircleUi: widget.roundUi, + image: widget._imageData, + controller: _controller, + + onCropped: (image) { + setState(() { + _loading = false; + }); + Navigator.pop(context, image); + }, + ), + Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Center( + child: SizedBox( + width: 250, + height: 40, + child: longButton( + loading: _loading, + inverted: true, + name: "apply", + onPressed: () { + setState(() { + _loading = true; + }); + _controller.crop(); + }, + ), + ), + ), + const SizedBox(height: 20), + ], + ), + ], + ); + } +} diff --git a/lib/atoms/follow_button.dart b/lib/presentation_layer/atoms/follow_button.dart similarity index 100% rename from lib/atoms/follow_button.dart rename to lib/presentation_layer/atoms/follow_button.dart diff --git a/lib/atoms/hashtag_card.dart b/lib/presentation_layer/atoms/hashtag_card.dart similarity index 100% rename from lib/atoms/hashtag_card.dart rename to lib/presentation_layer/atoms/hashtag_card.dart diff --git a/lib/atoms/long_button.dart b/lib/presentation_layer/atoms/long_button.dart similarity index 100% rename from lib/atoms/long_button.dart rename to lib/presentation_layer/atoms/long_button.dart diff --git a/lib/presentation_layer/atoms/mnemonic_grid.dart b/lib/presentation_layer/atoms/mnemonic_grid.dart new file mode 100644 index 00000000..2be53859 --- /dev/null +++ b/lib/presentation_layer/atoms/mnemonic_grid.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import '../../config/palette.dart'; + +class MnemonicSentenceGrid extends StatelessWidget { + final List words; + + final bool isVisible; + + const MnemonicSentenceGrid({ + super.key, + required this.words, + required this.isVisible, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 400, + width: 500, + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 150, + mainAxisExtent: 50, + childAspectRatio: 10 / 4, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + itemCount: words.length, + itemBuilder: (context, index) { + return Card( + elevation: 2, + child: Stack( + children: [ + Center( + child: isVisible + ? Text( + words[index], + style: const TextStyle(fontSize: 16), + ) + : const Text( + '••••', + style: TextStyle(fontSize: 16), + ), + ), + Positioned( + top: 2, + left: 4, + child: Text( + '${index + 1}', + style: const TextStyle( + fontSize: 10, + color: Palette.gray, + ), + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/presentation_layer/atoms/my_profile_picture.dart b/lib/presentation_layer/atoms/my_profile_picture.dart new file mode 100644 index 00000000..3bef2f3d --- /dev/null +++ b/lib/presentation_layer/atoms/my_profile_picture.dart @@ -0,0 +1,81 @@ +import 'package:camelus/config/dicebear.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:camelus/config/palette.dart'; +import 'package:cached_network_image/cached_network_image.dart'; + +class UserImage extends StatelessWidget { + const UserImage({ + super.key, + required this.imageUrl, + required this.pubkey, + this.size = 60, + this.filterQuality = FilterQuality.medium, + this.cacheHeight, + this.disableGif = false, + }); + + final String? imageUrl; + final String pubkey; + final double size; + final FilterQuality filterQuality; + final int? cacheHeight; + final bool disableGif; + + @override + Widget build(BuildContext context) { + final pictureUrl = imageUrl ?? "${Dicebear.baseUrl}$pubkey"; + + if (pictureUrl.contains(".png") || + pictureUrl.contains(".jpg") || + pictureUrl.contains(".jpeg") || + (!disableGif && pictureUrl.contains(".gif")) || + pictureUrl.contains(".webp") || + pictureUrl.contains(".avif")) { + return ClipOval( + child: SizedBox.fromSize( + size: Size.fromRadius(size / 2), + child: Container( + color: Palette.background, + child: CachedNetworkImage( + imageUrl: pictureUrl, + filterQuality: filterQuality, + progressIndicatorBuilder: (context, url, downloadProgress) => + const CircularProgressIndicator( + color: Palette.darkGray, + ), + errorWidget: (context, url, error) => const Icon(Icons.error), + memCacheWidth: cacheHeight ?? 150, + maxHeightDiskCache: cacheHeight ?? 150, + maxWidthDiskCache: cacheHeight ?? 150, + alignment: Alignment.center, + fit: BoxFit.cover, + ), + ), + ), + ); + } + + if (pictureUrl.contains(".svg")) { + return Container( + height: size, + width: size, + decoration: const BoxDecoration( + color: Palette.primary, + shape: BoxShape.circle, + ), + child: SvgPicture.network(pictureUrl), + ); + } + + return Container( + height: size, + width: size, + decoration: const BoxDecoration( + color: Palette.primary, + shape: BoxShape.circle, + ), + child: SvgPicture.network("${Dicebear.baseUrl}$pubkey"), + ); + } +} diff --git a/lib/atoms/new_posts_available.dart b/lib/presentation_layer/atoms/new_posts_available.dart similarity index 100% rename from lib/atoms/new_posts_available.dart rename to lib/presentation_layer/atoms/new_posts_available.dart diff --git a/lib/atoms/picture.dart b/lib/presentation_layer/atoms/picture.dart similarity index 73% rename from lib/atoms/picture.dart rename to lib/presentation_layer/atoms/picture.dart index f2a520c3..abeffaad 100644 --- a/lib/atoms/picture.dart +++ b/lib/presentation_layer/atoms/picture.dart @@ -2,10 +2,10 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; -Widget simplePicture(String pictureUrl, String? pubkey) { +Widget simplePicture(String? pictureUrl, String? pubkey) { if (pictureUrl == null) { return SvgPicture.network( - "https://avatars.dicebear.com/api/personas/$pubkey.svg"); + "https://api.dicebear.com/7.x/personas/svg?seed=$pubkey"); } if (pictureUrl.contains(".svg")) { @@ -15,6 +15,8 @@ Widget simplePicture(String pictureUrl, String? pubkey) { if (pictureUrl.contains(".png") || pictureUrl.contains(".jpg") || pictureUrl.contains(".jpeg") || + pictureUrl.endsWith(".webp") || + pictureUrl.contains(".avif") || pictureUrl.contains(".gif")) { return CachedNetworkImage( imageUrl: pictureUrl, @@ -25,5 +27,5 @@ Widget simplePicture(String pictureUrl, String? pubkey) { } return SvgPicture.network( - "https://avatars.dicebear.com/api/personas/$pubkey.svg"); + "https://api.dicebear.com/7.x/personas/svg?seed=$pubkey"); } diff --git a/lib/atoms/refresh_indicator_no_need.dart b/lib/presentation_layer/atoms/refresh_indicator_no_need.dart similarity index 75% rename from lib/atoms/refresh_indicator_no_need.dart rename to lib/presentation_layer/atoms/refresh_indicator_no_need.dart index 652b0f08..d60f4af4 100644 --- a/lib/atoms/refresh_indicator_no_need.dart +++ b/lib/presentation_layer/atoms/refresh_indicator_no_need.dart @@ -2,7 +2,7 @@ import 'package:camelus/config/palette.dart'; import 'package:custom_refresh_indicator/custom_refresh_indicator.dart'; import 'package:flutter/material.dart'; -Widget RefreshIndicatorNoNeed( +Widget refreshIndicatorNoNeed( {required Widget child, required Future Function() onRefresh}) { return CustomRefreshIndicator( builder: ( @@ -13,13 +13,19 @@ Widget RefreshIndicatorNoNeed( return Stack( children: [ myIndicator( - value: controller.value, loading: controller.state.isLoading), - Transform.scale( - scale: (controller.value * 0.2) < 0.1 - ? 1.0 - (controller.value * 0.2) - : 0.90, + value: controller.value, + loading: controller.state.isLoading, + ), + Transform.translate( + offset: Offset(0, controller.value * 50), child: child, ), + // Transform.scale( + // scale: (controller.value * 0.2) < 0.1 + // ? 1.0 - (controller.value * 0.2) + // : 0.90, + // child: child, + // ), ], ); }, diff --git a/lib/presentation_layer/atoms/round_image_border.dart b/lib/presentation_layer/atoms/round_image_border.dart new file mode 100644 index 00000000..b30e069b --- /dev/null +++ b/lib/presentation_layer/atoms/round_image_border.dart @@ -0,0 +1,34 @@ +import 'dart:typed_data'; + +import 'package:flutter/widgets.dart'; + +import '../../config/palette.dart'; + +class RoundImageWithBorder extends StatelessWidget { + final Uint8List image; + final double size; + + const RoundImageWithBorder({ + super.key, + required this.image, + this.size = 100, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Palette.black, width: 2), + ), + child: ClipOval( + child: Image.memory( + image, + fit: BoxFit.cover, + ), + ), + ); + } +} diff --git a/lib/presentation_layer/atoms/rounded_corner_painer.dart b/lib/presentation_layer/atoms/rounded_corner_painer.dart new file mode 100644 index 00000000..5f970d66 --- /dev/null +++ b/lib/presentation_layer/atoms/rounded_corner_painer.dart @@ -0,0 +1,26 @@ +import 'package:flutter/widgets.dart'; + +class RoundedCornerPainter extends CustomPainter { + final Color color; + + RoundedCornerPainter({required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0; + + final path = Path() + ..moveTo(0, 0) + ..lineTo(0, size.height / 2) + ..quadraticBezierTo(0, size.height, size.width / 2, size.height) + ..lineTo(size.width, size.height); + + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +} diff --git a/lib/atoms/spinner_center.dart b/lib/presentation_layer/atoms/spinner_center.dart similarity index 100% rename from lib/atoms/spinner_center.dart rename to lib/presentation_layer/atoms/spinner_center.dart diff --git a/lib/components/bottom_sheet_share.dart b/lib/presentation_layer/components/bottom_sheet_share.dart similarity index 95% rename from lib/components/bottom_sheet_share.dart rename to lib/presentation_layer/components/bottom_sheet_share.dart index 6af2582c..f5e475a4 100644 --- a/lib/components/bottom_sheet_share.dart +++ b/lib/presentation_layer/components/bottom_sheet_share.dart @@ -2,7 +2,7 @@ import 'dart:ui'; import 'package:camelus/config/palette.dart'; import 'package:camelus/helpers/nevent_helper.dart'; -import 'package:camelus/models/nostr_note.dart'; +import 'package:camelus/domain_layer/entities/nostr_note.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -63,7 +63,7 @@ void openBottomSheetShare(context, NostrNote note) { Column( children: [ IconButton( - tooltip: 'nostr.com', + tooltip: 'njump.me', onPressed: () { var bech32nevent = NeventHelper().mapToBech32({ @@ -72,7 +72,7 @@ void openBottomSheetShare(context, NostrNote note) { "relays": note.relayHints, }); _copyToClipboard( - 'https://nostr.com/$bech32nevent'); + 'https://njump.me/$bech32nevent'); }, icon: const Icon( Icons.link, diff --git a/lib/presentation_layer/components/comments_section.dart b/lib/presentation_layer/components/comments_section.dart new file mode 100644 index 00000000..0f9998e2 --- /dev/null +++ b/lib/presentation_layer/components/comments_section.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:camelus/config/palette.dart'; +import 'package:camelus/domain_layer/entities/nostr_note.dart'; +import 'package:camelus/domain_layer/entities/tree_node.dart'; +import 'package:camelus/presentation_layer/components/note_card/note_card_container.dart'; +import 'package:camelus/presentation_layer/atoms/rounded_corner_painer.dart'; + +class CommentSection extends StatelessWidget { + final TreeNode comment; + + const CommentSection({ + super.key, + required this.comment, + }); + + @override + Widget build(BuildContext context) { + return CommentTreeItem( + node: comment, + depth: 0, + ancestorHasSibling: [false], + ); + } +} + +class CommentTreeItem extends StatefulWidget { + final TreeNode node; + final int depth; + final List ancestorHasSibling; + + const CommentTreeItem({ + super.key, + required this.node, + required this.depth, + required this.ancestorHasSibling, + }); + + @override + CommentTreeItemState createState() => CommentTreeItemState(); +} + +class CommentTreeItemState extends State { + bool isExpanded = false; + + @override + void initState() { + super.initState(); + isExpanded = widget.node.children.length < 2; + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CommentCard( + node: widget.node, + depth: widget.depth, + ancestorHasSibling: widget.ancestorHasSibling, + isExpanded: isExpanded, + onToggleExpand: () { + setState(() { + isExpanded = !isExpanded; + }); + }, + ), + if (isExpanded) + ...widget.node.children.map((child) => CommentTreeItem( + node: child, + depth: widget.depth + 1, + ancestorHasSibling: [ + ...widget.ancestorHasSibling, + widget.node.hasSiblings + ], + )), + ], + ); + } +} + +class CommentCard extends StatelessWidget { + final TreeNode node; + final int depth; + final List ancestorHasSibling; + final bool isExpanded; + final VoidCallback onToggleExpand; + + const CommentCard({ + super.key, + required this.node, + required this.depth, + required this.ancestorHasSibling, + required this.isExpanded, + required this.onToggleExpand, + }); + + @override + Widget build(BuildContext context) { + final Color lineColor = Palette.darkGray; + + return Stack( + clipBehavior: Clip.none, + children: [ + Padding( + padding: EdgeInsets.only(left: depth * 16.0), + child: Stack( + clipBehavior: Clip.none, + children: [ + if (node.hasParent) + Positioned( + left: -15.0, + top: 0.0, + child: Column( + children: [ + Container( + width: 2.0, + height: 60.0, + color: lineColor, + ), + Padding( + padding: EdgeInsets.only(left: 10.0), + child: CustomPaint( + size: Size(10.0, 10.0), + painter: RoundedCornerPainter( + color: lineColor, + ), + ), + ), + ], + ), + ), + if (node.hasChildren) + Positioned( + left: 10.0, + top: 60, + bottom: 40, + child: Container( + width: 2.0, + color: lineColor, + ), + ), + if (node.hasChildren && node.children.length == 1) + Positioned( + left: 10.0, + top: 60, + bottom: 0, + child: Container( + width: 2.0, + color: lineColor, + ), + ), + if (node.hasChildren && isExpanded) + Positioned( + left: 10.0, + bottom: 0, + child: Container( + width: 2.0, + height: 22, + color: lineColor, + ), + ), + NoteCardContainer( + key: ValueKey(node.value.id), + note: node.value, + ), + if (node.hasChildren && node.children.length > 1) + Positioned( + left: 0.0, + bottom: 20, + child: GestureDetector( + onTap: onToggleExpand, + child: Container( + child: isExpanded + ? Icon( + Icons.remove_circle_outline, + color: Palette.gray, + size: 22.0, + ) + : Icon( + Icons.add_circle_outline, + color: Palette.gray, + size: 22.0, + ), + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/presentation_layer/components/edit_profile.dart b/lib/presentation_layer/components/edit_profile.dart new file mode 100644 index 00000000..54b815b2 --- /dev/null +++ b/lib/presentation_layer/components/edit_profile.dart @@ -0,0 +1,209 @@ +import 'dart:developer'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../config/palette.dart'; +import '../atoms/camer_upload.dart'; +import '../atoms/round_image_border.dart'; + +class EditProfile extends ConsumerStatefulWidget { + final String initialName; + final Function(String) onNameChanged; + final Uint8List? initialPicture; + final Function() pictureCallback; + final Uint8List? initialBanner; + final Function() bannerCallback; + final String initialAbout; + final Function(String) onAboutChanged; + final String initialNip05; + final Function(String) onNip05Changed; + final String initialWebsite; + final Function(String) onWebsiteChanged; + final String initialLud06; + final Function(String) onLud06Changed; + final String initialLud16; + final Function(String) onLud16Changed; + + const EditProfile({ + super.key, + required this.initialName, + required this.initialPicture, + required this.initialBanner, + required this.initialAbout, + required this.initialNip05, + required this.initialWebsite, + required this.initialLud06, + required this.initialLud16, + required this.onNameChanged, + required this.pictureCallback, + required this.bannerCallback, + required this.onAboutChanged, + required this.onNip05Changed, + required this.onWebsiteChanged, + required this.onLud06Changed, + required this.onLud16Changed, + }); + + @override + ConsumerState createState() => _EditProfileState(); +} + +class _EditProfileState extends ConsumerState { + late final Map _controllers; + + @override + void initState() { + super.initState(); + _controllers = { + 'name': TextEditingController(text: widget.initialName), + 'about': TextEditingController(text: widget.initialAbout), + 'nip05': TextEditingController(text: widget.initialNip05), + 'website': TextEditingController(text: widget.initialWebsite), + 'lud06': TextEditingController(text: widget.initialLud06), + 'lud16': TextEditingController(text: widget.initialLud16), + }; + + _controllers.forEach((key, controller) { + controller.addListener(() { + switch (key) { + case 'name': + widget.onNameChanged(controller.text); + break; + case 'about': + widget.onAboutChanged(controller.text); + break; + case 'nip05': + widget.onNip05Changed(controller.text); + break; + case 'website': + widget.onWebsiteChanged(controller.text); + break; + case 'lud06': + widget.onLud06Changed(controller.text); + break; + case 'lud16': + widget.onLud16Changed(controller.text); + break; + } + }); + }); + } + + @override + void dispose() { + for (var controller in _controllers.values) { + controller.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _buildHeader(), + _buildForm(), + ], + ); + } + + Widget _buildHeader() { + return SizedBox( + height: (MediaQuery.of(context).size.height / 6) + 60, + child: Stack( + children: [ + InkWell( + onTap: () { + widget.bannerCallback(); + }, + child: Container( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height / 6, + decoration: BoxDecoration( + color: Palette.darkGray, + image: widget.initialBanner != null + ? DecorationImage( + image: MemoryImage(widget.initialBanner!), + fit: BoxFit.cover, + ) + : null, + ), + ), + ), + Positioned( + bottom: 0, + left: MediaQuery.of(context).size.width / 8, + child: InkWell( + onTap: () { + widget.pictureCallback(); + }, + child: widget.initialPicture == null + ? const CameraUpload( + size: 100, + ) + : RoundImageWithBorder( + image: widget.initialPicture!, size: 102), + ), + ), + ], + ), + ); + } + + Widget _buildForm() { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + _buildInputField('Name', _controllers['name']!), + _buildInputField('Bio', _controllers['about']!, isMultiline: true), + _buildInputField('Website', _controllers['website']!), + _buildInputField('nip05', _controllers['nip05']!), + _buildInputField('lud06', _controllers['lud06']!), + _buildInputField('lud16', _controllers['lud16']!), + ], + ), + ); + } + + Widget _buildInputField(String label, TextEditingController controller, + {bool isMultiline = false}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 16.0, bottom: 8.0, left: 8.0), + child: Text( + label, + style: TextStyle( + color: const Color.fromARGB(213, 245, 248, 250), + fontSize: MediaQuery.of(context).size.width / 28, + ), + ), + ), + TextFormField( + controller: controller, + decoration: textEditInputDecoration, + maxLines: isMultiline + ? null + : 1, // Set to null for multiline, 1 for single line + keyboardType: + isMultiline ? TextInputType.multiline : TextInputType.text, + ), + ], + ); + } +} + +const textEditInputDecoration = InputDecoration( + hintText: "", + contentPadding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 8.0), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide( + width: 1, + color: Colors.grey, + ), + ), +); diff --git a/lib/components/edit_relays_view.dart b/lib/presentation_layer/components/edit_relays_view.dart similarity index 87% rename from lib/components/edit_relays_view.dart rename to lib/presentation_layer/components/edit_relays_view.dart index 659a0280..0664b688 100644 --- a/lib/components/edit_relays_view.dart +++ b/lib/presentation_layer/components/edit_relays_view.dart @@ -1,15 +1,15 @@ import 'dart:developer'; -import 'package:camelus/atoms/long_button.dart'; +import 'package:camelus/domain_layer/entities/relay.dart'; +import 'package:camelus/presentation_layer/atoms/long_button.dart'; import 'package:camelus/config/palette.dart'; -import 'package:camelus/providers/following_provider.dart'; -import 'package:camelus/services/nostr/relays/relay_address_parser.dart'; +import 'package:camelus/presentation_layer/providers/edit_relays_provider.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; class EditRelaysView extends ConsumerStatefulWidget { // async function with Map> as parameter - final Function(Map>) onSave; - const EditRelaysView({Key? key, required this.onSave}) : super(key: key); + final Function(List) onSave; + const EditRelaysView({super.key, required this.onSave}); @override ConsumerState createState() => _EditRelaysViewState(); @@ -22,7 +22,7 @@ class _EditRelaysViewState extends ConsumerState { bool touched = false; bool loading = false; - late Map> myRelays = {}; + late List myRelays; void _addRelay() { setState(() { @@ -40,10 +40,8 @@ class _EditRelaysViewState extends ConsumerState { log("added"); } - relayName = RelayAddressParser.parseAddress(relayName); - // check if relay already exists - if (myRelays.containsKey(relayName)) { + if (myRelays.any((element) => element.url == relayName)) { // show dialog showDialog( context: context, @@ -66,9 +64,7 @@ class _EditRelaysViewState extends ConsumerState { } setState(() { - myRelays[relayName] = - // ignore: unnecessary_cast - {"read": true, "write": true} as Map; + myRelays.add(Relay(url: relayName, read: false, write: false)); }); _relayNameController.clear(); @@ -88,17 +84,12 @@ class _EditRelaysViewState extends ConsumerState { } void _initSequence() async { - var followingPubkeys = ref.read(followingProvider); - await followingPubkeys.servicesReady; - var relays = followingPubkeys.ownRelays; - Map> relaysMap = {}; + var relaysProvider = ref.read(editRelaysProvider); + + var relays = await relaysProvider.getRelaysSelf(); - for (var relay in relays.entries) { - // cast to Map - relaysMap[relay.key] = relay.value.cast(); - } setState(() { - myRelays = Map.from(relaysMap); + myRelays = relays; }); return; } @@ -191,7 +182,7 @@ class _EditRelaysViewState extends ConsumerState { ), ), const SizedBox(height: 20), - for (var relay in myRelays.entries) + for (var relay in myRelays) Padding( padding: const EdgeInsets.only( left: 10, @@ -212,7 +203,7 @@ class _EditRelaysViewState extends ConsumerState { children: [ const SizedBox(width: 15), Text( - relay.key, + relay.url, style: const TextStyle(color: Colors.white), ), const Spacer(), @@ -223,11 +214,11 @@ class _EditRelaysViewState extends ConsumerState { Checkbox( activeColor: Palette.lightGray, checkColor: Palette.black, - value: relay.value["read"]!, + value: relay.read, onChanged: (value) { setState(() { touched = true; - relay.value["read"] = !relay.value["read"]!; + relay.read = !relay.read; }); }, @@ -246,12 +237,11 @@ class _EditRelaysViewState extends ConsumerState { Checkbox( activeColor: Palette.lightGray, checkColor: Palette.black, - value: relay.value["write"]!, + value: relay.write, onChanged: (value) { setState(() { touched = true; - relay.value["write"] = - !relay.value["write"]!; + relay.write = !relay.write; }); }, ), @@ -291,7 +281,7 @@ class _EditRelaysViewState extends ConsumerState { onPressed: () { setState(() { touched = true; - myRelays.remove(relay.key); + myRelays.remove(relay); }); Navigator.of(context).pop(); }, @@ -314,7 +304,8 @@ class _EditRelaysViewState extends ConsumerState { ), const SizedBox(height: 10), Padding( - padding: const EdgeInsets.only(left: 20, right: 20, bottom: 10), + padding: + const EdgeInsets.only(left: 20, right: 20, bottom: 10), child: longButton( name: "save", onPressed: _saveRelays, diff --git a/lib/presentation_layer/components/full_screen_loading.dart b/lib/presentation_layer/components/full_screen_loading.dart new file mode 100644 index 00000000..7046d7c4 --- /dev/null +++ b/lib/presentation_layer/components/full_screen_loading.dart @@ -0,0 +1,207 @@ +import 'dart:math'; +import 'dart:ui'; +import 'package:flutter/material.dart'; + +class FullScreenLoading extends StatefulWidget { + final List loadingTexts; + final int numberOfBlobs; + + final void Function(void Function()) updateState; + + const FullScreenLoading({ + Key? key, + required this.loadingTexts, + this.numberOfBlobs = 5, + required this.updateState, + }) : super(key: key); + + @override + _FullScreenLoadingState createState() => _FullScreenLoadingState(); +} + +class _FullScreenLoadingState extends State + with TickerProviderStateMixin { + late AnimationController _blobController; + late AnimationController _textController; + late Animation _textOpacity; + int _currentTextIndex = 0; + late List blobs; + + String? _successMessage; + bool _showSuccessMessage = false; + + void showSuccessMessage(String message) { + widget.updateState(() { + _successMessage = message; + _showSuccessMessage = true; + }); + } + + @override + void initState() { + super.initState(); + _blobController = AnimationController( + vsync: this, + duration: const Duration(minutes: 1), + )..repeat(reverse: true); + + _textController = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + ); + + _textOpacity = Tween(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _textController, + curve: Interval(0, 0.5, curve: Curves.easeIn), + reverseCurve: Interval(0.5, 1, curve: Curves.easeOut), + ), + ); + + _textController.addStatusListener((status) { + if (status == AnimationStatus.completed) { + _textController.reverse(); + } else if (status == AnimationStatus.dismissed) { + setState(() { + if (_showSuccessMessage && _successMessage != null) { + _currentTextIndex = + widget.loadingTexts.length; // Use this as a flag + } else { + _currentTextIndex = + (_currentTextIndex + 1) % widget.loadingTexts.length; + } + }); + _textController.forward(); + } + }); + + _textController.forward(); + + blobs = List.generate(widget.numberOfBlobs, (_) => Blob()); + } + + @override + void dispose() { + _blobController.dispose(); + _textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + AnimatedBuilder( + animation: _blobController, + builder: (context, child) { + return CustomPaint( + painter: BlobPainter(_blobController.value, blobs), + child: Container(), + ); + }, + ), + Center( + child: AnimatedBuilder( + animation: _textController, + builder: (context, child) { + return Opacity( + opacity: _textOpacity.value, + child: Text( + _showSuccessMessage && _successMessage != null + ? _successMessage! + : widget.loadingTexts[_currentTextIndex], + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + fontFamily: 'Poppins', + color: _showSuccessMessage ? Colors.green : Colors.white, + ), + ), + ); + }, + ), + ) + ], + ), + ); + } +} + +class BlobPainter extends CustomPainter { + final double animationValue; + final List blobs; + + BlobPainter(this.animationValue, this.blobs); + + @override + void paint(Canvas canvas, Size size) { + for (var blob in blobs) { + blob.update(animationValue, size); + _drawBlob(canvas, blob); + } + } + + void _drawBlob(Canvas canvas, Blob blob) { + final paint = Paint() + ..shader = RadialGradient( + colors: [ + blob.color.withOpacity(0.85), + blob.color.withOpacity(0.0), + ], + stops: const [0.0, 1.0], + ).createShader( + Rect.fromCircle(center: blob.position, radius: blob.radius)) + ..blendMode = BlendMode.xor; + + canvas.drawCircle(blob.position, blob.radius, paint); + } + + @override + bool shouldRepaint(covariant BlobPainter oldDelegate) => true; +} + +class Blob { + late Offset position; + late double radius; + late Color color; + late Offset startPosition; + late Offset endPosition; + late double startRadius; + late double endRadius; + + final random = Random(); + + Blob() { + _initializeProperties(); + } + + void _initializeProperties() { + startPosition = Offset(random.nextDouble(), random.nextDouble()); + endPosition = Offset(random.nextDouble(), random.nextDouble()); + position = startPosition; + startRadius = 50 + random.nextDouble() * 400; + endRadius = 60 + random.nextDouble() * 400; + radius = startRadius; + color = Color.fromRGBO( + random.nextInt(100) + 100, + random.nextInt(100) + 100, + 255, + random.nextDouble(), + ); + //color = Palette.white; + } + + void update(double animationValue, Size size) { + const speed = 4; + position = Offset( + lerpDouble(startPosition.dx, endPosition.dx, animationValue * speed)! * + size.width, + lerpDouble(startPosition.dy, endPosition.dy, animationValue * speed)! * + size.height, + ); + + radius = lerpDouble(startRadius, endRadius, animationValue)!; + } +} diff --git a/lib/components/images_gallery.dart b/lib/presentation_layer/components/images_gallery.dart similarity index 99% rename from lib/components/images_gallery.dart rename to lib/presentation_layer/components/images_gallery.dart index 5d33ee09..9450399e 100644 --- a/lib/components/images_gallery.dart +++ b/lib/presentation_layer/components/images_gallery.dart @@ -11,13 +11,13 @@ class ImageGallery extends StatefulWidget { final Widget? bottomBarWidget; const ImageGallery({ - Key? key, + super.key, required this.imageUrls, required this.defaultImageIndex, required this.topBarTitle, this.bottomBarWidget, this.heroTag, - }) : super(key: key); + }); @override _ImageGalleryState createState() => _ImageGalleryState(); diff --git a/lib/components/images_tile_view.dart b/lib/presentation_layer/components/images_tile_view.dart similarity index 92% rename from lib/components/images_tile_view.dart rename to lib/presentation_layer/components/images_tile_view.dart index 61247eca..098086e9 100644 --- a/lib/components/images_tile_view.dart +++ b/lib/presentation_layer/components/images_tile_view.dart @@ -1,4 +1,4 @@ -import 'package:camelus/components/images_gallery.dart'; +import 'package:camelus/presentation_layer/components/images_gallery.dart'; import 'package:camelus/config/palette.dart'; import 'package:camelus/helpers/helpers.dart'; import 'package:flutter/material.dart'; @@ -6,8 +6,7 @@ import 'package:flutter/material.dart'; class ImagesTileView extends StatelessWidget { final List images; final Widget? galleryBottomWidget; - ImagesTileView({Key? key, required this.images, this.galleryBottomWidget}) - : super(key: key); + ImagesTileView({super.key, required this.images, this.galleryBottomWidget}); final String _tileViewId = Helpers().getRandomString(4); @@ -54,7 +53,7 @@ class ImagesTileView extends StatelessWidget { transitionOnUserGestures: true, tag: 'image-${images[index]}-$_tileViewId', child: Image.network( - cacheHeight: 500, + cacheHeight: 850, images[index], fit: BoxFit.cover, alignment: Alignment.center, @@ -81,7 +80,8 @@ class ImagesTileView extends StatelessWidget { child: Center( child: Text( '+$additionalImages', - style: const TextStyle(color: Colors.white, fontSize: 38), + style: const TextStyle( + color: Colors.white, fontSize: 38), ), ), ), diff --git a/lib/components/note_card/bottom_action_row.dart b/lib/presentation_layer/components/note_card/bottom_action_row.dart similarity index 99% rename from lib/components/note_card/bottom_action_row.dart rename to lib/presentation_layer/components/note_card/bottom_action_row.dart index 781412d7..3c9ac13f 100644 --- a/lib/components/note_card/bottom_action_row.dart +++ b/lib/presentation_layer/components/note_card/bottom_action_row.dart @@ -21,7 +21,7 @@ class BottomActionRow extends StatelessWidget { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - InkWell( + GestureDetector( onTap: () => onComment(), child: Container( padding: diff --git a/lib/components/note_card/bottom_sheet_more.dart b/lib/presentation_layer/components/note_card/bottom_sheet_more.dart similarity index 57% rename from lib/components/note_card/bottom_sheet_more.dart rename to lib/presentation_layer/components/note_card/bottom_sheet_more.dart index c3aa9772..60d3a1c6 100644 --- a/lib/components/note_card/bottom_sheet_more.dart +++ b/lib/presentation_layer/components/note_card/bottom_sheet_more.dart @@ -1,8 +1,7 @@ import 'dart:ui'; - import 'package:camelus/config/palette.dart'; -import 'package:camelus/models/nostr_note.dart'; -import 'package:camelus/routes/nostr/blockedUsers/block_page.dart'; +import 'package:camelus/domain_layer/entities/nostr_note.dart'; +import 'package:camelus/presentation_layer/routes/nostr/blockedUsers/block_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; @@ -23,41 +22,6 @@ void openBottomSheetMore(context, NostrNote note) { child: Column( mainAxisSize: MainAxisSize.min, children: [ - GestureDetector( - onTap: () { - // push Seen on relays - - //Navigator.push( - // context, - // MaterialPageRoute( - // builder: (context) => - // SeenOnRelaysPage(note: note), - // ), - //); - //! todo: push seen on relays - }, - child: Container( - color: Palette.background, - padding: const EdgeInsets.all(5), - child: Row( - children: [ - // svg icon - SvgPicture.asset( - height: 30, - width: 30, - 'assets/icons/target.svg', - color: Palette.gray, - ), - const SizedBox(width: 15), - const Text( - "seen on relays", - style: TextStyle( - color: Palette.lightGray, fontSize: 17), - ), - ], - ), - ), - ), const SizedBox(height: 20), GestureDetector( onTap: () { @@ -83,7 +47,7 @@ void openBottomSheetMore(context, NostrNote note) { ), const SizedBox(width: 15), const Text( - "mute/block", + "block/report", style: TextStyle( color: Palette.lightGray, fontSize: 17), ), diff --git a/lib/presentation_layer/components/note_card/in_reply_to.dart b/lib/presentation_layer/components/note_card/in_reply_to.dart new file mode 100644 index 00000000..89df0077 --- /dev/null +++ b/lib/presentation_layer/components/note_card/in_reply_to.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../config/palette.dart'; +import '../../../domain_layer/entities/nostr_note.dart'; +import '../../../helpers/helpers.dart'; +import '../../providers/metadata_provider.dart'; + +class InReplyTo extends ConsumerStatefulWidget { + const InReplyTo({ + super.key, + required this.myNote, + }); + + final NostrNote myNote; + + @override + ConsumerState createState() => _InReplyToState(); +} + +class _InReplyToState extends ConsumerState { + String valueFirst = ""; + String pubkeyFirst = ""; + + String valueSecond = ""; + String pubkeySecond = ""; + + int othersCount = 0; + + void populateValues() { + final note = widget.myNote; + final notePubkeys = note.getTagPubkeys; + + // populate + for (var i = 0; i < notePubkeys.length; i++) { + var tag = notePubkeys[i]; + + if (i == 0) { + valueFirst = _formatPubkey(tag.value); + pubkeyFirst = tag.value; + } else if (i == 1) { + valueSecond = _formatPubkey(tag.value); + pubkeySecond = tag.value; + } else { + othersCount++; + } + } + + setState(() { + valueFirst = valueFirst; + valueSecond = valueSecond; + othersCount = othersCount; + }); + } + + void resolveMetadata() async { + final metadata = ref.read(metadataProvider); + + if (pubkeyFirst.isEmpty) return; + final firstFuture = metadata.getMetadataByPubkey(pubkeyFirst).toList(); + firstFuture.then((value) => { + if (value.isNotEmpty) {valueFirst = value[0].name ?? valueFirst}, + if (mounted) + { + setState(() { + valueFirst = valueFirst; + }) + } + }); + + if (pubkeySecond.isEmpty) return; + final secondFuture = metadata.getMetadataByPubkey(pubkeySecond).toList(); + secondFuture.then((value) => { + if (value.isNotEmpty) {valueSecond = value[0].name ?? valueSecond}, + if (mounted) + { + setState(() { + valueSecond = valueSecond; + }) + } + }); + } + + String _formatPubkey(String pubkey) { + final pubkeyBech = Helpers().encodeBech32(pubkey, "npub"); + final pubkeyHr = + "${pubkeyBech.substring(0, 4)}:${pubkeyBech.substring(pubkeyBech.length - 5)}"; + return pubkeyHr; + } + + @override + void initState() { + super.initState(); + populateValues(); + resolveMetadata(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (valueFirst.isEmpty) { + return const SizedBox(); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "reply to ", + style: TextStyle(fontSize: 16, color: Palette.gray), + ), + GestureDetector( + onTap: () { + Navigator.pushNamed(context, "/nostr/profile", + arguments: pubkeyFirst); + }, + child: Text('@$valueFirst ', + style: const TextStyle( + color: Palette.primary, fontSize: 16, height: 1.3)), + ), + if (valueSecond.isNotEmpty) + GestureDetector( + onTap: () { + Navigator.pushNamed(context, "/nostr/profile", + arguments: pubkeySecond); + }, + child: Text('@$valueSecond ', + style: const TextStyle( + color: Palette.primary, fontSize: 16, height: 1.3)), + ), + if (othersCount != 0) + Text(' and $othersCount more', + style: const TextStyle( + color: Palette.darkGray, fontSize: 16, height: 1.3)) + ], + ); + } +} diff --git a/lib/components/note_card/name_row.dart b/lib/presentation_layer/components/note_card/name_row.dart similarity index 53% rename from lib/components/note_card/name_row.dart rename to lib/presentation_layer/components/note_card/name_row.dart index 206d9c56..cb3aa3d1 100644 --- a/lib/components/note_card/name_row.dart +++ b/lib/presentation_layer/components/note_card/name_row.dart @@ -1,15 +1,14 @@ -import 'dart:developer'; - import 'package:camelus/config/palette.dart'; +import 'package:camelus/domain_layer/entities/user_metadata.dart'; import 'package:camelus/helpers/helpers.dart'; -import 'package:camelus/providers/nip05_provider.dart'; +import 'package:camelus/presentation_layer/providers/nip05_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:timeago/timeago.dart' as timeago; class NoteCardNameRow extends ConsumerStatefulWidget { - final Future> myMetadata; + final UserMetadata? myMetadata; final String pubkey; final int created_at; final Function openMore; @@ -33,12 +32,12 @@ class _NoteCardNameRowState extends ConsumerState { if (nip05.isEmpty) return; if (nip05verified.isNotEmpty) return; try { - var nip05Service = ref.watch(nip05provider); - var check = await nip05Service.checkNip05(nip05, pubkey); + var nip05Service = await ref.watch(nip05provider.future); + var check = await nip05Service.check(nip05, pubkey); - if (check["valid"] == true) { + if (check != null && check.valid) { setState(() { - nip05verified = check["nip05"]; + nip05verified = check.nip05; }); } // ignore: empty_catches @@ -46,9 +45,6 @@ class _NoteCardNameRowState extends ConsumerState { } void _initSqeuence() async { - var metadata = await widget.myMetadata; - _checkNip05(metadata["nip05"] ?? "", widget.pubkey); - var npubHr = Helpers().encodeBech32(widget.pubkey, "npub"); npubHrShort = "${npubHr.substring(0, 4)}...${npubHr.substring(npubHr.length - 4)}"; @@ -66,49 +62,33 @@ class _NoteCardNameRowState extends ConsumerState { //mainAxisAlignment: MainAxisAlignment.end, //crossAxisAlignment: CrossAxisAlignment.center, children: [ - FutureBuilder( - future: widget.myMetadata, - builder: (BuildContext context, AsyncSnapshot snapshot) { - var name = ""; - - if (snapshot.hasData) { - name = snapshot.data?["name"] ?? npubHrShort; - } else if (snapshot.hasError) { - name = "error"; - } else { - // loading - name = "loading"; - } - - return Row( - children: [ - Container( - constraints: - const BoxConstraints(minWidth: 5, maxWidth: 150), - child: RichText( - maxLines: 2, - overflow: TextOverflow.ellipsis, - text: TextSpan( - text: name, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 17), - ), - ), - ), - if (nip05verified.isNotEmpty) - Container( - margin: const EdgeInsets.only(top: 0, left: 5), - child: const Icon( - Icons.verified, - color: Palette.white, - size: 15, - ), - ), - ], - ); - }), + Row( + children: [ + Container( + constraints: const BoxConstraints(minWidth: 5, maxWidth: 150), + child: RichText( + maxLines: 2, + overflow: TextOverflow.ellipsis, + text: TextSpan( + text: widget.myMetadata?.name, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 17), + ), + ), + ), + if (nip05verified.isNotEmpty) + Container( + margin: const EdgeInsets.only(top: 0, left: 5), + child: const Icon( + Icons.verified, + color: Palette.white, + size: 15, + ), + ), + ], + ), const SizedBox(width: 10), Container( height: 3, diff --git a/lib/presentation_layer/components/note_card/note_card.dart b/lib/presentation_layer/components/note_card/note_card.dart new file mode 100644 index 00000000..12aeeacb --- /dev/null +++ b/lib/presentation_layer/components/note_card/note_card.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; + +import '../../../config/palette.dart'; +import '../../../domain_layer/entities/nostr_note.dart'; +import '../../../domain_layer/entities/user_metadata.dart'; +import '../../atoms/my_profile_picture.dart'; +import '../bottom_sheet_share.dart'; +import 'bottom_action_row.dart'; +import 'bottom_sheet_more.dart'; +import 'name_row.dart'; +import 'note_card_build_split_content.dart'; + +class NoteCard extends StatelessWidget { + final NostrNote note; + final UserMetadata? myMetadata; + final bool hideBottomBar; + + const NoteCard({ + super.key, + required this.note, + required this.myMetadata, + this.hideBottomBar = false, + }); + + @override + Widget build(BuildContext context) { + if (note.pubkey == 'missing') { + return _buildMissingNote(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (note.sig_valid != true) _buildInvalidSignature(), + Padding( + padding: const EdgeInsets.fromLTRB(10, 10, 10, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildUserImage(context), + const SizedBox(width: 10), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildNameRow(context), + const SizedBox(height: 10), + _buildNoteContent(context), + if (!hideBottomBar) ...[ + const SizedBox(height: 10), + _buildBottomActionRow(context), + ], + ], + ), + ), + ], + ), + ), + if (!hideBottomBar) + const Divider( + thickness: 0.3, + color: Palette.darkGray, + ), + ], + ); + } + + Widget _buildMissingNote() { + return SizedBox( + height: 50, + child: Center( + child: Text( + "Missing note: ${note.getDirectReply?.recommended_relay}, ${note.getRootReply?.recommended_relay}", + style: const TextStyle(color: Colors.purple, fontSize: 20), + ), + ), + ); + } + + Widget _buildInvalidSignature() { + return Center( + child: Container( + decoration: const BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.all(Radius.circular(25)), + ), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 25, vertical: 8), + child: Text("Invalid signature!", style: TextStyle(fontSize: 15)), + ), + ), + ); + } + + Widget _buildUserImage(BuildContext context) { + return GestureDetector( + onTap: () => Navigator.pushNamed(context, "/nostr/profile", + arguments: note.pubkey), + child: UserImage( + imageUrl: myMetadata?.picture, + pubkey: note.pubkey, + ), + ); + } + + Widget _buildNameRow(BuildContext context) { + return NoteCardNameRow( + created_at: note.created_at, + myMetadata: myMetadata, + pubkey: note.pubkey, + openMore: () => openBottomSheetMore(context, note), + ); + } + + Widget _buildNoteContent(BuildContext context) { + return NoteCardSplitContent( + note: note, + profileCallback: (String pubkey) => + Navigator.pushNamed(context, "/nostr/profile", arguments: pubkey), + hashtagCallback: (String hashtag) => + Navigator.pushNamed(context, "/nostr/hastag", arguments: hashtag), + ); + } + + Widget _buildBottomActionRow(BuildContext context) { + return BottomActionRow( + key: ValueKey("${note.id}bottom_action_row"), + onComment: () { + //_writeReply(context, note) + print("comment"); + }, + onLike: () {}, + onRetweet: () {}, + onShare: () => openBottomSheetShare(context, note), + ); + } +} diff --git a/lib/presentation_layer/components/note_card/note_card_build_split_content.dart b/lib/presentation_layer/components/note_card/note_card_build_split_content.dart new file mode 100644 index 00000000..29a8781d --- /dev/null +++ b/lib/presentation_layer/components/note_card/note_card_build_split_content.dart @@ -0,0 +1,364 @@ +import 'package:camelus/domain_layer/entities/user_metadata.dart'; +import 'package:camelus/domain_layer/usecases/get_user_metadata.dart'; +import 'package:camelus/presentation_layer/components/images_tile_view.dart'; +import 'package:camelus/presentation_layer/components/note_card/note_card_reference.dart'; +import 'package:camelus/config/palette.dart'; +import 'package:camelus/helpers/helpers.dart'; +import 'package:camelus/helpers/nprofile_helper.dart'; +import 'package:camelus/domain_layer/entities/nostr_note.dart'; +import 'package:camelus/presentation_layer/providers/metadata_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +final profilePattern = RegExp(r"nostr:(nprofile|npub)[a-zA-Z0-9]+"); +final notePattern = RegExp(r"nostr:(note|nevent)[a-zA-Z0-9]+"); + +/// this class is responsible for building the content of a note +class NoteCardSplitContent extends ConsumerStatefulWidget { + final NostrNote note; + final Function(String) profileCallback; + final Function(String) hashtagCallback; + + const NoteCardSplitContent({ + super.key, + required this.note, + required this.hashtagCallback, + required this.profileCallback, + }); + + @override + ConsumerState createState() => + _NoteCardSplitContentState(); +} + +class _NoteCardSplitContentState extends ConsumerState { + final Map _tagsMetadata = {}; + + late final GetUserMetadata _metadataProvider; + + List imageLinks = []; + + _NoteCardSplitContentState(); + + List body = []; + + @override + void initState() { + super.initState(); + _metadataProvider = ref.read(metadataProvider); + + imageLinks = _extractImages(widget.note); + body = _buildContent(widget.note.content); + } + + @override + void dispose() { + super.dispose(); + } + + @override + void didUpdateWidget(NoteCardSplitContent oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.note.id != widget.note.id) { + imageLinks = _extractImages(widget.note); + body = _buildContent(widget.note.content); + } + } + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 0, + runSpacing: 4, + direction: Axis.horizontal, + verticalDirection: VerticalDirection.down, + children: body, + ); + } + + List _buildContent(String content) { + List widgets = []; + List lines = content.split("\n"); + for (var line in lines) { + if (line == "") { + //widgets.add(_buildText("\n")); + widgets.add(const SizedBox(height: 7, width: 1000)); + continue; + } + List words = line.split(" "); + for (var word in words) { + if (profilePattern.hasMatch(word)) { + widgets.add(ProfileLink( + metadataProvider: _metadataProvider, + profileCallback: widget.profileCallback, + word: word, + )); + } else if (notePattern.hasMatch(word)) { + widgets.add(NoteCardReference(word: word)); + } else if (word.startsWith("#[")) { + widgets.add(LegacyMentionHashtag( + note: widget.note, + tagsMetadata: _tagsMetadata, + metadataProvider: _metadataProvider, + profileCallback: widget.profileCallback, + word: word)); + } else if (word.startsWith("#")) { + widgets.add( + HashtagLink(hashtagCallback: widget.hashtagCallback, word: word)); + } else if (word.startsWith("http")) { + widgets.add(HttpLink(imageLinks: imageLinks, word: word)); + } else { + widgets.add(DisplayText(word: word)); + } + widgets.add(const DisplayText(word: " ")); + } + widgets.removeLast(); // remove last space + //widgets.add(_buildText("\n")); // add back the original line break + widgets.add(const SizedBox( + height: 7, + )); + } + + if (imageLinks.isNotEmpty) { + widgets.add( + ImagesTileView( + images: imageLinks, + //galleryBottomWidget: splitContent.content, + ), + ); + } + + return widgets; + } + + List _extractImages(NostrNote note) { + List imageLinks = []; + RegExp exp = RegExp(r"(https?:\/\/[^\s]+)"); + Iterable matches = exp.allMatches(note.content); + for (var match in matches) { + var link = match.group(0); + if (link!.endsWith(".jpg") || + link.endsWith(".jpeg") || + link.endsWith(".png") || + link.endsWith(".webp") || + link.contains(".avif") || + link.endsWith(".gif")) { + imageLinks.add(link); + } + } + + return imageLinks; + } +} + +class DisplayText extends StatelessWidget { + const DisplayText({ + super.key, + required this.word, + }); + + final String word; + + @override + Widget build(BuildContext context) { + return Text( + word, + style: const TextStyle(color: Palette.lightGray, fontSize: 17), + ); + } +} + +class HttpLink extends StatelessWidget { + const HttpLink({ + super.key, + required this.imageLinks, + required this.word, + }); + + final List imageLinks; + final String word; + + @override + Widget build(BuildContext context) { + if (imageLinks.contains(word)) { + return const SizedBox(height: 0, width: 0); + } + + return GestureDetector( + onTap: () { + launchUrlString(word, mode: LaunchMode.externalApplication); + }, + child: Text( + word, + style: const TextStyle(color: Palette.primary, fontSize: 17), + ), + ); + } +} + +class HashtagLink extends StatelessWidget { + const HashtagLink({ + super.key, + required Function(String p1) hashtagCallback, + required this.word, + }) : _hashtagCallback = hashtagCallback; + + final Function(String p1) _hashtagCallback; + final String word; + + @override + Widget build(BuildContext context) { + String hashtag = word.substring(1); + + return GestureDetector( + onTap: () { + _hashtagCallback(hashtag); + }, + child: Text( + word, + style: const TextStyle(color: Palette.primary, fontSize: 17), + ), + ); + } +} + +class LegacyMentionHashtag extends StatelessWidget { + const LegacyMentionHashtag({ + super.key, + required NostrNote note, + required Map tagsMetadata, + required GetUserMetadata metadataProvider, + required Function(String p1) profileCallback, + required this.word, + }) : _note = note, + _tagsMetadata = tagsMetadata, + _metadataProvider = metadataProvider, + _profileCallback = profileCallback; + + final NostrNote _note; + final Map _tagsMetadata; + final GetUserMetadata _metadataProvider; + final Function(String p1) _profileCallback; + final String word; + + @override + Widget build(BuildContext context) { + var indexString = word.replaceAll("#[", "").replaceAll("]", ""); + int index; + try { + index = int.parse(indexString); + } catch (e) { + return const SizedBox(height: 0, width: 0); + } + + var tag = _note.tags[index]; + + if (tag.type != 'p') { + return const SizedBox(height: 0, width: 0); + } + + var pubkeyBech = Helpers().encodeBech32(tag.value, "npub"); + // first 5 chars then ... then last 5 chars + var pubkeyHr = + "${pubkeyBech.substring(0, 5)}...${pubkeyBech.substring(pubkeyBech.length - 5)}"; + _tagsMetadata[tag.value] = pubkeyHr; + var metadata = + _metadataProvider.getMetadataByPubkey(tag.value).first.timeout( + const Duration(seconds: 2), + ); + + return GestureDetector( + onTap: () { + _profileCallback(tag.value); + }, + child: FutureBuilder( + future: metadata, + builder: (context, snapshot) { + if (snapshot.hasData) { + return Text( + "@${snapshot.data!.name ?? pubkeyHr}", + style: const TextStyle(color: Palette.primary, fontSize: 17), + ); + } + return Text( + "@$pubkeyHr", + style: const TextStyle(color: Palette.primary, fontSize: 17), + ); + }), + ); + } +} + +class ProfileLink extends StatelessWidget { + const ProfileLink({ + super.key, + required GetUserMetadata metadataProvider, + required Function(String p1) profileCallback, + required this.word, + }) : _metadataProvider = metadataProvider, + _profileCallback = profileCallback; + + final GetUserMetadata _metadataProvider; + final Function(String p1) _profileCallback; + final String word; + + @override + Widget build(BuildContext context) { + var group = profilePattern.allMatches(word); + var match = group.first; + var cleaned = match.group(0); + + if (cleaned == "") return const SizedBox(height: 0, width: 0); + + final myMatch = cleaned!.replaceAll("nostr:", ""); + String myPubkeyHex = ""; + String pubkeyBech = ""; + + if (myMatch.contains("nprofile")) { + // remove the "nostr:" part + + Map nProfileDecode = + NprofileHelper().bech32toMap(myMatch); + + final List myRelays = nProfileDecode['relays']; + myPubkeyHex = nProfileDecode['pubkey']; + pubkeyBech = Helpers().encodeBech32(myPubkeyHex, "npub"); + } + + if (myMatch.contains("npub")) { + pubkeyBech = myMatch; + final List decode = Helpers().decodeBech32(myMatch); + + myPubkeyHex = decode[0]; + } + + final String pubkeyHr = + "${pubkeyBech.substring(0, 5)}:${pubkeyBech.substring(pubkeyBech.length - 5)}"; + + var metadata = + _metadataProvider.getMetadataByPubkey(myPubkeyHex).first.timeout( + const Duration(seconds: 2), + ); + + return GestureDetector( + onTap: () { + _profileCallback(myPubkeyHex); + }, + child: FutureBuilder( + future: metadata, + builder: (context, metadataSnp) { + if (metadataSnp.hasData) { + return Text( + "@${metadataSnp.data!.name ?? pubkeyHr}", + style: const TextStyle(color: Palette.primary, fontSize: 17), + ); + } + return Text( + "@$pubkeyHr", + style: const TextStyle(color: Palette.primary, fontSize: 17), + ); + }), + ); + } +} diff --git a/lib/presentation_layer/components/note_card/note_card_container.dart b/lib/presentation_layer/components/note_card/note_card_container.dart new file mode 100644 index 00000000..a25e0235 --- /dev/null +++ b/lib/presentation_layer/components/note_card/note_card_container.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../config/palette.dart'; +import '../../../domain_layer/entities/nostr_note.dart'; +import '../../../domain_layer/entities/nostr_tag.dart'; +import '../../../domain_layer/entities/user_metadata.dart'; +import '../../providers/metadata_provider.dart'; +import 'in_reply_to.dart'; +import 'note_card.dart'; + +/// this is a container for the note cards +/// its purpose is to wrap a note with additional information, like reply to + +class NoteCardContainer extends ConsumerStatefulWidget { + final NostrNote note; + + const NoteCardContainer({super.key, required this.note}); + + @override + ConsumerState createState() => _NoteCardContainerState(); +} + +class _NoteCardContainerState extends ConsumerState { + UserMetadata? myUserNoteMetadata; + + void _onNoteTab(BuildContext context, NostrNote myNote) { + var refEvents = myNote.getTagEvents; + + if (myNote.isRoot) { + _navigateToEventViewPage(context, myNote.id, null); + return; + } + + NostrTag? root = myNote.getRootReply; + NostrTag? reply = myNote.getDirectReply; + + // off spec support, sometimes not marked as root + root ??= refEvents.first; + + _navigateToEventViewPage(context, root.value, reply?.value ?? myNote.id); + } + + void _navigateToEventViewPage( + BuildContext context, + String root, + String? scrollIntoView, + ) { + Navigator.pushNamed(context, "/nostr/event", arguments: { + "root": root, + "scrollIntoView": scrollIntoView + }); + } + + Future _getMetadata(String pubkey) async { + final mProvider = ref.read(metadataProvider); + + final myMetadata = await mProvider.getMetadataByPubkey(pubkey).toList(); + + if (myMetadata.isEmpty) { + return null; + } + + return myMetadata[0]; + } + + @override + void initState() { + super.initState(); + _getMetadata(widget.note.pubkey).then((data) { + if (mounted) { + setState(() { + myUserNoteMetadata = data; + }); + } + }); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final note = widget.note; + return GestureDetector( + onTap: () { + _onNoteTab(context, note); + }, + child: Container( + color: Colors + .transparent, // needed for comment lines and tab still working + child: Column( + children: [ + // check if reply + if (note.getTagEvents.isNotEmpty) + // for myNote.getTagPubkeys + Padding( + padding: const EdgeInsets.only(left: 15.0), + child: InReplyTo( + key: ValueKey('in-reply-to-${note.id}'), + myNote: note, + ), + ), + + NoteCard( + note: note, + myMetadata: myUserNoteMetadata, + key: ValueKey('note-${note.id}'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation_layer/components/note_card/note_card_reference.dart b/lib/presentation_layer/components/note_card/note_card_reference.dart new file mode 100644 index 00000000..57182e95 --- /dev/null +++ b/lib/presentation_layer/components/note_card/note_card_reference.dart @@ -0,0 +1,143 @@ +import 'package:camelus/presentation_layer/components/note_card/note_card.dart'; +import 'package:camelus/config/palette.dart'; +import 'package:camelus/helpers/helpers.dart'; +import 'package:camelus/helpers/nevent_helper.dart'; +import 'package:camelus/domain_layer/entities/nostr_note.dart'; +import 'package:camelus/presentation_layer/providers/get_notes_provider.dart'; +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class NoteCardReference extends ConsumerStatefulWidget { + final String word; + + const NoteCardReference({ + super.key, + required this.word, + }); + + @override + ConsumerState createState() => _NoteCardReferenceState(); +} + +class _NoteCardReferenceState extends ConsumerState { + late Widget body = Container(); + + late final String? nostrId; + + _fetchNoteIfUnavailable() async { + final notesProvider = ref.read(getNotesProvider); + final result = notesProvider.getNote(nostrId!); + if (result != null) { + // note exits in db + return; + } + } + + Widget? _buildBody() { + //return Text("OKKKKK", style: const TextStyle(color: Palette.primary)); + + if (nostrId == null) { + return null; + } + + return _myBody(nostrId!); + } + + String? _getNostrId(String word) { + final cleanedWord = widget.word.replaceAll("nostr:", ""); + + if (cleanedWord.startsWith("note")) { + final String res; + try { + res = Helpers().decodeBech32(cleanedWord)[0]; + } catch (e) { + return null; + } + return res; + } + if (cleanedWord.startsWith("nevent")) { + final String res; + try { + final map = NeventHelper().bech32ToMap(cleanedWord); + res = map["eventId"]; + } catch (e) { + return null; + } + return res; + } + return null; + } + + @override + void initState() { + super.initState(); + + nostrId = _getNostrId(widget.word); + + _fetchNoteIfUnavailable(); + body = _buildBody() ?? Container(); + } + + @override + void didUpdateWidget(NoteCardReference oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.word != widget.word) { + body = _buildBody() ?? Container(); + } + } + + @override + Widget build(BuildContext context) { + return body; + } + + Column _myBody(String nostrId) { + final notesProvider = ref.read(getNotesProvider); + final noteStream = notesProvider.getNote(nostrId); + return Column( + children: [ + const SizedBox(height: 20), + StreamBuilder( + stream: noteStream, + builder: (context, snapshotStream) { + if (snapshotStream.hasData) { + final NostrNote note = snapshotStream.data!; + final Key key = UniqueKey(); + return GestureDetector( + onTap: () { + Navigator.pushNamed(context, "/nostr/event", arguments: { + "root": note.id, + "scrollIntoView": note.id, + }); + }, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Palette.darkGray, width: 1.0), + ), + child: NoteCard( + note: note, + myMetadata: null, + key: key, + hideBottomBar: true, + ))); + } + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Palette.darkGray, width: 1.0), + ), + child: const Center( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 0, vertical: 20), + child: Text("note not found", + style: TextStyle(color: Palette.white, fontSize: 17)), + ), + ), + ); + }, + ), + ], + ); + } +} diff --git a/lib/presentation_layer/components/note_card/sceleton_note.dart b/lib/presentation_layer/components/note_card/sceleton_note.dart new file mode 100644 index 00000000..5f44ba0b --- /dev/null +++ b/lib/presentation_layer/components/note_card/sceleton_note.dart @@ -0,0 +1,118 @@ +import 'package:camelus/presentation_layer/components/note_card/bottom_action_row.dart'; +import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; + +import '../../../config/palette.dart'; + +class SkeletonNote extends StatelessWidget { + final Function? renderCallback; + + const SkeletonNote({super.key, this.renderCallback}); + + @override + Widget build(BuildContext context) { + WidgetsBinding.instance.addPostFrameCallback((_) { + // This code will run after the widget has been rendered + if (renderCallback != null) { + renderCallback!(); + } + }); + + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(10, 10, 10, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // profile picture + Shimmer.fromColors( + baseColor: Palette.extraDarkGray, + highlightColor: Palette.darkGray, + child: Container( + height: 60, + width: 60, + decoration: const BoxDecoration( + color: Palette.primary, + shape: BoxShape.circle, + ), + child: const Icon(Icons.person), + ), + ), + Container( + width: MediaQuery.of(context).size.width - 95, + margin: const EdgeInsets.only(left: 5, right: 10), + color: Palette.background, + child: Padding( + padding: const EdgeInsets.only(left: 10.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // username + Shimmer.fromColors( + baseColor: Palette.extraDarkGray, + highlightColor: Palette.darkGray, + child: Container( + height: 18, + width: 120, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + const SizedBox(height: 10), + + Shimmer.fromColors( + baseColor: Palette.extraDarkGray, + highlightColor: Palette.darkGray, + child: Container( + height: 12, + width: MediaQuery.of(context).size.width / 2.5, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 10), + Shimmer.fromColors( + baseColor: Palette.extraDarkGray, + highlightColor: Palette.darkGray, + child: Container( + height: 12, + width: 50, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + const SizedBox(height: 6), + + Padding( + padding: const EdgeInsets.only(top: 10.0), + child: BottomActionRow( + onComment: () {}, + onLike: () {}, + onRetweet: () {}, + onShare: () {}, + ), + ), + const SizedBox(height: 20), + // show text if replies > 0 + ], + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/atoms/person_card.dart b/lib/presentation_layer/components/person_card.dart similarity index 89% rename from lib/atoms/person_card.dart rename to lib/presentation_layer/components/person_card.dart index 4848de71..e550921b 100644 --- a/lib/atoms/person_card.dart +++ b/lib/presentation_layer/components/person_card.dart @@ -1,8 +1,8 @@ - -import 'package:camelus/atoms/follow_button.dart'; -import 'package:camelus/atoms/my_profile_picture.dart'; +import 'package:camelus/presentation_layer/atoms/follow_button.dart'; import 'package:camelus/config/palette.dart'; -import 'package:camelus/providers/nostr_service_provider.dart'; +import 'package:camelus/presentation_layer/atoms/my_profile_picture.dart'; +import 'package:camelus/presentation_layer/providers/nip05_provider.dart'; + import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -17,7 +17,7 @@ class PersonCard extends ConsumerWidget { final Function(bool) onFollowTab; const PersonCard({ - Key? key, + super.key, required this.pubkey, required this.name, required this.pictureUrl, @@ -26,15 +26,15 @@ class PersonCard extends ConsumerWidget { required this.onTap, required this.onFollowTab, this.nip05, - }) : super(key: key); + }); Future checkNip05(String nip05, String pubkey, WidgetRef ref) async { - var nostrService = ref.watch(nostrServiceProvider); + var nip05service = await ref.read(nip05provider.future); try { - var check = await nostrService.checkNip05(nip05, pubkey); + var check = await nip05service.check(nip05, pubkey); - if (check["valid"] == true) { - return check["nip05"]; + if (check != null && check.valid) { + return check.nip05; } // ignore: empty_catches } catch (e) {} @@ -52,11 +52,8 @@ class PersonCard extends ConsumerWidget { child: Row( // profile children: [ - myProfilePicture( - pictureUrl: pictureUrl, - pubkey: pubkey, - filterQuality: FilterQuality.medium, - disableGif: true), + UserImage(imageUrl: pictureUrl, pubkey: pubkey), + const SizedBox(width: 16), //text section Expanded( diff --git a/lib/components/write_post.dart b/lib/presentation_layer/components/write_post.dart similarity index 86% rename from lib/components/write_post.dart rename to lib/presentation_layer/components/write_post.dart index ca44199a..e980d82f 100644 --- a/lib/components/write_post.dart +++ b/lib/presentation_layer/components/write_post.dart @@ -1,20 +1,13 @@ import 'dart:developer'; import 'dart:io'; - -import 'package:camelus/atoms/picture.dart'; -import 'package:camelus/db/database.dart'; +import 'package:camelus/domain_layer/entities/user_metadata.dart'; +import 'package:camelus/domain_layer/usecases/get_user_metadata.dart'; +import 'package:camelus/presentation_layer/atoms/picture.dart'; import 'package:camelus/helpers/nprofile_helper.dart'; -import 'package:camelus/helpers/search.dart'; -import 'package:camelus/models/nostr_request_event.dart'; -import 'package:camelus/models/nostr_tag.dart'; -import 'package:camelus/providers/database_provider.dart'; -import 'package:camelus/providers/key_pair_provider.dart'; -import 'package:camelus/providers/metadata_provider.dart'; -import 'package:camelus/providers/nostr_service_provider.dart'; -import 'package:camelus/providers/relay_provider.dart'; -import 'package:camelus/services/external/nostr_build_file_upload.dart'; -import 'package:camelus/services/nostr/metadata/user_metadata.dart'; -import 'package:camelus/services/nostr/relays/relays_ranking.dart'; +import 'package:camelus/domain_layer/entities/nostr_tag.dart'; +import 'package:camelus/presentation_layer/providers/edit_relays_provider.dart'; +import 'package:camelus/presentation_layer/providers/file_upload_provider.dart'; +import 'package:camelus/presentation_layer/providers/metadata_provider.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_mentions/flutter_mentions.dart'; @@ -23,23 +16,18 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lottie/lottie.dart'; import 'package:camelus/config/palette.dart'; import 'package:camelus/helpers/helpers.dart'; -import 'package:camelus/models/post_context.dart'; -import 'package:camelus/services/nostr/nostr_service.dart'; +import 'package:camelus/data_layer/models/post_context.dart'; class WritePost extends ConsumerStatefulWidget { final PostContext? context; - const WritePost({Key? key, this.context}) : super(key: key); + const WritePost({super.key, this.context}); @override ConsumerState createState() => _WritePostState(); } class _WritePostState extends ConsumerState { - late NostrService _nostrService; - late AppDatabase _db; - late Search _search; - final TextEditingController _textEditingController = TextEditingController(); final GlobalKey _textEditingControllerKey = GlobalKey(); @@ -53,7 +41,11 @@ class _WritePostState extends ConsumerState { List _mentionedInPost = []; _addImage() async { - FilePickerResult? result = await FilePicker.platform.pickFiles(); + FilePickerResult? result = await FilePicker.platform.pickFiles( + allowMultiple: false, + type: FileType.image, + dialogTitle: "select image", + ); if (result != null) { File file = File(result.files.single.path!); @@ -70,23 +62,17 @@ class _WritePostState extends ConsumerState { _searchMentions(search) async { List> results = []; - results = _search.searchUsersMetadata(search); - - for (var result in results) { - result['id'] = result['pubkey']; - if (result['picture'] == null) { - result['picture'] = ""; - } - - if (result['nip05'] == null) { - result['nip05'] = ""; - } - - if (result['name'] == null) { - result['display'] = ""; - } - // rename name to display - result['display'] = result['name']; + var rawResults = []; //_search.searchUsersMetadata(search); + for (var rawResult in rawResults) { + var result = { + "id": rawResult.pubkey, + "pubkey": rawResult.pubkey, + "display": rawResult.name ?? "", + "name": rawResult.name ?? "", + "picture": rawResult.picture ?? "", + "nip05": rawResult.nip05 ?? "", + }; + results.add(result); } // keep data from already mentioned users @@ -233,12 +219,15 @@ class _WritePostState extends ConsumerState { if (mentionKeys.isNotEmpty) { for (int i = 0; i < mentionKeys.length; i++) { var pubkey = mentionKeys[i]; + final editRelayProvider = ref.watch(editRelaysProvider); + var potentialRelays = - await RelaysRanking().getBestRelays(pubkey, Direction.read); + await editRelayProvider.getRelayHintsInbox(pubkey); + tags.add(NostrTag( type: "p", value: pubkey, - recommended_relay: potentialRelays.firstOrNull ?? "", + recommended_relay: potentialRelays.firstOrNull?.url ?? "", marker: "mention", )); } @@ -267,7 +256,7 @@ class _WritePostState extends ConsumerState { List imageUrls = []; for (var image in _images) { try { - var url = await NostrBuildFileUpload.uploadImage(image); + var url = await ref.watch(fileUploadProvider).uploadImageFile(image); imageUrls.add(url); } catch (e) { log("errUploadImage: ${e.toString()}"); @@ -283,31 +272,8 @@ class _WritePostState extends ConsumerState { log("content: $content"); //_nostrService.writeEvent(content, 1, tags); - var relays = ref.watch(relayServiceProvider); - var keyService = await ref.watch(keyPairProvider.future); - - var keyPair = keyService.keyPair!; - - var myBody = NostrRequestEventBody( - pubkey: keyPair.publicKey, - privateKey: keyPair.privateKey, - kind: 1, - content: content, - tags: tags, - ); - var myRequest = NostrRequestEvent(body: myBody); - List results = await relays.write(request: myRequest); - - // check if all are timeout - var timeouts = results.where((element) => element == 'timeout'); - if (timeouts.length == results.length || results == []) { - log("error sending msg"); - setState(() { - submitLoading = false; - }); - _showErrorMsg("all relays timed out 😥"); - return; - } + //! todo implement writeEvent + throw UnimplementedError(); // wait for x seconds Future.delayed(const Duration(milliseconds: 100), () { @@ -337,11 +303,7 @@ class _WritePostState extends ConsumerState { ); } - void _initServices() async { - _nostrService = ref.read(nostrServiceProvider); - _db = await ref.watch(databaseProvider.future); - _search = Search(_db); - } + void _initServices() async {} @override void initState() { @@ -612,7 +574,7 @@ class _WritePostState extends ConsumerState { ); } - Row _topBar(BuildContext context, UserMetadata metadata) { + Row _topBar(BuildContext context, GetUserMetadata metadata) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -639,14 +601,15 @@ class _WritePostState extends ConsumerState { Column( children: [ // get metadata - FutureBuilder( - future: metadata + StreamBuilder( + stream: metadata .getMetadataByPubkey(widget.context!.replyToNote.pubkey), - builder: (BuildContext context, AsyncSnapshot snapshot) { + builder: (BuildContext context, + AsyncSnapshot snapshot) { var name = ""; if (snapshot.hasData) { - name = snapshot.data?["name"] ?? ""; + name = snapshot.data?.name ?? ""; } else if (snapshot.hasError) { name = ""; } else { diff --git a/lib/physics/position_retained_scroll_physics.dart b/lib/presentation_layer/physics/position_retained_scroll_physics.dart similarity index 100% rename from lib/physics/position_retained_scroll_physics.dart rename to lib/presentation_layer/physics/position_retained_scroll_physics.dart diff --git a/lib/presentation_layer/providers/app_update_provider.dart b/lib/presentation_layer/providers/app_update_provider.dart new file mode 100644 index 00000000..e49eaa9f --- /dev/null +++ b/lib/presentation_layer/providers/app_update_provider.dart @@ -0,0 +1,18 @@ +import 'package:camelus/data_layer/repositories/app_update_repository_impl.dart'; +import 'package:camelus/domain_layer/repositories/app_update_repository.dart'; +import 'package:camelus/domain_layer/usecases/check_app_update.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; + +import '../../data_layer/data_sources/http_request_data_source.dart'; + +final appUpdateProvider = Provider((ref) { + final http.Client client = http.Client(); + final HttpRequestDataSource dataSource = HttpRequestDataSource(client); + final AppUpdateRepository appUpdateRepository = + AppUpdateRepositoryImpl(httpJsonDataSource: dataSource); + + final CheckAppUpdate appUpdate = CheckAppUpdate(appUpdateRepository); + + return appUpdate; +}); diff --git a/lib/presentation_layer/providers/db_app_provider.dart b/lib/presentation_layer/providers/db_app_provider.dart new file mode 100644 index 00000000..77a1f374 --- /dev/null +++ b/lib/presentation_layer/providers/db_app_provider.dart @@ -0,0 +1,8 @@ +import 'package:riverpod/riverpod.dart'; +import '../../data_layer/db/object_box_camelus/db_camelus.dart'; +import '../../domain_layer/repositories/app_db.dart'; + +final dbAppProvider = Provider((ref) { + final AppDb db = DbAppImpl(); + return db; +}); diff --git a/lib/presentation_layer/providers/db_object_box_provider.dart b/lib/presentation_layer/providers/db_object_box_provider.dart new file mode 100644 index 00000000..02b48e5d --- /dev/null +++ b/lib/presentation_layer/providers/db_object_box_provider.dart @@ -0,0 +1,8 @@ +import 'package:riverpod/riverpod.dart'; + +import '../../data_layer/db/object_box_ndk/db_object_box.dart'; + +final dbObjectBoxProvider = Provider((ref) { + final db = DbObjectBox(); + return db; +}); diff --git a/lib/presentation_layer/providers/edit_relays_provider.dart b/lib/presentation_layer/providers/edit_relays_provider.dart new file mode 100644 index 00000000..c12db096 --- /dev/null +++ b/lib/presentation_layer/providers/edit_relays_provider.dart @@ -0,0 +1,26 @@ +import 'package:camelus/data_layer/data_sources/dart_ndk_source.dart'; +import 'package:camelus/data_layer/repositories/edit_relays_repository_impl.dart'; +import 'package:camelus/domain_layer/repositories/edit_relays_repository.dart'; +import 'package:camelus/domain_layer/usecases/edit_relays.dart'; +import 'package:camelus/presentation_layer/providers/event_signer_provider.dart'; +import 'package:camelus/presentation_layer/providers/event_verifier.dart'; +import 'package:camelus/presentation_layer/providers/ndk_provider.dart'; +import 'package:riverpod/riverpod.dart'; + +final editRelaysProvider = Provider((ref) { + final ndk = ref.watch(ndkProvider); + + final eventVerifier = ref.watch(eventVerifierProvider); + + final EditRelaysRepository editRelayRepository = EditRelaysRepositoryImpl( + dartNdkSource: DartNdkSource(ndk), + eventVerifier: eventVerifier, + ); + + final signerP = ref.watch(eventSignerProvider); + + final EditRelays editRelays = + EditRelays(editRelayRepository, signerP?.getPublicKey()); + + return editRelays; +}); diff --git a/lib/presentation_layer/providers/event_feed_provider.dart b/lib/presentation_layer/providers/event_feed_provider.dart new file mode 100644 index 00000000..abeeeb95 --- /dev/null +++ b/lib/presentation_layer/providers/event_feed_provider.dart @@ -0,0 +1,94 @@ +import 'dart:async'; + +import 'package:riverpod/riverpod.dart'; + +import '../../data_layer/data_sources/dart_ndk_source.dart'; +import '../../data_layer/repositories/note_repository_impl.dart'; +import '../../domain_layer/entities/feed_event_view_model.dart'; +import '../../domain_layer/repositories/note_repository.dart'; +import '../../domain_layer/usecases/event_feed.dart'; + +import 'event_verifier.dart'; +import 'ndk_provider.dart'; + +//NotifierProvider + +/// [String] is root event id +final eventFeedStateProvider = NotifierProvider.autoDispose + .family( + EventFeedState.new, +); + +class EventFeedState + extends AutoDisposeFamilyNotifier { + StreamSubscription? _rootNoteSub; + StreamSubscription? _commentNotesSub; + + late EventFeed myEventFeed; + + _setupEventFeed() { + final ndk = ref.watch(ndkProvider); + + final eventVerifier = ref.watch(eventVerifierProvider); + + final DartNdkSource dartNdkSource = DartNdkSource(ndk); + + final NoteRepository noteRepository = NoteRepositoryImpl( + dartNdkSource: dartNdkSource, + eventVerifier: eventVerifier, + ); + + myEventFeed = EventFeed(noteRepository); + } + + /// closes everthing and resets the state + Future resetStateDispose() async { + state = FeedEventViewModel( + comments: [], + rootNote: null, + ); + + _commentNotesSub?.cancel(); + _rootNoteSub?.cancel(); + _rootNoteSub = null; + _commentNotesSub = null; + await myEventFeed.dispose(); + } + + @override + FeedEventViewModel build(String arg) { + ref.onDispose(() { + resetStateDispose(); + }); + _setupEventFeed(); + _initSubscriptions(); + _initialFetch(arg); + + return FeedEventViewModel( + comments: [], + rootNote: null, + ); + } + + _initialFetch(String rootNoteId) { + myEventFeed.subscribeToRootNote( + noteId: rootNoteId, + ); + + myEventFeed.subscribeToReplyNotes( + rootNoteId: rootNoteId, + ); + } + + void _initSubscriptions() async { + // Root note subscription + _rootNoteSub = myEventFeed.rootNoteStream.listen((event) { + state = state.copyWith(rootNote: event); + }); + + // Comment notes subscription + _commentNotesSub = myEventFeed.repliesTreeStream.listen((tree) { + state = state.copyWith(comments: tree); + }); + } +} diff --git a/lib/presentation_layer/providers/event_signer_provider.dart b/lib/presentation_layer/providers/event_signer_provider.dart new file mode 100644 index 00000000..dd1e9d38 --- /dev/null +++ b/lib/presentation_layer/providers/event_signer_provider.dart @@ -0,0 +1,26 @@ +import 'package:riverpod/riverpod.dart'; +import 'package:ndk/ndk.dart'; + +final eventSignerProvider = + StateNotifierProvider((ref) { + return EventSignerNotifier(); +}); + +class EventSignerNotifier extends StateNotifier { + EventSignerNotifier() : super(null); + + void setSigner(EventSigner signer) { + state = signer; + } + + void clearSigner() { + state = null; + } +} + +/* usage + +ref.read(eventSignerProvider.notifier).setSigner(bip340Signer); +ref.read(eventSignerProvider.notifier).clearSigner(); + +*/ \ No newline at end of file diff --git a/lib/presentation_layer/providers/event_verifier.dart b/lib/presentation_layer/providers/event_verifier.dart new file mode 100644 index 00000000..6192fd68 --- /dev/null +++ b/lib/presentation_layer/providers/event_verifier.dart @@ -0,0 +1,25 @@ +import 'package:ndk/ndk.dart'; +import 'package:ndk/entities.dart' as ndk_entities; +import 'package:riverpod/riverpod.dart'; + +final eventVerifierProvider = Provider((ref) { + final EventVerifier eventVerifier = Bip340EventVerifier(); + final EventVerifier mockEventVerifier = MockEventVerifier(); + final RustEventVerifier rustEventVerifier = RustEventVerifier(); + + return rustEventVerifier; +}); + +class MockEventVerifier implements EventVerifier { + bool _result = true; + + /// If [result] is false, [verify] will always return false. Default is true. + MockEventVerifier({bool result = true}) { + _result = result; + } + + @override + Future verify(ndk_entities.Nip01Event event) async { + return _result; + } +} diff --git a/lib/presentation_layer/providers/file_upload_provider.dart b/lib/presentation_layer/providers/file_upload_provider.dart new file mode 100644 index 00000000..58870c2b --- /dev/null +++ b/lib/presentation_layer/providers/file_upload_provider.dart @@ -0,0 +1,14 @@ +import 'package:camelus/data_layer/data_sources/nostr_build_file_upload.dart'; +import 'package:camelus/data_layer/repositories/file_upload_repository_impl.dart'; +import 'package:camelus/domain_layer/repositories/upload_file_repository.dart'; +import 'package:camelus/domain_layer/usecases/file_upload.dart'; +import 'package:riverpod/riverpod.dart'; + +final fileUploadProvider = Provider((ref) { + final nostrBuildFileUpload = NostrBuildFileUpload(); + final FileUploadRepository fileUploadRepository = + FileUploadRepositoryImpl(nostrBuildFileUpload: nostrBuildFileUpload); + final fileUpload = FileUpload(fileUploadRepository); + + return fileUpload; +}); diff --git a/lib/presentation_layer/providers/following_provider.dart b/lib/presentation_layer/providers/following_provider.dart new file mode 100644 index 00000000..5fe7ecb3 --- /dev/null +++ b/lib/presentation_layer/providers/following_provider.dart @@ -0,0 +1,35 @@ +import 'package:camelus/data_layer/repositories/follow_repository_impl.dart'; +import 'package:camelus/domain_layer/repositories/follow_repository.dart'; +import 'package:camelus/domain_layer/usecases/follow.dart'; +import 'package:camelus/presentation_layer/providers/event_signer_provider.dart'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../data_layer/data_sources/dart_ndk_source.dart'; +import 'event_verifier.dart'; +import 'ndk_provider.dart'; + +final followingProvider = Provider((ref) { + final ndk = ref.watch(ndkProvider); + + final eventVerifier = ref.watch(eventVerifierProvider); + + final eventSigner = ref.watch(eventSignerProvider); + + final DartNdkSource dartNdkSource = DartNdkSource(ndk); + + final FollowRepository _followRepository = FollowRepositoryImpl( + dartNdkSource: dartNdkSource, + eventVerifier: eventVerifier, + eventSigner: eventSigner, + ); + + final signerP = ref.watch(eventSignerProvider); + + final follow = Follow( + followRepository: _followRepository, + selfPubkey: signerP?.getPublicKey(), + ); + + return follow; +}); diff --git a/lib/presentation_layer/providers/get_notes_provider.dart b/lib/presentation_layer/providers/get_notes_provider.dart new file mode 100644 index 00000000..ca4a3f2d --- /dev/null +++ b/lib/presentation_layer/providers/get_notes_provider.dart @@ -0,0 +1,28 @@ +import 'package:camelus/data_layer/data_sources/dart_ndk_source.dart'; +import 'package:camelus/data_layer/repositories/note_repository_impl.dart'; +import 'package:camelus/domain_layer/repositories/note_repository.dart'; +import 'package:camelus/domain_layer/usecases/follow.dart'; +import 'package:camelus/domain_layer/usecases/get_notes.dart'; +import 'package:camelus/presentation_layer/providers/event_verifier.dart'; +import 'package:camelus/presentation_layer/providers/following_provider.dart'; +import 'package:camelus/presentation_layer/providers/ndk_provider.dart'; +import 'package:riverpod/riverpod.dart'; + +final getNotesProvider = Provider((ref) { + final ndk = ref.watch(ndkProvider); + + final eventVerifier = ref.watch(eventVerifierProvider); + + final DartNdkSource dartNdkSource = DartNdkSource(ndk); + + final NoteRepository noteRepository = NoteRepositoryImpl( + dartNdkSource: dartNdkSource, + eventVerifier: eventVerifier, + ); + + final Follow followProvider = ref.watch(followingProvider); + + final GetNotes getNotes = GetNotes(noteRepository, followProvider); + + return getNotes; +}); diff --git a/lib/presentation_layer/providers/main_feed_provider.dart b/lib/presentation_layer/providers/main_feed_provider.dart new file mode 100644 index 00000000..e4f25a01 --- /dev/null +++ b/lib/presentation_layer/providers/main_feed_provider.dart @@ -0,0 +1,150 @@ +import 'dart:async'; + +import 'package:riverpod/riverpod.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../../data_layer/data_sources/dart_ndk_source.dart'; +import '../../data_layer/repositories/note_repository_impl.dart'; +import '../../domain_layer/entities/feed_view_model.dart'; +import '../../domain_layer/entities/nostr_note.dart'; +import '../../domain_layer/repositories/note_repository.dart'; +import '../../domain_layer/usecases/follow.dart'; +import '../../domain_layer/usecases/main_feed.dart'; +import 'db_app_provider.dart'; +import 'event_verifier.dart'; +import 'following_provider.dart'; +import 'ndk_provider.dart'; + +final getMainFeedProvider = Provider((ref) { + final ndk = ref.watch(ndkProvider); + + final eventVerifier = ref.watch(eventVerifierProvider); + + final DartNdkSource dartNdkSource = DartNdkSource(ndk); + + final NoteRepository noteRepository = NoteRepositoryImpl( + dartNdkSource: dartNdkSource, + eventVerifier: eventVerifier, + ); + + final Follow followProvider = ref.watch(followingProvider); + + final MainFeed mainFeed = MainFeed(noteRepository, followProvider); + + return mainFeed; +}); + +final mainFeedStateProvider = + NotifierProvider.family( + MainFeedState.new, +); + +class MainFeedState extends FamilyNotifier { + StreamSubscription? _rootNotesSub; + StreamSubscription? _newRootNotesSub; + StreamSubscription? _rootAndReplySub; + StreamSubscription? _newRootAndReplySub; + + /// closes everthing and resets the state + Future resetStateDispose() async { + final mainFeed = ref.read(getMainFeedProvider); + state = FeedViewModel( + timelineRootNotes: [], + newRootNotes: [], + timelineRootAndReplyNotes: [], + newRootAndReplyNotes: [], + ); + + _rootNotesSub?.cancel(); + _newRootNotesSub?.cancel(); + _rootAndReplySub?.cancel(); + _newRootAndReplySub?.cancel(); + await mainFeed.dispose(); + } + + @override + FeedViewModel build(String arg) { + _initSubscriptions(arg); + return FeedViewModel( + timelineRootNotes: [], + newRootNotes: [], + timelineRootAndReplyNotes: [], + newRootAndReplyNotes: [], + ); + } + + void _initSubscriptions(String pubkey) async { + final mainFeed = ref.read(getMainFeedProvider); + final appDbP = ref.read(dbAppProvider); + + // [cutoff] is seperates the feed into old and new notes + // basically marking the cache point + final lastFetch = await appDbP.read('main_feed_cache_cutoff'); + int cutoff = 0; + final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + if (lastFetch != null) { + cutoff = int.parse(lastFetch); + } else { + cutoff = now; + } + // Save the current time as the new cutoff + appDbP.save(key: 'main_feed_cache_cutoff', value: now.toString()); + + // Timeline subscription + _rootNotesSub = mainFeed.rootNotesStream + .bufferTime(const Duration(milliseconds: 500)) + .where((events) => events.isNotEmpty) + .listen(_addRootTimelineEvents); + + // New notes subscription + _newRootNotesSub = mainFeed.newRootNotesStream + .bufferTime(const Duration(seconds: 1)) + .where((events) => events.isNotEmpty) + .listen(_addNewRootEvents); + + _rootAndReplySub = mainFeed.rootAndReplyNotesStream + .bufferTime(const Duration(milliseconds: 500)) + .where((events) => events.isNotEmpty) + .listen(_addRootAndReplyTimelineEvents); + + _newRootAndReplySub = mainFeed.newRootAndReplyNotesStream + .bufferTime(const Duration(seconds: 1)) + .where((events) => events.isNotEmpty) + .listen(_addNewRootAndReplyEvents); + + // Initial fetch + mainFeed.fetchFeedEvents( + npub: pubkey, + requestId: "startup", + limit: 20, + until: cutoff, + ); + mainFeed.subscribeToFreshNotes(npub: pubkey, since: cutoff); + } + + void _addRootTimelineEvents(List events) { + state = state.copyWith( + timelineRootNotes: [...state.timelineRootNotes, ...events] + ..sort((a, b) => b.created_at.compareTo(a.created_at))); + } + + void _addNewRootEvents(List events) { + state = state.copyWith( + newRootNotes: [...state.newRootNotes, ...events] + ..sort((a, b) => b.created_at.compareTo(a.created_at))); + } + + void _addRootAndReplyTimelineEvents(List events) { + state = state.copyWith( + timelineRootAndReplyNotes: [ + ...state.timelineRootAndReplyNotes, + ...events + ]..sort((a, b) => b.created_at.compareTo(a.created_at))); + } + + void _addNewRootAndReplyEvents(List events) { + state = state.copyWith( + newRootAndReplyNotes: [...state.newRootAndReplyNotes, ...events] + ..sort((a, b) => b.created_at.compareTo(a.created_at))); + } +} diff --git a/lib/presentation_layer/providers/metadata_provider.dart b/lib/presentation_layer/providers/metadata_provider.dart new file mode 100644 index 00000000..60475f68 --- /dev/null +++ b/lib/presentation_layer/providers/metadata_provider.dart @@ -0,0 +1,25 @@ +import 'package:riverpod/riverpod.dart'; + +import '../../data_layer/data_sources/dart_ndk_source.dart'; +import '../../data_layer/repositories/metadata_repository_impl.dart'; +import '../../domain_layer/repositories/metadata_repository.dart'; +import '../../domain_layer/usecases/get_user_metadata.dart'; +import 'event_verifier.dart'; +import 'ndk_provider.dart'; + +final metadataProvider = Provider((ref) { + final ndk = ref.watch(ndkProvider); + + final eventVerifier = ref.watch(eventVerifierProvider); + + final DartNdkSource dartNdkSource = DartNdkSource(ndk); + + final MetadataRepository metadataRepository = MetadataRepositoryImpl( + dartNdkSource: dartNdkSource, + eventVerifier: eventVerifier, + ); + + final GetUserMetadata getUserMetadata = GetUserMetadata(metadataRepository); + + return getUserMetadata; +}); diff --git a/lib/presentation_layer/providers/moderation_provider.dart b/lib/presentation_layer/providers/moderation_provider.dart new file mode 100644 index 00000000..fd96517a --- /dev/null +++ b/lib/presentation_layer/providers/moderation_provider.dart @@ -0,0 +1,9 @@ +import 'package:camelus/domain_layer/usecases/moderation.dart'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +final moderationProvider = FutureProvider((ref) async { + final moderation = Moderation(); + + return moderation; +}); diff --git a/lib/providers/navigation_bar_provider.dart b/lib/presentation_layer/providers/navigation_bar_provider.dart similarity index 100% rename from lib/providers/navigation_bar_provider.dart rename to lib/presentation_layer/providers/navigation_bar_provider.dart diff --git a/lib/presentation_layer/providers/ndk_provider.dart b/lib/presentation_layer/providers/ndk_provider.dart new file mode 100644 index 00000000..b59279b9 --- /dev/null +++ b/lib/presentation_layer/providers/ndk_provider.dart @@ -0,0 +1,27 @@ +import 'package:ndk/ndk.dart'; +import 'package:riverpod/riverpod.dart'; + +import '../../config/default_relays.dart'; +import 'db_object_box_provider.dart'; +import 'event_signer_provider.dart'; +import 'event_verifier.dart'; + +final ndkProvider = Provider((ref) { + final eventSigner = ref.watch(eventSignerProvider); + final eventVerifier = ref.watch(eventVerifierProvider); + + final dbObjectBox = ref.watch(dbObjectBoxProvider); + + final CacheManager memDb = MemCacheManager(); + + final NdkConfig ndkConfig = NdkConfig( + engine: NdkEngine.JIT, + cache: dbObjectBox, + eventSigner: eventSigner, + eventVerifier: eventVerifier, + bootstrapRelays: CAMELUS_BOOTSTRAP_RELAYS, + ); + + final ndk = Ndk(ndkConfig); + return ndk; +}); diff --git a/lib/presentation_layer/providers/nip05_provider.dart b/lib/presentation_layer/providers/nip05_provider.dart new file mode 100644 index 00000000..935a5aee --- /dev/null +++ b/lib/presentation_layer/providers/nip05_provider.dart @@ -0,0 +1,22 @@ +import 'package:camelus/data_layer/repositories/database_repository_impl.dart'; +import 'package:camelus/data_layer/repositories/nip05_repository_impl.dart'; +import 'package:camelus/domain_layer/repositories/database_repository.dart'; +import 'package:camelus/domain_layer/repositories/nip05_repository.dart'; +import 'package:camelus/domain_layer/usecases/verify_nip05.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart'; + +import '../../data_layer/data_sources/http_request_data_source.dart'; + +final nip05provider = FutureProvider((ref) async { + final Client client = Client(); + final HttpRequestDataSource dataSource = HttpRequestDataSource(client); + final Nip05Repository nip05Repository = + Nip05RepositoryImpl(dataSource: dataSource); + + final DatabaseRepository databaseRepository = DatabaseRepositoryImpl(); + + var nip05 = VerifyNip05(databaseRepository, nip05Repository); + + return nip05; +}); diff --git a/lib/presentation_layer/providers/nostr_band_provider.dart b/lib/presentation_layer/providers/nostr_band_provider.dart new file mode 100644 index 00000000..2962e8e9 --- /dev/null +++ b/lib/presentation_layer/providers/nostr_band_provider.dart @@ -0,0 +1,14 @@ +import 'package:camelus/data_layer/data_sources/api_nostr_band_data_source.dart'; +import 'package:camelus/data_layer/repositories/nostr_band_repository_impl.dart'; +import 'package:camelus/domain_layer/repositories/nostr_band_repository.dart'; +import 'package:camelus/domain_layer/usecases/get_nostr_band_hashtags.dart'; +import 'package:riverpod/riverpod.dart'; + +final nostrBandProvider = Provider((ref) { + final apiNostrBandDataSource = ApiNostrBandDataSource(); + final NostrBandRepository nostrBandRepository = + NostrBandRepositoryImpl(apiNostrBandDataSource: apiNostrBandDataSource); + final getNostrBand = GetNostrBand(nostrBandRepository); + + return getNostrBand; +}); diff --git a/lib/presentation_layer/providers/onboarding_provider.dart b/lib/presentation_layer/providers/onboarding_provider.dart new file mode 100644 index 00000000..37bf5c45 --- /dev/null +++ b/lib/presentation_layer/providers/onboarding_provider.dart @@ -0,0 +1,15 @@ +import 'package:camelus/domain_layer/entities/onboarding_user_info.dart'; +import 'package:camelus/domain_layer/usecases/onboard.dart'; +import 'package:camelus/helpers/bip340.dart'; +import 'package:riverpod/riverpod.dart'; + +final onboardingProvider = Provider((ref) { + OnboardingUserInfo signUpInfo = OnboardingUserInfo( + keyPair: Bip340().generatePrivateKey(), + ); + final onboard = Onboard( + signUpInfo: signUpInfo, + ); + + return onboard; +}); diff --git a/lib/presentation_layer/providers/profile_feed_provider.dart b/lib/presentation_layer/providers/profile_feed_provider.dart new file mode 100644 index 00000000..cbd09015 --- /dev/null +++ b/lib/presentation_layer/providers/profile_feed_provider.dart @@ -0,0 +1,148 @@ +import 'dart:async'; + +import 'package:riverpod/riverpod.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../../data_layer/data_sources/dart_ndk_source.dart'; +import '../../data_layer/repositories/note_repository_impl.dart'; +import '../../domain_layer/entities/feed_view_model.dart'; +import '../../domain_layer/entities/nostr_note.dart'; +import '../../domain_layer/repositories/note_repository.dart'; +import '../../domain_layer/usecases/profile_feed.dart'; +import 'db_app_provider.dart'; +import 'event_verifier.dart'; +import 'ndk_provider.dart'; + +final profileFeedProvider = Provider((ref) { + final ndk = ref.watch(ndkProvider); + + final eventVerifier = ref.watch(eventVerifierProvider); + + final DartNdkSource dartNdkSource = DartNdkSource(ndk); + + final NoteRepository noteRepository = NoteRepositoryImpl( + dartNdkSource: dartNdkSource, + eventVerifier: eventVerifier, + ); + + final ProfileFeed profileFeed = ProfileFeed(noteRepository); + + return profileFeed; +}); + +final profileFeedStateProvider = + NotifierProvider.family( + ProfileFeedState.new, +); + +class ProfileFeedState extends FamilyNotifier { + StreamSubscription? _rootNotesSub; + StreamSubscription? _newRootNotesSub; + StreamSubscription? _rootAndReplySub; + StreamSubscription? _newRootAndReplySub; + + /// closes everthing and resets the state + Future resetStateDispose() async { + final profileFeed = ref.read(profileFeedProvider); + state = FeedViewModel( + timelineRootNotes: [], + newRootNotes: [], + timelineRootAndReplyNotes: [], + newRootAndReplyNotes: [], + ); + + _rootNotesSub?.cancel(); + _newRootNotesSub?.cancel(); + _rootAndReplySub?.cancel(); + _newRootAndReplySub?.cancel(); + await profileFeed.dispose(); + } + + @override + FeedViewModel build(String arg) { + _initSubscriptions(arg); + return FeedViewModel( + timelineRootNotes: [], + newRootNotes: [], + timelineRootAndReplyNotes: [], + newRootAndReplyNotes: [], + ); + } + + void _initSubscriptions(String pubkey) async { + final profileFeed = ref.read(profileFeedProvider); + final appDbP = ref.read(dbAppProvider); + + final dbCutOffKey = 'profile_feed_cache_cutoff_$pubkey'; + + // [cutoff] is seperates the feed into old and new notes + // basically marking the cache point + final lastFetch = await appDbP.read(dbCutOffKey); + int cutoff = 0; + final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + if (lastFetch != null) { + cutoff = int.parse(lastFetch); + } else { + cutoff = now; + } + // Save the current time as the new cutoff + appDbP.save(key: dbCutOffKey, value: now.toString()); + + // Timeline subscription + _rootNotesSub = profileFeed.rootNotesStream + .bufferTime(const Duration(milliseconds: 500)) + .where((events) => events.isNotEmpty) + .listen(_addRootTimelineEvents); + + // New notes subscription + _newRootNotesSub = profileFeed.newRootNotesStream + .bufferTime(const Duration(seconds: 1)) + .where((events) => events.isNotEmpty) + .listen(_addNewRootEvents); + + _rootAndReplySub = profileFeed.rootAndReplyNotesStream + .bufferTime(const Duration(milliseconds: 500)) + .where((events) => events.isNotEmpty) + .listen(_addRootAndReplyTimelineEvents); + + _newRootAndReplySub = profileFeed.newRootAndReplyNotesStream + .bufferTime(const Duration(seconds: 1)) + .where((events) => events.isNotEmpty) + .listen(_addNewRootAndReplyEvents); + + // Initial fetch + profileFeed.fetchFeedEvents( + npub: pubkey, + requestId: "startup-profile", + limit: 20, + until: cutoff, + ); + profileFeed.subscribeToFreshNotes(npub: pubkey, since: cutoff); + } + + void _addRootTimelineEvents(List events) { + state = state.copyWith( + timelineRootNotes: [...state.timelineRootNotes, ...events] + ..sort((a, b) => b.created_at.compareTo(a.created_at))); + } + + void _addNewRootEvents(List events) { + state = state.copyWith( + newRootNotes: [...state.newRootNotes, ...events] + ..sort((a, b) => b.created_at.compareTo(a.created_at))); + } + + void _addRootAndReplyTimelineEvents(List events) { + state = state.copyWith( + timelineRootAndReplyNotes: [ + ...state.timelineRootAndReplyNotes, + ...events + ]..sort((a, b) => b.created_at.compareTo(a.created_at))); + } + + void _addNewRootAndReplyEvents(List events) { + state = state.copyWith( + newRootAndReplyNotes: [...state.newRootAndReplyNotes, ...events] + ..sort((a, b) => b.created_at.compareTo(a.created_at))); + } +} diff --git a/lib/routes/home_page.dart b/lib/presentation_layer/routes/home_page.dart similarity index 88% rename from lib/routes/home_page.dart rename to lib/presentation_layer/routes/home_page.dart index e110ef3c..9dcb5a10 100644 --- a/lib/routes/home_page.dart +++ b/lib/presentation_layer/routes/home_page.dart @@ -1,22 +1,23 @@ import 'dart:ui'; import 'package:camelus/helpers/helpers.dart'; -import 'package:camelus/providers/navigation_bar_provider.dart'; -import 'package:camelus/routes/notification_page.dart'; -import 'package:camelus/routes/search_page.dart'; +import 'package:camelus/presentation_layer/providers/navigation_bar_provider.dart'; +import 'package:camelus/presentation_layer/routes/nostr/nostr_page/nostr_page.dart'; +import 'package:camelus/presentation_layer/routes/notification_page.dart'; +import 'package:camelus/presentation_layer/routes/search_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:camelus/components/write_post.dart'; +import 'package:camelus/presentation_layer/components/write_post.dart'; import 'package:camelus/config/palette.dart'; -import 'package:camelus/routes/nostr/nostr_drawer.dart'; -import 'package:camelus/routes/nostr/nostr_page/nostr_page.dart'; +import 'package:camelus/presentation_layer/routes/nostr/nostr_drawer.dart'; +import 'package:camelus/presentation_layer/routes/nostr/nostr_page/nostr_page.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:matomo_tracker/matomo_tracker.dart'; class HomePage extends ConsumerStatefulWidget { final String pubkey; - const HomePage({Key? key, required this.pubkey}) : super(key: key); + const HomePage({super.key, required this.pubkey}); @override ConsumerState createState() => _HomePageState(); @@ -57,7 +58,7 @@ class _HomePageState extends ConsumerState { try { //todo: fix onboarding await MatomoTracker.instance.initialize( - siteId: 3, + siteId: "3", url: 'https://customer.beonde.de/matomo/matomo.php', visitorId: myVisitorId, ); @@ -105,7 +106,11 @@ class _HomePageState extends ConsumerState { controller: _myPage, physics: const NeverScrollableScrollPhysics(), children: [ - NostrPage(parentScaffoldKey: _scaffoldKey, pubkey: widget.pubkey), + //NostrPage(parentScaffoldKey: _scaffoldKey, pubkey: widget.pubkey), + NostrPage( + parentScaffoldKey: _scaffoldKey, + pubkey: widget.pubkey, + ), const SearchPage(), const NotificationPage(), const Center( diff --git a/lib/presentation_layer/routes/nostr/blockedUsers/block_page.dart b/lib/presentation_layer/routes/nostr/blockedUsers/block_page.dart new file mode 100644 index 00000000..3809067e --- /dev/null +++ b/lib/presentation_layer/routes/nostr/blockedUsers/block_page.dart @@ -0,0 +1,235 @@ +import 'dart:async'; +import 'package:camelus/presentation_layer/atoms/long_button.dart'; +import 'package:camelus/config/palette.dart'; +import 'package:camelus/domain_layer/entities/nostr_tag.dart'; +import 'package:camelus/presentation_layer/providers/metadata_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class BlockPage extends ConsumerStatefulWidget { + String userPubkey; + String? postId; + + BlockPage({super.key, required this.userPubkey, this.postId}); + + @override + ConsumerState createState() => _BlockPageState(); +} + +class _BlockPageState extends ConsumerState { + bool isUserBlocked = false; + bool requestLoading = false; + + List contentTags = []; + + final TextEditingController _textController = TextEditingController(); + String _reportReason = ""; + bool _postReported = false; + bool _reportLoading = false; + + @override + void initState() { + super.initState(); + } + + void _blockUser( + String pubkey, + ) async { + throw UnimplementedError(); + } + + void _unblockUser( + String pubkey, + ) async { + throw UnimplementedError(); + } + + void _setReportReason(String reason) { + if (reason == _reportReason) { + reason = ""; + } + setState(() { + _reportReason = reason; + }); + } + + Future _reportPost() async { + throw UnimplementedError(); + } + + @override + Widget build(BuildContext context) { + var metadata = ref.watch(metadataProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('block/report'), + backgroundColor: Palette.background, + ), + backgroundColor: Palette.background, + body: SingleChildScrollView( + child: Column( + children: [ + // block user + Container( + padding: const EdgeInsets.all(10), + child: Column( + children: [ + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('user', + style: TextStyle( + color: Palette.lightGray, fontSize: 20)), + const SizedBox(width: 10), + Text("unimplemented", + style: TextStyle( + color: Palette.white, + fontSize: 20, + fontWeight: FontWeight.bold)), + ], + ), + const SizedBox(height: 20), + FutureBuilder( + future: Future.delayed(Duration(seconds: 1)), + builder: (context, snapshot) { + if (snapshot.connectionState == + ConnectionState.waiting) { + return SizedBox( + height: 40, + width: MediaQuery.of(context).size.width * 0.75, + child: longButton( + name: "loading", + loading: true, + onPressed: () {}), + ); + } + + return SizedBox( + height: 40, + width: MediaQuery.of(context).size.width * 0.75, + child: longButton( + name: isUserBlocked ? "unblock" : "block", + inverted: !isUserBlocked, + loading: requestLoading, + onPressed: () { + if (isUserBlocked) { + _unblockUser(widget.userPubkey); + } else { + _blockUser(widget.userPubkey); + } + setState(() { + isUserBlocked = !isUserBlocked; + }); + }), + ); + }), + const SizedBox(height: 10), + if (widget.postId != null && !_postReported) + Column( + children: [ + const SizedBox(height: 100), + //text user input + + GridView.count( + crossAxisCount: 2, + childAspectRatio: 4.5 / 1, + shrinkWrap: true, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + children: [ + longButton( + name: "impersonation", + onPressed: () => + {_setReportReason("impersonation")}, + inverted: _reportReason == "impersonation", + ), + longButton( + name: "spam", + onPressed: () => {_setReportReason("spam")}, + inverted: _reportReason == "spam", + ), + longButton( + name: "illegal", + onPressed: () => {_setReportReason("illegal")}, + inverted: _reportReason == "illegal", + ), + longButton( + name: "profanity", + onPressed: () => + {_setReportReason("profanity")}, + inverted: _reportReason == "profanity", + ), + longButton( + name: "nudity", + onPressed: () => {_setReportReason("nudity")}, + inverted: _reportReason == "nudity", + ), + ]), + + const SizedBox(height: 20), + SizedBox( + width: MediaQuery.of(context).size.width * 0.90, + child: TextField( + controller: _textController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'what is wrong with this post?', + ), + ), + ), + const SizedBox(height: 20), + + SizedBox( + height: 40, + width: MediaQuery.of(context).size.width * 0.75, + child: longButton( + name: "report post", + inverted: true, + loading: _reportLoading, + onPressed: () => {_reportPost()}), + ), + ], + ), + if (_postReported) + Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 100), + const Text( + "post reported", + style: TextStyle( + color: Palette.white, + fontSize: 30, + fontWeight: FontWeight.bold), + ), + const SizedBox(height: 20), + const Text( + "thank you for reporting this post", + style: + TextStyle(color: Palette.lightGray, fontSize: 20), + ), + const SizedBox(height: 20), + SizedBox( + height: 40, + width: MediaQuery.of(context).size.width * 0.75, + child: longButton( + inverted: true, + name: "go back", + onPressed: () => { + Navigator.pop(context), + }), + ), + ], + ) + ], + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/presentation_layer/routes/nostr/blockedUsers/blocked_users.dart b/lib/presentation_layer/routes/nostr/blockedUsers/blocked_users.dart new file mode 100644 index 00000000..79537a69 --- /dev/null +++ b/lib/presentation_layer/routes/nostr/blockedUsers/blocked_users.dart @@ -0,0 +1,47 @@ +import 'dart:async'; +import 'package:camelus/presentation_layer/atoms/my_profile_picture.dart'; +import 'package:camelus/presentation_layer/atoms/spinner_center.dart'; +import 'package:camelus/config/palette.dart'; +import 'package:camelus/helpers/helpers.dart'; +import 'package:camelus/domain_layer/entities/nostr_tag.dart'; +import 'package:camelus/presentation_layer/providers/metadata_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class BlockedUsers extends ConsumerStatefulWidget { + const BlockedUsers({super.key}); + + @override + ConsumerState createState() => _BlockedUsersState(); +} + +class _BlockedUsersState extends ConsumerState { + Completer initDone = Completer(); + + List contentTags = []; + + @override + void initState() { + super.initState(); + _initState(); + } + + @override + void dispose() { + super.dispose(); + } + + void _initState() async {} + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Palette.background, + appBar: AppBar( + backgroundColor: Palette.background, + title: const Text('Blocked Users'), + ), + body: const Text('not implemented'), + ); + } +} diff --git a/lib/presentation_layer/routes/nostr/event_view/event_view_page.dart b/lib/presentation_layer/routes/nostr/event_view/event_view_page.dart new file mode 100644 index 00000000..6281a103 --- /dev/null +++ b/lib/presentation_layer/routes/nostr/event_view/event_view_page.dart @@ -0,0 +1,115 @@ +import 'dart:async'; +import 'dart:developer'; +import 'package:camelus/domain_layer/usecases/get_notes.dart'; +import 'package:camelus/presentation_layer/atoms/refresh_indicator_no_need.dart'; +import 'package:camelus/presentation_layer/components/note_card/note_card_container.dart'; +import 'package:camelus/config/palette.dart'; +import 'package:camelus/domain_layer/entities/nostr_note.dart'; +import 'package:camelus/presentation_layer/components/note_card/sceleton_note.dart'; +import 'package:camelus/presentation_layer/providers/get_notes_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../components/comments_section.dart'; +import '../../../providers/event_feed_provider.dart'; + +class EventViewPage extends ConsumerStatefulWidget { + final String? _openNoteId; + final String _rootNoteId; + + const EventViewPage({ + super.key, + required String? openNoteId, + required String rootNoteId, + }) : _openNoteId = openNoteId, + _rootNoteId = rootNoteId; + + @override + _EventViewPageState createState() => _EventViewPageState(); +} + +class _EventViewPageState extends ConsumerState { + late GetNotes _getNotes; + + Stream> notesStream = Stream.empty(); + + late final ScrollController _scrollControllerFeed = ScrollController(); + + final Completer _servicesReady = Completer(); + + final String eventFeedFreshId = "fresh"; + + NostrNote? _lastNoteInFeed; + + void _setupScrollListener() { + _scrollControllerFeed.addListener(() { + if (_scrollControllerFeed.position.pixels == + _scrollControllerFeed.position.maxScrollExtent) { + log("reached end of scroll"); + } + + if (_scrollControllerFeed.position.pixels < 100) { + // disable after sroll + // if (_newPostsAvailable) { + // setState(() { + // _newPostsAvailable = false; + // }); + // } + } + }); + } + + Future _initSequence() async { + _getNotes = ref.read(getNotesProvider); + _setupScrollListener(); + } + + @override + void initState() { + super.initState(); + _initSequence(); + } + + @override + void dispose() { + _scrollControllerFeed.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final eventFeedState = + ref.watch(eventFeedStateProvider(widget._rootNoteId)); + + return Scaffold( + backgroundColor: Palette.background, + appBar: AppBar( + foregroundColor: Palette.white, + backgroundColor: Palette.background, + title: const Text("thread"), + ), + body: ListView.builder( + controller: _scrollControllerFeed, + itemCount: eventFeedState.comments.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return eventFeedState.rootNote != null + ? NoteCardContainer( + note: eventFeedState.rootNote!, + key: ValueKey(widget._rootNoteId), + ) + : const SkeletonNote(); + } + + final event = eventFeedState.comments[index - 1]; + + return CommentSection( + key: ObjectKey(event), + comment: event, + ); + }, + ), + ); + } +} diff --git a/lib/routes/nostr/hashtag_view/hashtag_view_page.dart b/lib/presentation_layer/routes/nostr/hashtag_view/hashtag_view_page.dart similarity index 84% rename from lib/routes/nostr/hashtag_view/hashtag_view_page.dart rename to lib/presentation_layer/routes/nostr/hashtag_view/hashtag_view_page.dart index 99da9fa7..facd1531 100644 --- a/lib/routes/nostr/hashtag_view/hashtag_view_page.dart +++ b/lib/presentation_layer/routes/nostr/hashtag_view/hashtag_view_page.dart @@ -1,13 +1,11 @@ -import 'dart:math'; - import 'package:camelus/config/palette.dart'; -import 'package:camelus/routes/nostr/nostr_page/hashtag_feed_view.dart'; +import 'package:camelus/presentation_layer/routes/nostr/nostr_page/hashtag_feed_view.dart'; import 'package:flutter/material.dart'; class HastagViewPage extends StatelessWidget { final String hashtag; - const HastagViewPage({Key? key, required this.hashtag}) : super(key: key); + const HastagViewPage({super.key, required this.hashtag}); @override Widget build(BuildContext context) { diff --git a/lib/routes/nostr/nostr_drawer.dart b/lib/presentation_layer/routes/nostr/nostr_drawer.dart similarity index 74% rename from lib/routes/nostr/nostr_drawer.dart rename to lib/presentation_layer/routes/nostr/nostr_drawer.dart index 79f17d18..655019ea 100644 --- a/lib/routes/nostr/nostr_drawer.dart +++ b/lib/presentation_layer/routes/nostr/nostr_drawer.dart @@ -1,15 +1,18 @@ +import 'package:camelus/domain_layer/entities/contact_list.dart'; +import 'package:camelus/domain_layer/entities/user_metadata.dart'; +import 'package:camelus/domain_layer/usecases/follow.dart'; +import 'package:camelus/domain_layer/usecases/get_user_metadata.dart'; import 'package:camelus/helpers/nprofile_helper.dart'; -import 'package:camelus/models/nostr_tag.dart'; -import 'package:camelus/providers/following_provider.dart'; -import 'package:camelus/providers/metadata_provider.dart'; -import 'package:camelus/services/nostr/metadata/following_pubkeys.dart'; -import 'package:camelus/services/nostr/metadata/user_metadata.dart'; +import 'package:camelus/domain_layer/entities/nostr_tag.dart'; +import 'package:camelus/presentation_layer/atoms/my_profile_picture.dart'; +import 'package:camelus/presentation_layer/providers/following_provider.dart'; +import 'package:camelus/presentation_layer/providers/metadata_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:camelus/atoms/my_profile_picture.dart'; import 'package:camelus/config/palette.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -18,7 +21,7 @@ class NostrDrawer extends ConsumerWidget { //late NostrService _nostrService; - const NostrDrawer({Key? key, required this.pubkey}) : super(key: key); + const NostrDrawer({super.key, required this.pubkey}); void navigateToProfile(BuildContext context) { Navigator.pushNamed(context, "/nostr/profile", arguments: pubkey); @@ -57,7 +60,7 @@ class NostrDrawer extends ConsumerWidget { style: TextStyle(color: Colors.white), ), const SizedBox(height: 40), - QrImage( + QrImageView( data: "nostr:$nprofile", version: QrVersions.auto, size: 300.0, @@ -82,43 +85,30 @@ class NostrDrawer extends ConsumerWidget { } Widget _drawerHeader( - context, UserMetadata metadata, FollowingPubkeys followingService) { + context, GetUserMetadata metadata, Follow followingService) { return DrawerHeader( child: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: GestureDetector( - onTap: () => navigateToProfile(context), - child: Container( - decoration: const BoxDecoration( - color: Palette.primary, - shape: BoxShape.circle, - ), - child: FutureBuilder( - future: metadata.getMetadataByPubkey(pubkey), - builder: - (BuildContext context, AsyncSnapshot snapshot) { - var picture = ""; - - if (snapshot.hasData) { - picture = snapshot.data?["picture"] ?? - "https://avatars.dicebear.com/api/personas/$pubkey.svg"; - } else if (snapshot.hasError) { - picture = - "https://avatars.dicebear.com/api/personas/$pubkey.svg"; - } else { - // loading - picture = - "https://avatars.dicebear.com/api/personas/$pubkey.svg"; - } - return myProfilePicture( - pictureUrl: picture, - pubkey: pubkey, - filterQuality: FilterQuality.medium); - }), + GestureDetector( + onTap: () => navigateToProfile(context), + child: Container( + width: 35, + height: 35, + decoration: const BoxDecoration( + color: Palette.primary, + shape: BoxShape.circle, ), + child: StreamBuilder( + stream: metadata.getMetadataByPubkey(pubkey), + builder: (BuildContext context, + AsyncSnapshot snapshot) { + return UserImage( + imageUrl: snapshot.data?.picture, + pubkey: pubkey, + ); + }), ), ), const SizedBox( @@ -133,23 +123,23 @@ class NostrDrawer extends ConsumerWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - FutureBuilder( - future: metadata.getMetadataByPubkey(pubkey), + StreamBuilder( + stream: metadata.getMetadataByPubkey(pubkey), builder: (BuildContext context, - AsyncSnapshot snapshot) { + AsyncSnapshot snapshot) { var name = ""; var nip05 = ""; if (snapshot.hasData) { - name = snapshot.data?["name"] ?? ""; - nip05 = snapshot.data?["nip05"] ?? ""; + name = snapshot.data?.name ?? ""; + nip05 = snapshot.data?.nip05 ?? ""; } else if (snapshot.hasError) { name = "error"; nip05 = "error"; } else { // loading - name = "loading"; - nip05 = "loading"; + name = snapshot.data?.name ?? ""; + nip05 = snapshot.data?.nip05 ?? ""; } return Column( @@ -190,13 +180,13 @@ class NostrDrawer extends ConsumerWidget { ), Row( children: [ - FutureBuilder>( - future: followingService.getFollowingPubkeys(pubkey), + FutureBuilder( + future: followingService.getContacts(pubkey), builder: (context, snapshot) { return RichText( text: TextSpan( text: snapshot.hasData - ? snapshot.data?.length.toString() + ? snapshot.data?.contacts.length.toString() : 'n.a.', style: const TextStyle( fontWeight: FontWeight.bold, @@ -267,6 +257,13 @@ class NostrDrawer extends ConsumerWidget { ); } + // Add method to get version + Future _getPackageInfo() async { + PackageInfo packageInfo = await PackageInfo.fromPlatform(); + + return packageInfo; + } + @override Widget build(BuildContext context, WidgetRef ref) { var metadata = ref.watch(metadataProvider); @@ -328,13 +325,46 @@ class NostrDrawer extends ConsumerWidget { Padding( padding: const EdgeInsets.fromLTRB(20, 10, 15, 20), child: _textButton( - text: 'terms of service', + text: 'Terms of Service', onPressed: () { // lauch url Uri url = Uri.parse("https://camelus.app/terms"); launchUrl(url, mode: LaunchMode.externalApplication); })), const Spacer(), + Padding( + padding: EdgeInsets.only(left: 20), + child: FutureBuilder( + future: _getPackageInfo(), + builder: (context, snapshot) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'v${snapshot.data?.version}', + style: const TextStyle( + color: Palette.gray, + fontSize: 10, + ), + ), + Text( + 'build ${snapshot.data?.buildNumber}', + style: const TextStyle( + color: Palette.gray, + fontSize: 8, + ), + ), + Text( + '${snapshot.data?.buildSignature}', + style: const TextStyle( + color: Palette.gray, + fontSize: 6, + ), + ), + ], + ); + }), + ), _divider(), Padding( padding: const EdgeInsets.fromLTRB(20, 10, 15, 20), @@ -360,7 +390,7 @@ class NostrDrawer extends ConsumerWidget { ), ], ), - ) + ), ], ), ), diff --git a/lib/presentation_layer/routes/nostr/nostr_page/hashtag_feed_view.dart b/lib/presentation_layer/routes/nostr/nostr_page/hashtag_feed_view.dart new file mode 100644 index 00000000..eedab142 --- /dev/null +++ b/lib/presentation_layer/routes/nostr/nostr_page/hashtag_feed_view.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class HashtagFeedView extends ConsumerStatefulWidget { + final String hashtag; + + const HashtagFeedView({super.key, required this.hashtag}); + + @override + ConsumerState createState() => _HashtagFeedViewState(); +} + +class _HashtagFeedViewState extends ConsumerState { + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Text("HashtagFeedView not implemented"); + } +} diff --git a/lib/presentation_layer/routes/nostr/nostr_page/nostr_page.dart b/lib/presentation_layer/routes/nostr/nostr_page/nostr_page.dart new file mode 100644 index 00000000..6e4a9f1f --- /dev/null +++ b/lib/presentation_layer/routes/nostr/nostr_page/nostr_page.dart @@ -0,0 +1,300 @@ +import 'dart:async'; +import 'dart:math'; +import 'dart:developer' as developer; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:badges/badges.dart' as badges; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../config/palette.dart'; +import '../../../../domain_layer/entities/app_update.dart'; +import '../../../../domain_layer/entities/user_metadata.dart'; +import '../../../../domain_layer/usecases/check_app_update.dart'; +import '../../../atoms/my_profile_picture.dart'; +import '../../../providers/app_update_provider.dart'; +import '../../../providers/metadata_provider.dart'; +import '../relays_page.dart'; +import 'user_feed_and_replies_view.dart'; +import 'user_feed_original_view.dart'; + +class NostrPage extends ConsumerStatefulWidget { + final GlobalKey parentScaffoldKey; + final String pubkey; + + const NostrPage( + {super.key, required this.parentScaffoldKey, required this.pubkey}); + @override + ConsumerState createState() => _NostrPageState(); +} + +class _NostrPageState extends ConsumerState + with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + List followingPubkeys = []; + + final ScrollController _scrollControllerPage = ScrollController(); + + late TabController _tabController; + + void _betaCheckForUpdates() async { + await Future.delayed(const Duration(seconds: 15)); + + final CheckAppUpdate appUpdate = ref.read(appUpdateProvider); + + final AppUpdate updateInfo = await appUpdate.call(); + + if (!updateInfo.isUpdateAvailable) return; + + if (!mounted) return; + + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(updateInfo.title), + content: Text(updateInfo.body), + actions: [ + TextButton( + child: const Text("cancel"), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + TextButton( + child: const Text("update"), + onPressed: () { + var u = Uri.parse(updateInfo.url); + launchUrl(u, mode: LaunchMode.externalApplication); + Navigator.of(context).pop(); + }, + ), + ], + ); + }); + } + + _openRelaysView() { + Navigator.of(context).push( + PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => + const RelaysPage(), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + var begin = const Offset(0.0, 0.2); + var end = Offset.zero; + var curve = Curves.linear; + + var tween = + Tween(begin: begin, end: end).chain(CurveTween(curve: curve)); + + return ScaleTransition( + alignment: Alignment.topRight, + scale: animation, + child: SlideTransition( + position: animation.drive(tween), + child: child, + ), + ); + }, + ), + ); + } + + @override + void initState() { + super.initState(); + + _betaCheckForUpdates(); + + _tabController = TabController(length: 2, vsync: this); + + _tabController.addListener(() { + if (_tabController.indexIsChanging) {} + }); + + _tabController.animation?.addListener(() { + if ((_tabController.offset >= 1 || _tabController.offset <= -1)) { + //log("animation tab changed to ${_tabController.index}"); + } + if ((_tabController.offset >= 0.5 || _tabController.offset <= -0.5)) { + //log("0,5###: ${_tabController.index}"); + } + }); + + _scrollControllerPage.addListener(() { + //log("scrolling page"); + }); + } + + @override + void dispose() { + _scrollControllerPage.dispose(); + + _tabController.dispose(); + + super.dispose(); + } + + Color getRandomColor() { + return Color(0xff000000 | (Random().nextInt(0xFFFFFF) + 1)); + } + + @override + Widget build(BuildContext context) { + super.build(context); + var metadata = ref.watch(metadataProvider); + + return SafeArea( + child: NestedScrollView( + floatHeaderSlivers: true, + controller: _scrollControllerPage, + headerSliverBuilder: (context, innerBoxIsScrolled) { + return [ + SliverAppBar( + floating: true, + snap: false, + pinned: false, + forceElevated: true, + backgroundColor: Palette.background, + leadingWidth: 52, + leading: InkWell( + onTap: () => + widget.parentScaffoldKey.currentState!.openDrawer(), + child: Container( + margin: const EdgeInsets.fromLTRB(10, 10, 10, 10), + decoration: const BoxDecoration( + color: Palette.primary, + shape: BoxShape.circle, + ), + child: StreamBuilder( + stream: metadata.getMetadataByPubkey(widget.pubkey), + builder: (BuildContext context, + AsyncSnapshot snapshot) { + return UserImage( + imageUrl: snapshot.data?.picture, // can be null + pubkey: widget.pubkey, + ); + }), + ), + ), + centerTitle: true, + title: GestureDetector( + onTap: () => {}, + child: badges.Badge( + badgeAnimation: const badges.BadgeAnimation.fade(), + showBadge: false, + badgeContent: const Text( + "", + style: TextStyle(color: Colors.white), + ), + child: GestureDetector( + onTap: () {}, + child: const Text( + "camelus", + style: TextStyle( + letterSpacing: 1.2, + color: Palette.lightGray, + fontSize: 20, + fontWeight: FontWeight.normal, + fontFamily: "Poppins", + ), + ), + )), + ), + actions: [ + GestureDetector( + onTap: () => _openRelaysView(), + child: StreamBuilder>( + stream: Stream.empty(), // todo: implement get relays + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return Row( + children: [ + SvgPicture.asset( + 'assets/icons/cell-signal-slash.svg', + color: Palette.gray, + height: 22, + width: 22, + ), + const SizedBox(width: 5), + if (!kReleaseMode) + Text( + "0".toString(), + style: + const TextStyle(color: Palette.lightGray), + ), + const SizedBox(width: 5), + ], + ); + } else { + return Row( + children: [ + SvgPicture.asset( + 'assets/icons/cell-signal-full.svg', + color: Palette.gray, + height: 22, + width: 22, + ), + const SizedBox(width: 5), + // check if dev build + if (!kReleaseMode) + Text( + // count how many relays are ready + "0", + style: + const TextStyle(color: Palette.lightGray), + ), + const SizedBox(width: 5), + ], + ); + } + }), + ), + ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(20), + child: TabBar( + controller: _tabController, + indicatorColor: Palette.primary, + indicatorSize: TabBarIndicatorSize.label, + automaticIndicatorColorAdjustment: true, + indicator: const UnderlineTabIndicator( + borderRadius: BorderRadius.all(Radius.circular(10)), + borderSide: BorderSide( + width: 2, + color: Palette.primary, + ), + ), + indicatorWeight: 5.0, + tabs: const [ + Text("feed", style: TextStyle(color: Palette.lightGray)), + Text("feed & replies", + style: TextStyle(color: Palette.lightGray)), + ], + ), + ), + ), + ]; + }, + body: TabBarView( + controller: _tabController, + physics: const BouncingScrollPhysics(), + children: [ + UserFeedOriginalView( + pubkey: widget.pubkey, + scrollControllerFeed: _scrollControllerPage, + ), + UserFeedAndRepliesView( + pubkey: widget.pubkey, + scrollControllerFeed: _scrollControllerPage, + ), + ], + ), + ), + ); + } +} diff --git a/lib/routes/nostr/nostr_page/perspective_feed_page.dart b/lib/presentation_layer/routes/nostr/nostr_page/perspective_feed_page.dart similarity index 68% rename from lib/routes/nostr/nostr_page/perspective_feed_page.dart rename to lib/presentation_layer/routes/nostr/nostr_page/perspective_feed_page.dart index 2fe8d7bf..a7842001 100644 --- a/lib/routes/nostr/nostr_page/perspective_feed_page.dart +++ b/lib/presentation_layer/routes/nostr/nostr_page/perspective_feed_page.dart @@ -1,15 +1,16 @@ import 'package:camelus/config/palette.dart'; -import 'package:camelus/providers/metadata_provider.dart'; -import 'package:camelus/routes/nostr/nostr_page/user_feed_original_view.dart'; -import 'package:camelus/services/nostr/metadata/user_metadata.dart'; +import 'package:camelus/domain_layer/usecases/get_user_metadata.dart'; +import 'package:camelus/presentation_layer/providers/metadata_provider.dart'; +import 'package:camelus/presentation_layer/routes/nostr/nostr_page/user_feed_original_view.dart'; + import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:matomo_tracker/matomo_tracker.dart'; class PerspectiveFeedPage extends ConsumerStatefulWidget { - late String pubkey; + final String pubkey; - PerspectiveFeedPage({Key? key, required this.pubkey}) : super(key: key); + const PerspectiveFeedPage({super.key, required this.pubkey}); @override ConsumerState createState() => @@ -18,7 +19,7 @@ class PerspectiveFeedPage extends ConsumerStatefulWidget { class _PerspectiveFeedPageState extends ConsumerState with TraceableClientMixin { - late UserMetadata _metadata; + late GetUserMetadata _metadata; @override String get traceName => 'Created PerspectiveFeedPage'; // optional @@ -35,17 +36,12 @@ class _PerspectiveFeedPageState extends ConsumerState _initNostrService(); } - @override - void dispose() { - super.dispose(); - } - @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: FutureBuilder( - future: _metadata.getMetadataByPubkey(widget.pubkey), + title: StreamBuilder( + stream: _metadata.getMetadataByPubkey(widget.pubkey), builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.hasData) { return Text("perspective of ${snapshot.data['name']}"); @@ -57,7 +53,10 @@ class _PerspectiveFeedPageState extends ConsumerState backgroundColor: Palette.background, ), backgroundColor: Palette.background, - body: UserFeedOriginalView(pubkey: widget.pubkey), + body: UserFeedOriginalView( + pubkey: widget.pubkey, + scrollControllerFeed: ScrollController(), + ), ); } } diff --git a/lib/presentation_layer/routes/nostr/nostr_page/profile_feed_root_view.dart b/lib/presentation_layer/routes/nostr/nostr_page/profile_feed_root_view.dart new file mode 100644 index 00000000..851f1bdf --- /dev/null +++ b/lib/presentation_layer/routes/nostr/nostr_page/profile_feed_root_view.dart @@ -0,0 +1,157 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../../domain_layer/entities/nostr_note.dart'; +import '../../../atoms/new_posts_available.dart'; +import '../../../atoms/refresh_indicator_no_need.dart'; +import '../../../components/note_card/note_card_container.dart'; +import '../../../components/note_card/sceleton_note.dart'; +import '../../../providers/navigation_bar_provider.dart'; +import '../../../providers/profile_feed_provider.dart'; + +class ProfileFeedRootView extends ConsumerStatefulWidget { + final String pubkey; + + final ScrollController scrollControllerFeed; + + // attaches from outside, used for scroll animation + const ProfileFeedRootView({ + super.key, + required this.pubkey, + required this.scrollControllerFeed, + }); + + @override + ConsumerState createState() => + _ProfileFeedRootViewState(); +} + +class _ProfileFeedRootViewState extends ConsumerState { + final List _subscriptions = []; + + NostrNote get latestNote => + ref.watch(profileFeedStateProvider(widget.pubkey)).timelineRootNotes.last; + + _loadMore() { + if (ref + .watch(profileFeedStateProvider(widget.pubkey)) + .timelineRootNotes + .length < + 2) return; + log("_loadMore()"); + final mainFeedProvider = ref.read(profileFeedProvider); + mainFeedProvider.loadMore( + oltherThen: latestNote.created_at, + pubkey: widget.pubkey, + ); + } + + void _setupNavBarHomeListener() { + var provider = ref.read(navigationBarProvider); + _subscriptions.add(provider.onTabHome.listen((event) { + _handleHomeBarTab(); + })); + } + + void _handleHomeBarTab() { + final newNotesLenth = + ref.watch(profileFeedStateProvider(widget.pubkey)).newRootNotes.length; + if (newNotesLenth > 0) { + _integrateNewNotes(); + return; + } + ref.watch(navigationBarProvider).resetNewNotesCount(); + // scroll to top + widget.scrollControllerFeed.animateTo( + widget.scrollControllerFeed.position.minScrollExtent, + duration: const Duration(milliseconds: 500), + curve: Curves.easeOutCubic, + ); + } + + void _integrateNewNotes() { + final newNotesP = ref.watch(profileFeedStateProvider(widget.pubkey)); + + final notesToIntegrate = newNotesP; + ref + .watch(profileFeedProvider) + .integrateRootNotes(notesToIntegrate.newRootNotes); + + // delte new notes in FeedNew + newNotesP.newRootNotes.clear(); + + ref.watch(navigationBarProvider).resetNewNotesCount(); + + widget.scrollControllerFeed.animateTo( + widget.scrollControllerFeed.position.minScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + + @override + void initState() { + super.initState(); + _setupNavBarHomeListener(); + } + + @override + void dispose() { + _disposeSubscriptions(); + + super.dispose(); + } + + void _disposeSubscriptions() { + for (var s in _subscriptions) { + s.cancel(); + } + } + + @override + Widget build(BuildContext context) { + final mainFeedStateP = ref.watch(profileFeedStateProvider(widget.pubkey)); + + ref.watch(navigationBarProvider).newNotesCount = + mainFeedStateP.newRootNotes.length; + + return Stack( + children: [ + refreshIndicatorNoNeed( + onRefresh: () { + return Future.delayed(const Duration(milliseconds: 0)); + }, + child: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == mainFeedStateP.timelineRootNotes.length) { + return SkeletonNote(renderCallback: _loadMore()); + } + + final event = mainFeedStateP.timelineRootNotes[index]; + + return NoteCardContainer( + key: PageStorageKey(event.id), + note: event, + ); + }, + childCount: mainFeedStateP.timelineRootNotes.length + 1, + ), + )), + if (mainFeedStateP.newRootNotes.isNotEmpty) + Container( + margin: const EdgeInsets.only(top: 20), + child: newPostsAvailable( + name: "${mainFeedStateP.newRootNotes.length} new posts", + onPressed: () { + _integrateNewNotes(); + }), + ), + ], + ); + } +} diff --git a/lib/presentation_layer/routes/nostr/nostr_page/user_feed_and_replies_view.dart b/lib/presentation_layer/routes/nostr/nostr_page/user_feed_and_replies_view.dart new file mode 100644 index 00000000..72a9309f --- /dev/null +++ b/lib/presentation_layer/routes/nostr/nostr_page/user_feed_and_replies_view.dart @@ -0,0 +1,154 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../../config/palette.dart'; +import '../../../../domain_layer/entities/nostr_note.dart'; +import '../../../atoms/new_posts_available.dart'; +import '../../../atoms/refresh_indicator_no_need.dart'; +import '../../../components/note_card/note_card_container.dart'; +import '../../../components/note_card/sceleton_note.dart'; +import '../../../providers/main_feed_provider.dart'; +import '../../../providers/navigation_bar_provider.dart'; + +class UserFeedAndRepliesView extends ConsumerStatefulWidget { + final String pubkey; + final ScrollController scrollControllerFeed; + + const UserFeedAndRepliesView({ + super.key, + required this.pubkey, + required this.scrollControllerFeed, + }); + + @override + UserFeedAndRepliesViewState createState() => UserFeedAndRepliesViewState(); +} + +class UserFeedAndRepliesViewState + extends ConsumerState { + final List _subscriptions = []; + + NostrNote get latestNote => ref + .watch(mainFeedStateProvider(widget.pubkey)) + .timelineRootAndReplyNotes + .last; + + _loadMore() { + if (ref + .watch(mainFeedStateProvider(widget.pubkey)) + .timelineRootNotes + .length < + 2) return; + log("_loadMore()"); + final mainFeedProvider = ref.read(getMainFeedProvider); + mainFeedProvider.loadMore( + oltherThen: latestNote.created_at, + pubkey: widget.pubkey, + ); + } + + void _setupNavBarHomeListener() { + var provider = ref.read(navigationBarProvider); + _subscriptions.add(provider.onTabHome.listen((event) { + _handleHomeBarTab(); + })); + } + + void _handleHomeBarTab() { + final newNotesLenth = + ref.watch(mainFeedStateProvider(widget.pubkey)).newRootNotes.length; + if (newNotesLenth > 0) { + _integrateNewNotes(); + return; + } + ref.watch(navigationBarProvider).resetNewNotesCount(); + // scroll to top + widget.scrollControllerFeed.animateTo( + widget.scrollControllerFeed.position.minScrollExtent, + duration: const Duration(milliseconds: 500), + curve: Curves.easeOutCubic, + ); + } + + void _integrateNewNotes() { + final newNotesP = ref.watch(mainFeedStateProvider(widget.pubkey)); + + final notesToIntegrate = newNotesP; + ref + .watch(getMainFeedProvider) + .integrateRootAndReplyNotes(notesToIntegrate.newRootAndReplyNotes); + + // delte new notes in FeedNew + newNotesP.newRootAndReplyNotes.clear(); + + ref.watch(navigationBarProvider).resetNewNotesCount(); + + widget.scrollControllerFeed.animateTo( + widget.scrollControllerFeed.position.minScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + + @override + void initState() { + _setupNavBarHomeListener(); + super.initState(); + } + + @override + void dispose() { + for (var s in _subscriptions) { + s.cancel(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final mainFeedStateP = ref.watch(mainFeedStateProvider(widget.pubkey)); + + ref.watch(navigationBarProvider).newNotesCount = + mainFeedStateP.newRootNotes.length; + return Stack( + children: [ + refreshIndicatorNoNeed( + onRefresh: () { + return Future.delayed(const Duration(milliseconds: 0)); + }, + child: Container( + color: Palette.black, + child: ListView.builder( + controller: PrimaryScrollController.of(context), + itemCount: mainFeedStateP.timelineRootAndReplyNotes.length + 1, + itemBuilder: (context, index) { + if (index == mainFeedStateP.timelineRootAndReplyNotes.length) { + return SkeletonNote(renderCallback: _loadMore()); + } + + final event = mainFeedStateP.timelineRootAndReplyNotes[index]; + + return NoteCardContainer( + key: PageStorageKey(event.id), + note: event, + ); + }, + ), + ), + ), + if (mainFeedStateP.newRootAndReplyNotes.isNotEmpty) + Container( + margin: const EdgeInsets.only(top: 20), + child: newPostsAvailable( + name: "${mainFeedStateP.newRootAndReplyNotes.length} new posts", + onPressed: () { + _integrateNewNotes(); + }), + ), + ], + ); + } +} diff --git a/lib/presentation_layer/routes/nostr/nostr_page/user_feed_original_view.dart b/lib/presentation_layer/routes/nostr/nostr_page/user_feed_original_view.dart new file mode 100644 index 00000000..cf53297e --- /dev/null +++ b/lib/presentation_layer/routes/nostr/nostr_page/user_feed_original_view.dart @@ -0,0 +1,160 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../../config/palette.dart'; +import '../../../../domain_layer/entities/nostr_note.dart'; +import '../../../atoms/new_posts_available.dart'; +import '../../../atoms/refresh_indicator_no_need.dart'; +import '../../../components/note_card/note_card_container.dart'; +import '../../../components/note_card/sceleton_note.dart'; +import '../../../providers/main_feed_provider.dart'; +import '../../../providers/navigation_bar_provider.dart'; + +class UserFeedOriginalView extends ConsumerStatefulWidget { + final String pubkey; + + final ScrollController scrollControllerFeed; + + // attaches from outside, used for scroll animation + const UserFeedOriginalView({ + super.key, + required this.pubkey, + required this.scrollControllerFeed, + }); + + @override + ConsumerState createState() => + _UserFeedOriginalViewState(); +} + +class _UserFeedOriginalViewState extends ConsumerState { + final List _subscriptions = []; + + NostrNote get latestNote => + ref.watch(mainFeedStateProvider(widget.pubkey)).timelineRootNotes.last; + + _loadMore() { + if (ref + .watch(mainFeedStateProvider(widget.pubkey)) + .timelineRootNotes + .length < + 2) return; + log("_loadMore()"); + final mainFeedProvider = ref.read(getMainFeedProvider); + mainFeedProvider.loadMore( + oltherThen: latestNote.created_at, + pubkey: widget.pubkey, + ); + } + + void _setupNavBarHomeListener() { + var provider = ref.read(navigationBarProvider); + _subscriptions.add(provider.onTabHome.listen((event) { + _handleHomeBarTab(); + })); + } + + void _handleHomeBarTab() { + final newNotesLenth = + ref.watch(mainFeedStateProvider(widget.pubkey)).newRootNotes.length; + if (newNotesLenth > 0) { + _integrateNewNotes(); + return; + } + ref.watch(navigationBarProvider).resetNewNotesCount(); + // scroll to top + widget.scrollControllerFeed.animateTo( + widget.scrollControllerFeed.position.minScrollExtent, + duration: const Duration(milliseconds: 500), + curve: Curves.easeOutCubic, + ); + } + + void _integrateNewNotes() { + final newNotesP = ref.watch(mainFeedStateProvider(widget.pubkey)); + + final notesToIntegrate = newNotesP; + ref + .watch(getMainFeedProvider) + .integrateRootNotes(notesToIntegrate.newRootNotes); + + // delte new notes in FeedNew + newNotesP.newRootNotes.clear(); + + ref.watch(navigationBarProvider).resetNewNotesCount(); + + widget.scrollControllerFeed.animateTo( + widget.scrollControllerFeed.position.minScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + + @override + void initState() { + super.initState(); + _setupNavBarHomeListener(); + } + + @override + void dispose() { + _disposeSubscriptions(); + + super.dispose(); + } + + void _disposeSubscriptions() { + for (var s in _subscriptions) { + s.cancel(); + } + } + + @override + Widget build(BuildContext context) { + final mainFeedStateP = ref.watch(mainFeedStateProvider(widget.pubkey)); + + ref.watch(navigationBarProvider).newNotesCount = + mainFeedStateP.newRootNotes.length; + + return Stack( + children: [ + refreshIndicatorNoNeed( + onRefresh: () { + return Future.delayed(const Duration(milliseconds: 0)); + }, + child: Container( + color: Palette.black, + child: ListView.builder( + controller: PrimaryScrollController.of(context), + itemCount: mainFeedStateP.timelineRootNotes.length + 1, + itemBuilder: (context, index) { + if (index == mainFeedStateP.timelineRootNotes.length) { + return SkeletonNote(renderCallback: _loadMore()); + } + + final event = mainFeedStateP.timelineRootNotes[index]; + + return NoteCardContainer( + key: PageStorageKey(event.id), + note: event, + ); + }, + ), + ), + ), + if (mainFeedStateP.newRootNotes.isNotEmpty) + Container( + margin: const EdgeInsets.only(top: 20), + child: newPostsAvailable( + name: "${mainFeedStateP.newRootNotes.length} new posts", + onPressed: () { + _integrateNewNotes(); + }), + ), + ], + ); + } +} diff --git a/lib/presentation_layer/routes/nostr/onboarding/onboarding.dart b/lib/presentation_layer/routes/nostr/onboarding/onboarding.dart new file mode 100644 index 00000000..1432d37a --- /dev/null +++ b/lib/presentation_layer/routes/nostr/onboarding/onboarding.dart @@ -0,0 +1,190 @@ +import 'package:camelus/presentation_layer/providers/onboarding_provider.dart'; +import 'package:camelus/presentation_layer/routes/nostr/onboarding/onboarding_done.dart'; +import 'package:camelus/presentation_layer/routes/nostr/onboarding/onboarding_follow_graph/onboarding_follow_graph.dart'; +import 'package:camelus/presentation_layer/routes/nostr/onboarding/onboarding_login.dart'; +import 'package:camelus/presentation_layer/routes/nostr/onboarding/onboarding_login_amber.dart'; +import 'package:camelus/presentation_layer/routes/nostr/onboarding/onboarding_name.dart'; +import 'package:camelus/presentation_layer/routes/nostr/onboarding/onboarding_page01.dart'; +import 'package:camelus/presentation_layer/routes/nostr/onboarding/onboarding_picture.dart'; +import 'package:camelus/presentation_layer/routes/nostr/onboarding/onboarding_profile.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'onboarding_login_select.dart'; + +class NostrOnboarding extends ConsumerStatefulWidget { + const NostrOnboarding({super.key}); + + @override + ConsumerState createState() => _NostrOnboardingState(); +} + +class _NostrOnboardingState extends ConsumerState + with TickerProviderStateMixin { + late TabController _tabController; + late TabController _loginTabController; + + final PageController _horizontalPageController = PageController( + initialPage: 1, + keepPage: true, + ); + bool horizontalScrollLock = false; + bool pageLock = false; + + bool pageLockLogin = false; + + void _setupTabLiseners() { + // listen to changes of tabs + _tabController.addListener(() { + if (_tabController.index >= 1) { + setState(() { + horizontalScrollLock = true; + }); + } else { + setState(() { + horizontalScrollLock = false; + }); + } + + if (_tabController.index == 4 || _tabController.index == 5) { + setState(() { + pageLock = true; + }); + } else { + setState(() { + pageLock = false; + }); + } + if (_tabController.index != 1) { + FocusScope.of(context).unfocus(); + } + }); + + _loginTabController.addListener(() { + if (_loginTabController.index == 1) { + setState(() { + horizontalScrollLock = true; + }); + } else { + setState(() { + horizontalScrollLock = false; + }); + } + }); + } + + _navigateToLogin() { + _horizontalPageController.animateToPage(0, + duration: const Duration(milliseconds: 500), curve: Curves.easeInOut); + } + + @override + void initState() { + super.initState(); + _tabController = TabController( + length: 6, + initialIndex: 0, + vsync: this, + ); + + _loginTabController = TabController( + length: 3, + initialIndex: 0, + vsync: this, + ); + + _setupTabLiseners(); + } + + _nextTab() { + _tabController.animateTo( + _tabController.index + 1, + curve: Curves.easeInOut, + duration: const Duration(milliseconds: 500), + ); + } + + @override + Widget build(BuildContext context) { + final signUpInfo = ref.watch(onboardingProvider).signUpInfo; + + return SafeArea( + child: PageView( + controller: _horizontalPageController, + scrollDirection: Axis.vertical, + physics: horizontalScrollLock + ? const NeverScrollableScrollPhysics() + : const AlwaysScrollableScrollPhysics(), + children: [ + TabBarView( + controller: _loginTabController, + physics: + pageLockLogin ? const NeverScrollableScrollPhysics() : null, + children: [ + OnboardingLoginSelectPage( + onPressedAmberLogin: () { + _loginTabController.animateTo( + 2, + curve: Curves.easeInOut, + duration: const Duration(milliseconds: 500), + ); + }, + onPressedSeedPhraseLogin: () { + _loginTabController.animateTo( + 1, + curve: Curves.easeInOut, + duration: const Duration(milliseconds: 500), + ); + }, + ), + OnboardingLoginPage(), + OnboardingLoginAmberPage(), + ], + ), + TabBarView( + controller: _tabController, + physics: pageLock ? const NeverScrollableScrollPhysics() : null, + children: [ + OnboardingPage01( + loginCallback: _navigateToLogin, + registerCallback: () { + _tabController.animateTo( + 1, + curve: Curves.easeInOut, + duration: const Duration(milliseconds: 500), + ); + }, + ), + OnboardingName( + userInfo: signUpInfo, + submitCallback: (_) { + _nextTab(); + }, + ), + OnboardingPicture( + pictureCallback: () { + _nextTab(); + }, + signUpInfo: signUpInfo, + ), + OnboardingProfile( + profileCallback: () { + _nextTab(); + }, + signUpInfo: signUpInfo, + ), + OnboardingFollowGraph( + submitCallback: (followPubkeys) { + signUpInfo.followPubkeys = followPubkeys; + _nextTab(); + }, + userInfo: signUpInfo, + ), + OnboardingDone(submitCallback: () {}, userInfo: signUpInfo) + ], + ), + ], + ), + ); + } +} diff --git a/lib/presentation_layer/routes/nostr/onboarding/onboarding_done.dart b/lib/presentation_layer/routes/nostr/onboarding/onboarding_done.dart new file mode 100644 index 00000000..d4e7fb3c --- /dev/null +++ b/lib/presentation_layer/routes/nostr/onboarding/onboarding_done.dart @@ -0,0 +1,357 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:camelus/presentation_layer/components/full_screen_loading.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ndk/ndk.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../config/palette.dart'; +import '../../../../domain_layer/entities/generated_private_key.dart'; +import '../../../../domain_layer/entities/key_pair.dart'; +import '../../../../domain_layer/entities/onboarding_user_info.dart'; +import '../../../../domain_layer/entities/user_metadata.dart'; +import '../../../../domain_layer/usecases/generate_private_key.dart'; +import '../../../atoms/long_button.dart'; +import '../../../atoms/mnemonic_grid.dart'; +import '../../../providers/event_signer_provider.dart'; +import '../../../providers/file_upload_provider.dart'; +import '../../../providers/following_provider.dart'; +import '../../../providers/metadata_provider.dart'; +import '../../home_page.dart'; + +class OnboardingDone extends ConsumerStatefulWidget { + final Function() submitCallback; + + final OnboardingUserInfo userInfo; + + const OnboardingDone({ + super.key, + required this.submitCallback, + required this.userInfo, + }); + @override + ConsumerState createState() => _OnboardingDoneState(); +} + +class _OnboardingDoneState extends ConsumerState { + bool _termsAndConditions = false; + bool _isVisible = false; + bool _isLoading = false; + double _loadingOpacity = 0.0; + + List loadingTexts = [ + "setting up your account", + "following people", + "moving data", + "cleaning up" + ]; + + void _toggleVisibility() { + setState(() { + _isVisible = !_isVisible; + }); + } + + late GeneratedPrivateKey _privateKey; + + void _generateKey() { + setState(() { + _privateKey = GeneratePrivateKey.generateKey(); + }); + } + + void _copyKey() { + final clipData = """ +Public Key: +${_privateKey.publicKeyHr} + + +Private Key: +${_privateKey.privKeyHr} + + +SeedPhrase: +${_privateKey.mnemonicSentence} + """; + + Clipboard.setData(ClipboardData(text: clipData)); + } + + Future _broadcastAcc() async { + String? uploadedPicture; + String? uploadedBanner; + final fileUploadP = ref.watch(fileUploadProvider); + + if (widget.userInfo.picture != null) { + setState(() { + // add to start + loadingTexts.insert(0, "uploading profile picture"); + }); + uploadedPicture = await fileUploadP.uploadImage(widget.userInfo.picture!); + } + if (widget.userInfo.banner != null) { + uploadedBanner = await fileUploadP.uploadImage(widget.userInfo.banner!); + } + + //! todo: broadcast data to nostr network + final metadataP = ref.watch(metadataProvider); + final followP = ref.watch(followingProvider); + + final UserMetadata userMetadata = UserMetadata( + eventId: '', + lastFetch: 0, + pubkey: _privateKey.publicKey, + name: widget.userInfo.name, + picture: uploadedPicture, + banner: uploadedBanner, + about: widget.userInfo.about, + website: widget.userInfo.website, + nip05: widget.userInfo.nip05, + lud06: widget.userInfo.lud06, + lud16: widget.userInfo.lud16, + ); + + metadataP.broadcastMetadata(userMetadata); + followP.setContacts(widget.userInfo.followPubkeys); + } + + @override + void initState() { + super.initState(); + _generateKey(); + } + + @override + void dispose() { + super.dispose(); + } + + _onSubmit() async { + if (!_termsAndConditions) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please read and accept the terms and conditions first', + style: TextStyle(color: Palette.black)), + ), + ); + return; + } + setState(() { + _isLoading = true; + _loadingOpacity = 1.0; + }); + + final myKeyPair = KeyPair( + privateKey: _privateKey.privateKey, + publicKey: _privateKey.publicKey, + privateKeyHr: _privateKey.privKeyHr, + publicKeyHr: _privateKey.publicKeyHr, + ); + + final bip340Signer = Bip340EventSigner( + privateKey: myKeyPair.privateKey, + publicKey: myKeyPair.publicKey, + ); + + ref.read(eventSignerProvider.notifier).setSigner(bip340Signer); + + // save in storage + const storage = FlutterSecureStorage(); + await storage.write( + key: "nostrKeys", value: json.encode(myKeyPair.toJson())); + + await _broadcastAcc(); + + await Future.delayed(const Duration(seconds: 3)); + + if (!mounted) return; + + // naviage to / + Navigator.pushReplacement(context, MaterialPageRoute(builder: (context) { + return HomePage(pubkey: myKeyPair.publicKey); + })); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Palette.background, + body: Stack( + children: [ + AnimatedOpacity( + duration: const Duration(seconds: 3), + opacity: max(1 - _loadingOpacity, 0.07), + curve: Curves.easeOut, + child: Column( + children: [ + const SizedBox(height: 20), + const Text( + "recovery phrase", + style: TextStyle( + color: Palette.white, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 25), + MnemonicSentenceGrid( + words: _privateKey.mnemonicWords, + isVisible: _isVisible, + ), + const SizedBox(height: 25), + Padding( + padding: const EdgeInsets.all(10.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Palette.black, + foregroundColor: Palette.white, + ), + onPressed: () => { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + duration: Duration(seconds: 2), + content: Text( + 'a new seed phrase has been generated', + style: TextStyle(color: Palette.black)), + ), + ), + _generateKey() + }, + icon: const Icon(Icons.refresh), + label: const Text('regenerate'), + ), + const SizedBox(width: 5), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Palette.lightGray, + foregroundColor: Palette.black, + ), + onPressed: () => { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + duration: Duration(seconds: 2), + content: Text('copied seed phrase to clipboard', + style: TextStyle(color: Palette.black)), + ), + ), + _copyKey() + }, + icon: const Icon(Icons.copy), + label: const Text('copy'), + ), + const SizedBox(width: 5), + IconButton( + onPressed: _toggleVisibility, + icon: Icon(_isVisible + ? Icons.visibility + : Icons.visibility_off), + tooltip: _isVisible ? 'Hide words' : 'Show words', + ), + ], + ), + ), + const Padding( + padding: EdgeInsets.all(16), + child: Text( + "You need the recovery phrase to login again. Make sure to keep it safe!"), + ), + const Spacer(flex: 1), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Checkbox( + value: _termsAndConditions, + onChanged: (value) { + setState(() { + _termsAndConditions = value!; + }); + }, + activeColor: Palette.white, + checkColor: Palette.black, + fillColor: WidgetStateProperty.all(Palette.white), + //overlayColor: MaterialStateProperty.all(Palette.primary), + ), + const Text( + "I have read and accept the ", + style: TextStyle( + color: Palette.white, + fontSize: 12, + fontWeight: FontWeight.normal, + ), + ), + GestureDetector( + onTap: () { + Uri url = Uri.parse("https://camelus.app/terms/"); + launchUrl(url, mode: LaunchMode.externalApplication); + }, + child: const Text( + "terms and conditions", + style: TextStyle( + color: Palette.white, + fontSize: 12, + fontWeight: FontWeight.bold, + decoration: TextDecoration.underline, + ), + ), + ), + ], + ), + GestureDetector( + onTap: () { + Uri url = Uri.parse("https://camelus.app/privacy/"); + launchUrl(url, mode: LaunchMode.externalApplication); + }, + child: const Text( + "privacy policy", + style: TextStyle( + color: Palette.white, + fontSize: 12, + fontWeight: FontWeight.bold, + decoration: TextDecoration.underline, + ), + ), + ), + const SizedBox(height: 50), + Container( + padding: const EdgeInsets.symmetric(horizontal: 20), + width: 400, + height: 40, + child: longButton( + name: "publish account", + inverted: true, + onPressed: () => _onSubmit(), + ), + ), + const SizedBox(height: 20), + ], + ), + ), + if (_isLoading) + AnimatedOpacity( + opacity: _loadingOpacity, + curve: Curves.easeInOut, + duration: const Duration(seconds: 2), // Adjust duration as needed + child: _isLoading + ? Container( + //color: Colors.black.withOpacity(0.5), + child: Center( + child: FullScreenLoading( + loadingTexts: loadingTexts, + updateState: (function) => {}, + ), + ), + ) + : const SizedBox.shrink(), + ), + ], + ), + ); + } +} diff --git a/lib/presentation_layer/routes/nostr/onboarding/onboarding_follow_graph/graph_node_data.dart b/lib/presentation_layer/routes/nostr/onboarding/onboarding_follow_graph/graph_node_data.dart new file mode 100644 index 00000000..b85310bf --- /dev/null +++ b/lib/presentation_layer/routes/nostr/onboarding/onboarding_follow_graph/graph_node_data.dart @@ -0,0 +1,31 @@ +import '../../../../../domain_layer/entities/contact_list.dart'; +import '../../../../../domain_layer/entities/user_metadata.dart'; + +class GraphNodeData { + final String pubkey; + final UserMetadata userMetadata; + final ContactList contactList; + bool selected; + + GraphNodeData({ + required this.pubkey, + required this.userMetadata, + required this.contactList, + this.selected = false, + }); + + @override + String toString() { + return pubkey; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is GraphNodeData && other.pubkey == pubkey; + } + + @override + int get hashCode => pubkey.hashCode; +} diff --git a/lib/presentation_layer/routes/nostr/onboarding/onboarding_follow_graph/graph_profile.dart b/lib/presentation_layer/routes/nostr/onboarding/onboarding_follow_graph/graph_profile.dart new file mode 100644 index 00000000..21336997 --- /dev/null +++ b/lib/presentation_layer/routes/nostr/onboarding/onboarding_follow_graph/graph_profile.dart @@ -0,0 +1,63 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../../domain_layer/entities/user_metadata.dart'; +import '../../../../atoms/my_profile_picture.dart'; + +class GraphProfile extends StatelessWidget { + final UserMetadata _userMetadata; + + const GraphProfile({ + super.key, + required UserMetadata metadata, + }) : _userMetadata = metadata; + + @override + build(context) { + return Row( + children: [ + const SizedBox( + height: 2, + width: 2, + ), + UserImage( + imageUrl: _userMetadata.picture, + pubkey: _userMetadata.pubkey, + filterQuality: FilterQuality.low, + disableGif: true, + ), + const SizedBox( + width: 10, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 170, // Set your desired max width + child: Text( + _userMetadata.name ?? _userMetadata.pubkey, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox( + height: 5, + ), + SizedBox( + width: 170, // Set your desired max width + child: Text( + _userMetadata.nip05 ?? '', + style: const TextStyle( + fontSize: 12, + ), + overflow: TextOverflow.ellipsis, + ), + ) + ], + ) + ], + ); + } +} diff --git a/lib/presentation_layer/routes/nostr/onboarding/onboarding_follow_graph/onboarding_follow_graph.dart b/lib/presentation_layer/routes/nostr/onboarding/onboarding_follow_graph/onboarding_follow_graph.dart new file mode 100644 index 00000000..08456505 --- /dev/null +++ b/lib/presentation_layer/routes/nostr/onboarding/onboarding_follow_graph/onboarding_follow_graph.dart @@ -0,0 +1,328 @@ +import 'dart:math'; + +import 'package:camelus/presentation_layer/atoms/long_button.dart'; +import 'package:camelus/config/palette.dart'; +import 'package:camelus/domain_layer/entities/onboarding_user_info.dart'; +import 'package:camelus/presentation_layer/atoms/my_profile_picture.dart'; +import 'package:camelus/presentation_layer/providers/following_provider.dart'; +import 'package:camelus/presentation_layer/providers/metadata_provider.dart'; +import 'package:camelus/presentation_layer/routes/nostr/onboarding/onboarding_follow_graph/graph_profile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_force_directed_graph/flutter_force_directed_graph.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'graph_node_data.dart'; + +class OnboardingFollowGraph extends ConsumerStatefulWidget { + final Function(List) submitCallback; + + final OnboardingUserInfo userInfo; + + const OnboardingFollowGraph({ + super.key, + required this.submitCallback, + required this.userInfo, + }); + @override + ConsumerState createState() => + _OnboardingFollowGraphState(); +} + +class _OnboardingFollowGraphState extends ConsumerState { + bool _loading = true; + + // npubs in hex + final List recommendations = [ + '717ff238f888273f5d5ee477097f2b398921503769303a0c518d06a952f2a75e', + '84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240', + '30782a8323b7c98b172c5a2af7206bb8283c655be6ddce11133611a03d5f1177', + '76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa' + ]; + + final followedList = []; + final int followTarget = 5; + + late final ForceDirectedGraphController _graphController = + ForceDirectedGraphController( + graph: ForceDirectedGraph( + config: const GraphConfig( + length: 200, + elasticity: 0.5, + // maxStaticFriction: 20, + repulsionRange: 250, + repulsion: 70, + )), + )..setOnScaleChange((scale) { + // can use to optimize the performance + // if scale is too small, can use simple node and edge builder to improve performance + if (!mounted) return; + setState(() { + _scale = scale; + }); + }); + + final Set _nodes = {}; + final Map _edges = {}; + double _scale = 1.0; + int _locatedTo = 0; + GraphNodeData? _draggingData; + + /// if addedBy Pubkey drawas a edge to the old and new node + addNode(GraphNodeData data, {String? addedByPubkey}) async { + // add data + + _graphController.addNode(data); + + if (addedByPubkey != null) { + try { + final rootNode = _graphController.graph.nodes + .firstWhere((data) => data.data.pubkey == addedByPubkey); + + _graphController.addEdgeByData(data, rootNode.data); + _edges[data.pubkey] = rootNode.data.pubkey; + } catch (_) {} + } + + _nodes.add(data); + } + + /// adds all the contacts (with cutoff) from a given pubkey (from node) + addContactsOfPubkey(String pubkey, {int cutoff = 3}) async { + final List contacts = + _nodes.firstWhere((n) => n.pubkey == pubkey).contactList.contacts; + + for (int i = 0; i < contacts.length; i++) { + if (i > cutoff) { + break; + } + + // skip if node already exists + if (_nodes.any((n) => n.pubkey == contacts[i])) { + continue; + } + + // add node to graph + + await addPubkeyNode(contacts[i], addedByPubkey: pubkey); + } + } + + /// removes the subtree + removeAncestors(String pubkey) { + final pubkeyNode = _nodes.firstWhere((n) => n.pubkey == pubkey); + + final List ancestors = []; + + // delete by traversing through the subgraph using a for loop in _edges + for (final edge in _edges.entries) { + if (edge.value == pubkey) { + ancestors.add(_nodes.firstWhere((n) => n.pubkey == edge.key)); + } + } + + // delete all edges and nodes + // delete the edges first, then the nodes + // delete the edges in reverse order to avoid deleting edges that are not in the graph anymore + ancestors.sort((a, b) => b.pubkey.compareTo(a.pubkey)); + + for (final ancestorNode in ancestors) { + if (ancestorNode.selected) continue; + _graphController.deleteEdgeByData(pubkeyNode, ancestorNode); + + _graphController.deleteNodeByData(ancestorNode); + _nodes.remove(ancestorNode); + } + } + + /// fetches and adds a node to the graph + addPubkeyNode(String pubkey, {String? addedByPubkey}) async { + final mynode = await _fetchNodePubkeyData(pubkey); + addNode(mynode, addedByPubkey: addedByPubkey); + } + + _addRecommendations() async { + final List recommendationsNodes = []; + + for (final pubkey in recommendations) { + final GraphNodeData mynode = await _fetchNodePubkeyData(pubkey); + + recommendationsNodes.add(mynode); + } + for (final node in recommendationsNodes) { + addNode(node); + } + setState(() { + _loading = false; + }); + } + + Future _fetchNodePubkeyData(String pubkey) async { + final metadataP = ref.watch(metadataProvider); + final followP = ref.watch(followingProvider); + + final metadata = + (await metadataP.getMetadataByPubkey(pubkey).toList()).first; + final followInfo = await followP.getContacts(pubkey); + + if (followInfo == null) { + throw Exception("followInfo is null"); + } + + final GraphNodeData mynode = GraphNodeData( + pubkey: pubkey, + userMetadata: metadata, + contactList: followInfo, + ); + return mynode; + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _graphController.needUpdate(); + _addRecommendations(); + }); + } + + @override + void dispose() { + _graphController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + const double miniViewCutoff = 0.35; + return Scaffold( + backgroundColor: Palette.background, + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: ForceDirectedGraphWidget( + controller: _graphController, + onDraggingStart: (data) { + setState(() { + _draggingData = data; + }); + }, + onDraggingEnd: (data) { + setState(() { + _draggingData = null; + }); + }, + onDraggingUpdate: (data) {}, + nodesBuilder: (context, data) { + final Color color; + + if (_draggingData == data) { + color = Colors.yellow; + } else if (_nodes.contains(data)) { + color = Colors.green; + } else { + color = Colors.red; + } + + return GestureDetector( + key: ValueKey(data.pubkey), + onTap: () { + setState(() { + data.selected = !data.selected; + if (data.selected) { + followedList.add(data.pubkey); + addContactsOfPubkey(data.pubkey, cutoff: 3); + } else { + followedList.remove(data.pubkey); + removeAncestors(data.pubkey); + } + }); + }, + child: AnimatedContainer( + width: _scale > miniViewCutoff ? 250 : 60, + height: _scale > miniViewCutoff ? 84 : 60, + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + decoration: BoxDecoration( + color: Palette.extraDarkGray, + border: Border.all( + color: data.selected + ? Colors.white + : Colors.transparent, + width: 2, + ), + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: _scale > miniViewCutoff + ? GraphProfile(metadata: data.userMetadata) + : UserImage( + imageUrl: data.userMetadata.picture, + pubkey: data.userMetadata.pubkey, + filterQuality: FilterQuality.low, + disableGif: true, + )), + ); + }, + edgesBuilder: (context, a, b, distance) { + final Color color; + + return GestureDetector( + onTap: () { + final edge = "$a <-> $b"; + setState(() { + print("onTap $a <-$distance-> $b"); + }); + }, + child: Container( + width: distance, + height: 2, + color: Palette.darkGray, + alignment: Alignment.center, + child: _scale > 0.5 + ? Text( + '${a.userMetadata.name} <-> ${b.userMetadata.name}') + : null, + ), + ); + }, + ), + ), + const SizedBox( + height: 10, + ), + Slider( + inactiveColor: Palette.extraDarkGray, + activeColor: Palette.lightGray, + value: _scale, + min: _graphController.minScale, + max: 1.0, + onChanged: (value) { + _graphController.scale = value; + }, + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 20), + width: 400, + height: 40, + child: longButton( + loading: _loading, + disabled: followedList.length < followTarget, + name: "follow ${followedList.length}/$followTarget", + onPressed: (() { + widget.submitCallback(followedList); + }), + inverted: true, + ), + ), + const SizedBox( + height: 15, + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation_layer/routes/nostr/onboarding/onboarding_image.dart b/lib/presentation_layer/routes/nostr/onboarding/onboarding_image.dart new file mode 100644 index 00000000..192751ed --- /dev/null +++ b/lib/presentation_layer/routes/nostr/onboarding/onboarding_image.dart @@ -0,0 +1,49 @@ +import 'dart:io'; + +import 'package:camelus/config/palette.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class OnboardingImage extends ConsumerStatefulWidget { + final Function imageCallback; + + const OnboardingImage({ + super.key, + required this.imageCallback, + }); + @override + ConsumerState createState() => _OnboardingImageState(); +} + +class _OnboardingImageState extends ConsumerState { + _addImage() async { + FilePickerResult? result = await FilePicker.platform.pickFiles( + allowMultiple: false, + type: FileType.image, + dialogTitle: "select profile image", + ); + + if (result != null) { + File file = File(result.files.single.path!); + } else { + // User canceled the picker + return; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Palette.background, + body: Center( + child: TextButton( + onPressed: () { + _addImage(); + }, + child: const Text('Select Image'), + ), + ), + ); + } +} diff --git a/lib/presentation_layer/routes/nostr/onboarding/onboarding_login.dart b/lib/presentation_layer/routes/nostr/onboarding/onboarding_login.dart new file mode 100644 index 00000000..9535c29e --- /dev/null +++ b/lib/presentation_layer/routes/nostr/onboarding/onboarding_login.dart @@ -0,0 +1,618 @@ +import 'dart:convert'; + +import 'package:bip32/bip32.dart' as bip32; +import 'package:camelus/presentation_layer/atoms/long_button.dart'; +import 'package:camelus/config/palette.dart'; +import 'package:camelus/helpers/bip340.dart'; +import 'package:camelus/helpers/helpers.dart'; +import 'package:camelus/presentation_layer/routes/home_page.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:hex/hex.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ndk/ndk.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:bip39_mnemonic/bip39_mnemonic.dart'; + +import '../../../../domain_layer/entities/key_pair.dart'; +import '../../../providers/event_signer_provider.dart'; + +class OnboardingLoginPage extends ConsumerStatefulWidget { + const OnboardingLoginPage({super.key}); + @override + ConsumerState createState() => + _OnboardingLoginPageState(); +} + +class _OnboardingLoginPageState extends ConsumerState { + bool _termsAndConditions = false; + + KeyPair? myKeys; + + final TextEditingController _inputController = TextEditingController(); + final FocusNode _inputFocusNode = FocusNode(); + + List _userWords = []; + String? mneonicError; + String? _userNsec = ""; + + void _pasteFromClipboard() async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + //check if data starts not with with nsec + if (data == null) { + showPasteError(); + return; + } + _addWords(data.text!); + } + + void showPasteError() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Invalid private key or seed phrase'), + ), + ); + } + + bool _setNsec(String nsec) { + try { + var privkey = Helpers().decodeBech32(nsec)[0]; + var pubkey = Bip340().getPublicKey(privkey); + var privKeyHr = nsec; + var publicKeyHr = Helpers().encodeBech32(pubkey, 'npub'); + + setState(() { + myKeys = KeyPair( + privateKey: privkey, + publicKey: pubkey, + privateKeyHr: privKeyHr, + publicKeyHr: publicKeyHr, + ); + }); + return true; + } catch (e) { + return false; + } + } + + bool _getPrivkeyFromSeed(String seed) { + try { + final mnemonic3 = Mnemonic.fromSentence(seed, Language.english); + + // list int to bytes + + final Uint8List seedBytes = Uint8List.fromList(mnemonic3.entropy); + bip32.BIP32 node = bip32.BIP32.fromSeed(seedBytes); + + // m/44'/1237'/'/0/0 + bip32.BIP32 child = node.derivePath("m/44'/1237'/0'/0/0"); + + final privkeyHex = HEX.encode(child.privateKey!); + + var pubkey = Bip340().getPublicKey(privkeyHex); + var privKeyHr = Helpers().encodeBech32(privkeyHex, 'nsec'); + var publicKeyHr = Helpers().encodeBech32(pubkey, 'npub'); + + setState(() { + myKeys = KeyPair( + privateKey: privkeyHex, + publicKey: pubkey, + privateKeyHr: privKeyHr, + publicKeyHr: publicKeyHr, + ); + }); + return true; + } catch (e) { + setState(() { + mneonicError = e.toString(); + }); + return false; + } + } + + _onSubmit() async { + if (!_termsAndConditions) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please read and accept the terms and conditions first', + style: TextStyle(color: Palette.black)), + ), + ); + return; + } + + if (_userWords.isNotEmpty) { + _getPrivkeyFromSeed(_userWords.join(" ")); + } + + if (myKeys == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please import your private key first'), + ), + ); + return; + } + + // store in secure storage + const storage = FlutterSecureStorage(); + storage.write(key: "nostrKeys", value: json.encode(myKeys!.toJson())); + // save in provider + + final bip340Signer = Bip340EventSigner( + privateKey: myKeys!.privateKey, + publicKey: myKeys!.publicKey, + ); + + ref.read(eventSignerProvider.notifier).setSigner(bip340Signer); + + setState(() {}); + + // ignore: use_build_context_synchronously + Navigator.pushReplacement(context, MaterialPageRoute(builder: (context) { + return HomePage(pubkey: myKeys!.publicKey); + })); + + //Navigator.popAndPushNamed(context, '/'); + } + + bool _checkWord(String word) { + const english = Language.english; + + return english.isValid(word); + } + + void _addWords(String words) { + List wordList = words.split(' '); + for (var word in wordList) { + _addWord(word); + } + setState(() { + _userWords = _userWords; + }); + // clear textfield + _inputController.clear(); + } + + void _addWord(String word) { + word = word.toLowerCase(); + if (word.startsWith("nsec")) { + setState(() { + _userWords = []; + _userNsec = word; + }); + _setNsec(word); + return; + } + setState(() { + _userNsec = null; + }); + + if (word.startsWith("npub")) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + duration: Duration(seconds: 20), + showCloseIcon: true, + content: Text( + 'you entered a public key, please enter a private key, it starts with nsec1'), + ), + ); + return; + } + + if (_checkWord(word)) { + _userWords.add(word); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'word: $word is not valid, check if it is spelled correctly'), + ), + ); + } + + mneonicError = null; + if (_userWords.length == 12 || _userWords.length == 24) { + _getPrivkeyFromSeed(_userWords.join(" ")); + } + } + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: null, + backgroundColor: Palette.background, + resizeToAvoidBottomInset: false, + body: SafeArea( + // input for the user to enter their private key, should be visible on a dark background. + child: LayoutBuilder(builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(30), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 20), + + if (_userWords.isNotEmpty) + Column( + children: [ + Container( + width: MediaQuery.of(context).size.width, + height: 200, + decoration: BoxDecoration( + color: Palette.extraDarkGray, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Palette.darkGray, + width: 1, + ), + ), + child: seedPhraseCheck(), + ), + const SizedBox(height: 5), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 10), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text(mneonicError ?? "", + style: const TextStyle( + color: Palette.error, + fontSize: 12, + )), + Text( + "${_userWords.length}/${(_userWords.length <= 12 ? "12" : "24")}", + style: TextStyle( + color: (_userWords.length > 24) + ? Palette.error + : Palette.white, + fontSize: 12, + ), + ), + ], + ), + ) + ], + ), + if (_userWords.isEmpty) + SizedBox( + height: 200, + width: MediaQuery.of(context).size.width, + child: const Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "login", + style: TextStyle( + color: Palette.white, + fontSize: 40, + fontFamily: "Poppins", + ), + ), + ], + ), + ), + + AnimatedOpacity( + opacity: (_userNsec != null && myKeys != null) ? 1 : 0, + duration: const Duration(milliseconds: 300), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const Text("your public key is:"), + const SizedBox(height: 10), + Container( + decoration: BoxDecoration( + color: Palette.extraDarkGray, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Palette.darkGray, + width: 1, + ), + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox( + height: 40, + child: Text(myKeys?.publicKeyHr ?? ""), + ), + ), + ), + const SizedBox(height: 15), + ], + ), + ), + Container( + child: TextField( + onSubmitted: (value) { + _addWords(value); + _inputFocusNode.requestFocus(); + }, + focusNode: _inputFocusNode, + autofillHints: Language.english.list, + controller: _inputController, + enableIMEPersonalizedLearning: false, + textCapitalization: TextCapitalization.none, + decoration: const InputDecoration( + isDense: true, + hintText: 'enter your seed phrase or nsec1', + hintStyle: TextStyle( + color: Palette.white, letterSpacing: 1.1), + filled: true, + fillColor: Palette.extraDarkGray, + enabledBorder: OutlineInputBorder( + borderRadius: + BorderRadius.all(Radius.circular(10)), + borderSide: + BorderSide(color: Palette.extraDarkGray), + ), + focusedBorder: OutlineInputBorder( + borderRadius: + BorderRadius.all(Radius.circular(10)), + borderSide: BorderSide(color: Palette.gray), + ), + errorBorder: OutlineInputBorder( + borderRadius: + BorderRadius.all(Radius.circular(10)), + borderSide: BorderSide(color: Palette.purple), + ), + ), + ), + ), + + const SizedBox(height: 15), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox( + height: 31, + child: ElevatedButton( + onPressed: () { + _pasteFromClipboard(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Palette.background, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: const BorderSide( + color: Palette.white, width: 1), + ), + ), + child: const Text( + 'paste', + style: TextStyle( + color: Palette.white, + fontSize: 16, + ), + ), + ), + ), + SizedBox( + height: 31, + child: ElevatedButton( + onPressed: () { + _addWords(_inputController.text); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Palette.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: const BorderSide( + color: Palette.background, width: 1), + ), + ), + child: const Text( + 'add', + style: TextStyle( + color: Palette.background, + fontSize: 16, + ), + ), + ), + ), + ], + ), + + const SizedBox(height: 50), + // checkbox to accept the privacy policy + + const SizedBox(height: 15), + + const Spacer(flex: 1), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Checkbox( + value: _termsAndConditions, + onChanged: (value) { + setState(() { + _termsAndConditions = value!; + }); + }, + activeColor: Palette.white, + checkColor: Palette.black, + fillColor: MaterialStateProperty.all(Palette.white), + //overlayColor: MaterialStateProperty.all(Palette.primary), + ), + const Text( + "I have read and accept the ", + style: TextStyle( + color: Palette.white, + fontSize: 12, + fontWeight: FontWeight.normal, + ), + ), + GestureDetector( + onTap: () { + Uri url = Uri.parse("https://camelus.app/terms/"); + launchUrl(url, + mode: LaunchMode.externalApplication); + }, + child: const Text( + "terms and conditions", + style: TextStyle( + color: Palette.white, + fontSize: 12, + fontWeight: FontWeight.bold, + decoration: TextDecoration.underline, + ), + ), + ), + ], + ), + + const SizedBox(height: 5), + GestureDetector( + onTap: () { + Uri url = Uri.parse("https://camelus.app/privacy/"); + launchUrl(url, mode: LaunchMode.externalApplication); + }, + child: const Text( + "privacy policy", + style: TextStyle( + color: Palette.white, + fontSize: 12, + fontWeight: FontWeight.bold, + decoration: TextDecoration.underline, + ), + ), + ), + + const SizedBox(height: 20), + + SizedBox( + width: MediaQuery.of(context).size.width, + height: 40, + child: longButton( + name: "login", + inverted: true, + onPressed: () => _onSubmit(), + ), + ), + ], + ), + ), + ), + ), + ); + }), + ), + ); + } + + Widget seedPhraseCheck() { + return Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: GridView.count( + crossAxisSpacing: 10, + mainAxisSpacing: 5, + crossAxisCount: 3, + childAspectRatio: (20 / 9), + dragStartBehavior: DragStartBehavior.start, + children: List.generate( + _userWords.length, + (index) { + return LongPressDraggable( + data: index, + feedback: Material( + color: Colors.transparent, + child: Center( + child: Container( + width: 100, + height: 40, + decoration: BoxDecoration( + color: Palette.extraDarkGray, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Palette.darkGray, + width: 1, + ), + ), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + (index + 1).toString(), + style: const TextStyle( + color: Palette.gray, + fontSize: 12, + ), + ), + const SizedBox(width: 5), + Text( + _userWords[index], + style: const TextStyle( + color: Palette.extraLightGray, + fontSize: 16, + ), + ), + ], + ), + ), + ), + ), + ), + child: DragTarget( + builder: (BuildContext context, List candidateData, + List rejectedData) { + return Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + (index + 1).toString(), + style: const TextStyle( + color: Palette.gray, + fontSize: 12, + ), + ), + const SizedBox(width: 5), + Text( + _userWords[index], + style: const TextStyle( + color: Palette.extraLightGray, + fontSize: 16, + ), + ), + ], + ), + ); + }, + onWillAcceptWithDetails: (data) => data != index, + onAcceptWithDetails: (data) { + setState(() { + String temp = _userWords[data.data]; + _userWords[data.data] = _userWords[index]; + _userWords[index] = temp; + }); + }, + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/presentation_layer/routes/nostr/onboarding/onboarding_login_amber.dart b/lib/presentation_layer/routes/nostr/onboarding/onboarding_login_amber.dart new file mode 100644 index 00000000..9fd41bf3 --- /dev/null +++ b/lib/presentation_layer/routes/nostr/onboarding/onboarding_login_amber.dart @@ -0,0 +1,196 @@ +import 'package:amberflutter/amberflutter.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +import '../../../../config/amber_url.dart'; +import '../../../../config/palette.dart'; +import '../../../../domain_layer/usecases/app_auth.dart'; +import '../../../atoms/long_button.dart'; +import '../../../providers/event_signer_provider.dart'; +import '../../home_page.dart'; + +class OnboardingLoginAmberPage extends ConsumerStatefulWidget { + const OnboardingLoginAmberPage({super.key}); + @override + ConsumerState createState() => + _OnboardingLoginAmberPageState(); +} + +class _OnboardingLoginAmberPageState + extends ConsumerState { + final amber = Amberflutter(); + bool _amberInstalled = false; + + bool _termsAndConditions = false; + + bool _amberLoading = false; + + void _checkAmberInstalled() async { + final installed = await amber.isAppInstalled(); + setState(() { + _amberInstalled = installed; + }); + } + + void _promtAmberInstall() { + launchUrlString(AMBER_INSTANCE_URL, mode: LaunchMode.externalApplication); + } + + @override + void initState() { + super.initState(); + _checkAmberInstalled(); + } + + void _onAmberLogin() async { + if (!_termsAndConditions) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please read and accept the terms and conditions first', + style: TextStyle(color: Palette.black)), + ), + ); + return; + } + setState(() { + _amberLoading = true; + }); + + final amberSigner = await AppAuth.amberRegister(); + + ref.read(eventSignerProvider.notifier).setSigner(amberSigner); + + setState(() {}); + + if (!mounted) return; + + Navigator.pushReplacement(context, MaterialPageRoute(builder: (context) { + return HomePage(pubkey: amberSigner.publicKey); + })); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: null, + backgroundColor: Palette.background, + resizeToAvoidBottomInset: false, + body: SafeArea( + // input for the user to enter their private key, should be visible on a dark background. + child: Container( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + padding: const EdgeInsets.all(30), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 20), + SizedBox( + height: 200, + width: MediaQuery.of(context).size.width, + child: const Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "login", + style: TextStyle( + color: Palette.white, + fontSize: 40, + fontFamily: "Poppins", + ), + ), + ], + ), + ), + const Spacer( + flex: 1, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Checkbox( + value: _termsAndConditions, + onChanged: (value) { + setState(() { + _termsAndConditions = value!; + }); + }, + activeColor: Palette.white, + checkColor: Palette.black, + fillColor: MaterialStateProperty.all(Palette.white), + //overlayColor: MaterialStateProperty.all(Palette.primary), + ), + const Text( + "I have read and accept the ", + style: TextStyle( + color: Palette.white, + fontSize: 12, + fontWeight: FontWeight.normal, + ), + ), + GestureDetector( + onTap: () { + Uri url = Uri.parse("https://camelus.app/terms/"); + launchUrl(url, mode: LaunchMode.externalApplication); + }, + child: const Text( + "terms and conditions", + style: TextStyle( + color: Palette.white, + fontSize: 12, + fontWeight: FontWeight.bold, + decoration: TextDecoration.underline, + ), + ), + ), + ], + ), + const SizedBox(height: 5), + GestureDetector( + onTap: () { + Uri url = Uri.parse("https://camelus.app/privacy/"); + launchUrl(url, mode: LaunchMode.externalApplication); + }, + child: const Text( + "privacy policy", + style: TextStyle( + color: Palette.white, + fontSize: 12, + fontWeight: FontWeight.bold, + decoration: TextDecoration.underline, + ), + ), + ), + const SizedBox(height: 20), + if (!_amberInstalled) + SizedBox( + width: MediaQuery.of(context).size.width, + height: 40, + child: longButton( + name: "install amber", + inverted: true, + onPressed: () => _promtAmberInstall(), + ), + ), + if (_amberInstalled) + SizedBox( + width: MediaQuery.of(context).size.width, + height: 40, + child: longButton( + name: "authorise amber", + inverted: true, + loading: _amberLoading, + onPressed: () => _onAmberLogin(), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation_layer/routes/nostr/onboarding/onboarding_login_select.dart b/lib/presentation_layer/routes/nostr/onboarding/onboarding_login_select.dart new file mode 100644 index 00000000..9cf027fc --- /dev/null +++ b/lib/presentation_layer/routes/nostr/onboarding/onboarding_login_select.dart @@ -0,0 +1,92 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../../config/palette.dart'; +import '../../../atoms/long_button.dart'; + +class OnboardingLoginSelectPage extends ConsumerStatefulWidget { + final Function onPressedSeedPhraseLogin; + final Function onPressedAmberLogin; + + const OnboardingLoginSelectPage({ + super.key, + required this.onPressedSeedPhraseLogin, + required this.onPressedAmberLogin, + }); + @override + ConsumerState createState() => + _OnboardingLoginSelectPageState(); +} + +class _OnboardingLoginSelectPageState + extends ConsumerState { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: null, + backgroundColor: Palette.background, + resizeToAvoidBottomInset: false, + body: SafeArea( + // input for the user to enter their private key, should be visible on a dark background. + child: Container( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + padding: const EdgeInsets.all(30), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 20), + SizedBox( + height: 200, + width: MediaQuery.of(context).size.width, + child: const Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "login", + style: TextStyle( + color: Palette.white, + fontSize: 40, + fontFamily: "Poppins", + ), + ), + ], + ), + ), + const Spacer( + flex: 1, + ), + if (Platform.isAndroid) + SizedBox( + width: MediaQuery.of(context).size.width, + height: 40, + child: longButton( + name: "amber login", + inverted: false, + onPressed: () => widget.onPressedAmberLogin(), + ), + ), + const SizedBox( + height: 20, + ), + SizedBox( + width: MediaQuery.of(context).size.width, + height: 40, + child: longButton( + name: "seed phrase login", + inverted: false, + onPressed: () => widget.onPressedSeedPhraseLogin(), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation_layer/routes/nostr/onboarding/onboarding_name.dart b/lib/presentation_layer/routes/nostr/onboarding/onboarding_name.dart new file mode 100644 index 00000000..6cd6483c --- /dev/null +++ b/lib/presentation_layer/routes/nostr/onboarding/onboarding_name.dart @@ -0,0 +1,117 @@ +import 'package:camelus/presentation_layer/atoms/long_button.dart'; +import 'package:camelus/config/palette.dart'; +import 'package:camelus/domain_layer/entities/onboarding_user_info.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class OnboardingName extends ConsumerStatefulWidget { + final Function submitCallback; + + OnboardingUserInfo userInfo; + + OnboardingName({ + super.key, + required this.submitCallback, + required this.userInfo, + }); + @override + ConsumerState createState() => _OnboardingNameState(); +} + +class _OnboardingNameState extends ConsumerState { + final TextEditingController _nameController = TextEditingController(); + final FocusNode _nameFocusNode = FocusNode(); + + bool nameSelected = false; + + @override + void initState() { + super.initState(); + _nameController.text = widget.userInfo.name ?? ''; + + _nameController.addListener(() { + setState(() { + nameSelected = _nameController.text.isNotEmpty; + }); + }); + } + + @override + void dispose() { + _nameController.dispose(); + _nameFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Palette.background, + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Spacer( + flex: 20, + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 20), + width: MediaQuery.of(context).size.width * 0.95, + child: TextField( + textAlign: TextAlign.justify, + cursorRadius: const Radius.circular(50), + maxLines: 2, + textAlignVertical: TextAlignVertical.center, + autofocus: true, + focusNode: _nameFocusNode, + controller: _nameController, + autofillHints: const [AutofillHints.name], + decoration: const InputDecoration( + hintText: 'what should we call you?', + contentPadding: EdgeInsets.all(0), + hintStyle: TextStyle( + color: Palette.white, + letterSpacing: 1.1, + ), + alignLabelWithHint: true, + border: InputBorder.none, + ), + onChanged: (value) { + widget.userInfo.name = value; + }, + style: const TextStyle( + color: Palette.lightGray, + letterSpacing: 1.1, + fontSize: 28, // Increase the font size + ), + ), + ), + const SizedBox( + height: 10, + ), + const Spacer( + flex: 1, + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 20), + width: 400, + height: 40, + child: longButton( + name: nameSelected ? "next" : "skip", + onPressed: (() { + _nameFocusNode.unfocus(); + widget.submitCallback(_nameController.text); + }), + inverted: nameSelected, + ), + ), + const SizedBox( + height: 15, + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation_layer/routes/nostr/onboarding/onboarding_page01.dart b/lib/presentation_layer/routes/nostr/onboarding/onboarding_page01.dart new file mode 100644 index 00000000..8ebe55cf --- /dev/null +++ b/lib/presentation_layer/routes/nostr/onboarding/onboarding_page01.dart @@ -0,0 +1,84 @@ +import 'package:camelus/presentation_layer/atoms/long_button.dart'; +import 'package:camelus/config/palette.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class OnboardingPage01 extends ConsumerStatefulWidget { + Function loginCallback; + Function registerCallback; + + OnboardingPage01({ + super.key, + required this.loginCallback, + required this.registerCallback, + }); + @override + ConsumerState createState() => _OnboardingPage01State(); +} + +class _OnboardingPage01State extends ConsumerState { + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Palette.background, + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spacer( + flex: 10, + ), + Text( + "welcome to", + style: TextStyle( + color: Palette.extraLightGray, + fontSize: MediaQuery.of(context).size.width / 22, + ), + ), + Text( + "camelus", + style: TextStyle( + color: Palette.white, + fontSize: MediaQuery.of(context).size.width / 7, + fontFamily: 'Poppins', + ), + ), + const Spacer( + flex: 10, + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 20), + width: 400, + height: 40, + child: longButton( + name: "join the conversation", + onPressed: (() { + widget.registerCallback(); + }), + inverted: true, + ), + ), + const SizedBox( + height: 30, + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 20), + width: 400, + height: 40, + child: longButton( + name: "login", + onPressed: (() { + widget.loginCallback(); + }), + inverted: false, + ), + ), + const Spacer( + flex: 2, + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation_layer/routes/nostr/onboarding/onboarding_picture.dart b/lib/presentation_layer/routes/nostr/onboarding/onboarding_picture.dart new file mode 100644 index 00000000..de12a690 --- /dev/null +++ b/lib/presentation_layer/routes/nostr/onboarding/onboarding_picture.dart @@ -0,0 +1,165 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:camelus/domain_layer/entities/mem_file.dart'; +import 'package:camelus/presentation_layer/atoms/crop_avatar.dart'; +import 'package:camelus/presentation_layer/atoms/my_profile_picture.dart'; +import 'package:camelus/config/palette.dart'; +import 'package:camelus/domain_layer/entities/onboarding_user_info.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:mime/mime.dart'; + +import '../../../atoms/camer_upload.dart'; +import '../../../atoms/long_button.dart'; + +class OnboardingPicture extends ConsumerStatefulWidget { + final Function pictureCallback; + + final OnboardingUserInfo signUpInfo; + + const OnboardingPicture({ + super.key, + required this.pictureCallback, + required this.signUpInfo, + }); + @override + ConsumerState createState() => _OnboardingPictureState(); +} + +class _OnboardingPictureState extends ConsumerState { + bool pictureSelected = false; + + _pickFile() async { + FilePickerResult? result = await FilePicker.platform.pickFiles( + allowMultiple: false, + type: FileType.image, + dialogTitle: "select image", + ); + + if (result != null) { + File file = File(result.files.single.path!); + + Uint8List imageData = file.readAsBytesSync(); + String imageMimeType = lookupMimeType(file.path) ?? ''; + String imageName = file.path.split('/').last; + + MemFile memFile = MemFile( + bytes: imageData, + mimeType: imageMimeType, + name: imageName, + ); + widget.signUpInfo.picture = memFile; + _openCropImagePopup(memFile.bytes); + } else { + // User canceled the picker + return; + } + } + + Uint8List? _displayImage; + + _openCropImagePopup(Uint8List imageData) { + // push fullscreen widget + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CropAvatar( + imageData: imageData, + ), + ), + ).then((value) { + if (value != null) { + setState(() { + widget.signUpInfo.picture!.bytes = value; + pictureSelected = true; + }); + } + }); + } + + @override + void initState() { + super.initState(); + if (widget.signUpInfo.picture != null) { + pictureSelected = true; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Palette.background, + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Spacer( + flex: 1, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + const Text("Welcome ", style: TextStyle(fontSize: 20)), + const SizedBox( + width: 5, + ), + Text( + widget.signUpInfo.name ?? '', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 23, + ), + ), + ], + ), + const Spacer( + flex: 1, + ), + InkWell( + borderRadius: BorderRadius.all(Radius.circular(50)), + onTap: () { + _pickFile(); + }, + child: widget.signUpInfo.picture == null + ? const CameraUpload( + size: 125, + ) + : ClipOval( + child: SizedBox.fromSize( + size: const Size.square(125), + child: Container( + color: Palette.background, + child: + Image.memory(widget.signUpInfo.picture!.bytes)), + ), + ), + ), + const Spacer( + flex: 1, + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 20), + width: 400, + height: 40, + child: longButton( + name: pictureSelected ? "next" : "skip", + onPressed: (() { + widget.pictureCallback(); + }), + inverted: pictureSelected, + ), + ), + const SizedBox( + height: 15, + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation_layer/routes/nostr/onboarding/onboarding_profile.dart b/lib/presentation_layer/routes/nostr/onboarding/onboarding_profile.dart new file mode 100644 index 00000000..84b7c70a --- /dev/null +++ b/lib/presentation_layer/routes/nostr/onboarding/onboarding_profile.dart @@ -0,0 +1,197 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:camelus/presentation_layer/components/edit_profile.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:mime/mime.dart'; + +import '../../../../domain_layer/entities/mem_file.dart'; +import '../../../../domain_layer/entities/onboarding_user_info.dart'; +import '../../../atoms/crop_avatar.dart'; +import '../../../atoms/long_button.dart'; + +class OnboardingProfile extends ConsumerStatefulWidget { + final OnboardingUserInfo signUpInfo; + final Function profileCallback; + + const OnboardingProfile({ + super.key, + required this.signUpInfo, + required this.profileCallback, + }); + + @override + ConsumerState createState() => _OnboardingProfileState(); +} + +class _OnboardingProfileState extends ConsumerState { + Future _pickFile() async { + FilePickerResult? result = await FilePicker.platform.pickFiles( + allowMultiple: false, + type: FileType.image, + dialogTitle: "select image", + ); + + if (result != null) { + File file = File(result.files.single.path!); + + Uint8List imageData = file.readAsBytesSync(); + String imageMimeType = lookupMimeType(file.path) ?? ''; + String imageName = file.path.split('/').last; + + MemFile memFile = MemFile( + bytes: imageData, + mimeType: imageMimeType, + name: imageName, + ); + + return memFile; + } else { + // User canceled the picker + return null; + } + } + + _openCropImagePopup({ + required Uint8List imageData, + required Function(Uint8List) callback, + double aspectRatio = 1, + bool roundUi = true, + }) { + // push fullscreen widget + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CropAvatar( + roundUi: roundUi, + aspectRatio: aspectRatio, + imageData: imageData, + ), + ), + ).then((value) { + if (value != null) { + callback(value); + } + }); + } + + /// pick a new picture and crop + _onClickPicture() async { + final file = await _pickFile(); + if (file == null) return; + + _openCropImagePopup( + imageData: file.bytes, + callback: (croppedData) => { + setState(() { + widget.signUpInfo.picture = MemFile( + bytes: croppedData, + mimeType: file.mimeType, + name: file.name, + ); + }), + }, + ); + } + + /// pick a new banner and crop + _onClickBanner() async { + final file = await _pickFile(); + if (file == null) return; + + _openCropImagePopup( + aspectRatio: 16 / 6, + imageData: file.bytes, + roundUi: false, + callback: (croppedData) => { + setState(() { + widget.signUpInfo.banner = MemFile( + bytes: croppedData, + mimeType: file.mimeType, + name: file.name, + ); + }) + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: Stack( + children: [ + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(bottom: 80), + child: EditProfile( + initialName: widget.signUpInfo.name ?? '', + onNameChanged: (value) { + widget.signUpInfo.name = value; + }, + initialPicture: widget.signUpInfo.picture?.bytes, + pictureCallback: () => _onClickPicture(), + initialBanner: widget.signUpInfo.banner?.bytes, + bannerCallback: () => _onClickBanner(), + initialAbout: widget.signUpInfo.about ?? '', + onAboutChanged: (value) { + widget.signUpInfo.about = value; + }, + initialNip05: widget.signUpInfo.nip05 ?? '', + onNip05Changed: (value) { + widget.signUpInfo.nip05 = value; + }, + initialWebsite: widget.signUpInfo.website ?? '', + onWebsiteChanged: (value) { + widget.signUpInfo.website = value; + }, + initialLud06: widget.signUpInfo.lud06, + onLud06Changed: (value) { + widget.signUpInfo.lud06 = value; + }, + initialLud16: widget.signUpInfo.lud16, + onLud16Changed: (value) { + widget.signUpInfo.lud16 = value; + }, + ), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.black, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: Offset(0, -5), + ), + ], + ), + child: SizedBox( + width: 400, + height: 40, + child: longButton( + name: "next", + onPressed: (() { + FocusScope.of(context).unfocus(); + widget.profileCallback(); + }), + inverted: true, + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/routes/nostr/profile/edit_profile_page.dart b/lib/presentation_layer/routes/nostr/profile/edit_profile_page.dart similarity index 75% rename from lib/routes/nostr/profile/edit_profile_page.dart rename to lib/presentation_layer/routes/nostr/profile/edit_profile_page.dart index 1afdc294..0e0a3433 100644 --- a/lib/routes/nostr/profile/edit_profile_page.dart +++ b/lib/presentation_layer/routes/nostr/profile/edit_profile_page.dart @@ -1,29 +1,24 @@ import 'dart:convert'; import 'dart:developer'; +import 'package:camelus/domain_layer/usecases/get_user_metadata.dart'; +import 'package:camelus/presentation_layer/atoms/long_button.dart'; -import 'package:camelus/atoms/long_button.dart'; -import 'package:camelus/models/nostr_request_event.dart'; -import 'package:camelus/providers/key_pair_provider.dart'; -import 'package:camelus/providers/metadata_provider.dart'; -import 'package:camelus/providers/nostr_service_provider.dart'; -import 'package:camelus/providers/relay_provider.dart'; -import 'package:camelus/services/nostr/metadata/user_metadata.dart'; +import 'package:camelus/presentation_layer/providers/metadata_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:camelus/config/palette.dart'; -import 'package:camelus/services/nostr/nostr_service.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; class EditProfilePage extends ConsumerStatefulWidget { - const EditProfilePage({Key? key}) : super(key: key); + const EditProfilePage({super.key}); @override ConsumerState createState() => _EditProfilePageState(); } class _EditProfilePageState extends ConsumerState { - late NostrService _nostrService; - late UserMetadata _metadataService; + late GetUserMetadata _metadataProvider; + // create text input controllers TextEditingController pictureController = TextEditingController(text: ""); TextEditingController bannerController = TextEditingController(text: ""); @@ -41,9 +36,11 @@ class _EditProfilePageState extends ConsumerState { bool submitLoading = false; bool isKeysExpanded = false; - void _initServices() { - _nostrService = ref.read(nostrServiceProvider); - _metadataService = ref.read(metadataProvider); + void _initServices() async { + _metadataProvider = ref.read(metadataProvider); + + // set initial values of text input controllers + _loadProfileValues(); } @override @@ -51,11 +48,7 @@ class _EditProfilePageState extends ConsumerState { super.initState(); _initServices(); - // get user public key - pubkey = _nostrService.myKeys.publicKey; - // set initial values of text input controllers - _loadProfileValues(); // listen to changes in text input controllers pictureController.addListener(() { setState(() {}); @@ -88,23 +81,23 @@ class _EditProfilePageState extends ConsumerState { } Future _loadProfileValues() async { - var profileData = await _metadataService.getMetadataByPubkey(pubkey); + var profileData = await _metadataProvider.getMetadataByPubkey(pubkey).first; setState(() { loading = false; }); log(profileData.toString()); - if (profileData["notFound"] == true) { + if (profileData == null) { return; } // set initial values of text input controllers - pictureController.text = profileData["picture"] ?? ""; - bannerController.text = profileData["banner"] ?? ""; - nameController.text = profileData["name"] ?? ""; - nip05Controller.text = profileData["nip05"] ?? ""; - aboutController.text = profileData["about"] ?? ""; - websiteController.text = profileData["website"] ?? ""; + pictureController.text = profileData.picture ?? ""; + bannerController.text = profileData.banner ?? ""; + nameController.text = profileData.name ?? ""; + nip05Controller.text = profileData.nip05 ?? ""; + aboutController.text = profileData.about ?? ""; + websiteController.text = profileData.website ?? ""; } void _submitData() async { @@ -135,17 +128,7 @@ class _EditProfilePageState extends ConsumerState { var contentString = json.encode(content); - var relays = ref.watch(relayServiceProvider); - var keyPair = await ref.watch(keyPairProvider.future); - - var myBody = NostrRequestEventBody( - pubkey: keyPair.keyPair!.publicKey, - privateKey: keyPair.keyPair!.privateKey, - tags: [], - content: contentString, - kind: 0); - var myRequest = NostrRequestEvent(body: myBody); - await relays.write(request: myRequest); + throw UnimplementedError("publish data to nostr"); setState(() { submitLoading = false; @@ -282,8 +265,7 @@ class _EditProfilePageState extends ConsumerState { animationDuration: const Duration(milliseconds: 100), expansionCallback: (int index, bool isExpanded) { setState(() { - isExpanded = !isExpanded; - isKeysExpanded = isExpanded; + isKeysExpanded = !isKeysExpanded; }); if (isKeysExpanded) { // wait for expansion animation to finish @@ -307,31 +289,32 @@ class _EditProfilePageState extends ConsumerState { }, body: Column( children: [ - // public key - Container( - margin: const EdgeInsets.all(10), - padding: const EdgeInsets.all(5), - child: GestureDetector( - onTap: () { - copyToClipboard( - _nostrService.myKeys.publicKeyHr); - }, - child: Text(_nostrService.myKeys.publicKeyHr), - ), - ), - // private key - Container( - margin: const EdgeInsets.all(10), - padding: const EdgeInsets.all(5), - child: GestureDetector( - onTap: () { - copyToClipboard( - _nostrService.myKeys.privateKeyHr); - }, - child: - Text(_nostrService.myKeys.privateKeyHr), - ), - ), + // // public key + // Container( + // margin: const EdgeInsets.all(10), + // padding: const EdgeInsets.all(5), + // child: GestureDetector( + // onTap: () { + // copyToClipboard( + // _keyPairService.keyPair!.publicKeyHr); + // }, + // child: Text( + // _keyPairService.keyPair!.publicKeyHr), + // ), + // ), + // // private key + // Container( + // margin: const EdgeInsets.all(10), + // padding: const EdgeInsets.all(5), + // child: GestureDetector( + // onTap: () { + // copyToClipboard( + // _keyPairService.keyPair!.privateKeyHr); + // }, + // child: Text( + // _keyPairService.keyPair!.privateKeyHr), + // ), + // ), ], ), isExpanded: isKeysExpanded, diff --git a/lib/presentation_layer/routes/nostr/profile/edit_relays_page.dart b/lib/presentation_layer/routes/nostr/profile/edit_relays_page.dart new file mode 100644 index 00000000..30b3fbdb --- /dev/null +++ b/lib/presentation_layer/routes/nostr/profile/edit_relays_page.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; + +import 'package:camelus/domain_layer/entities/relay.dart'; +import 'package:camelus/presentation_layer/components/edit_relays_view.dart'; +import 'package:camelus/domain_layer/entities/nostr_tag.dart'; +import 'package:camelus/presentation_layer/providers/following_provider.dart'; + +import 'package:flutter/material.dart'; +import 'package:camelus/config/palette.dart'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class EditRelaysPage extends ConsumerStatefulWidget { + const EditRelaysPage({super.key}); + + @override + ConsumerState createState() => _EditRelaysPageState(); +} + +class _EditRelaysPageState extends ConsumerState { + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + Future onSave(List changedRelays) async { + throw UnimplementedError("save in nip65"); + + return; + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + return true; + }, + child: Scaffold( + backgroundColor: Palette.background, + appBar: AppBar( + title: const Text('Edit Relays'), + backgroundColor: Palette.background, + foregroundColor: Palette.lightGray, + ), + // show loading indicator when reconnecting + body: EditRelaysView( + onSave: onSave, + ), + ), + ); + } +} diff --git a/lib/presentation_layer/routes/nostr/profile/follower_page.dart b/lib/presentation_layer/routes/nostr/profile/follower_page.dart new file mode 100644 index 00000000..0bce15e3 --- /dev/null +++ b/lib/presentation_layer/routes/nostr/profile/follower_page.dart @@ -0,0 +1,146 @@ +import 'package:camelus/domain_layer/entities/contact_list.dart'; +import 'package:camelus/domain_layer/entities/user_metadata.dart'; +import 'package:camelus/presentation_layer/components/person_card.dart'; +import 'package:camelus/domain_layer/entities/nostr_tag.dart'; +import 'package:camelus/presentation_layer/providers/following_provider.dart'; +import 'package:camelus/presentation_layer/providers/metadata_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:camelus/config/palette.dart'; +import 'package:camelus/presentation_layer/routes/nostr/profile/profile_page.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class FollowerPage extends ConsumerStatefulWidget { + final String title; + + final ContactList contactList; + + const FollowerPage({ + super.key, + required this.contactList, + required this.title, + }); + + @override + ConsumerState createState() => _FollowerPageState(); +} + +class _FollowerPageState extends ConsumerState { + /// follow Change - true to add, false to remove + Future _changeFollowing( + bool followChange, + String pubkey, + ContactList currentOwnContacts, + ) async { + final followService = ref.read(followingProvider); + List newContacts = [...currentOwnContacts.contacts]; + + if (followChange) { + newContacts.add(pubkey); + await followService.followUser(pubkey); + } else { + newContacts.removeWhere((element) => element == pubkey); + await followService.unfollowUser(pubkey); + } + setState(() { + currentOwnContacts.contacts = newContacts; + }); + } + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + var metadata = ref.watch(metadataProvider); + var followingService = ref.watch(followingProvider); + return Scaffold( + backgroundColor: Palette.background, + appBar: AppBar( + backgroundColor: Palette.background, + title: Text(widget.title), + foregroundColor: Palette.white, + ), + body: StreamBuilder( + + /// get self contacts to display follow state + stream: followingService.getContactsStreamSelf(), + builder: (context, contactsSnapshot) { + if (contactsSnapshot.hasError) { + return Text('Error: ${contactsSnapshot.error}'); + } + if (!contactsSnapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + + return ListView.builder( + physics: const BouncingScrollPhysics(), + itemCount: widget.contactList.contacts.length, + itemBuilder: (context, index) { + var displayPubkey = widget.contactList.contacts[index]; + return StreamBuilder( + stream: metadata.getMetadataByPubkey(displayPubkey), + builder: (BuildContext context, metadataSnapshot) { + if (metadataSnapshot.hasData) { + return personCard( + displayPubkey, + metadataSnapshot, + contactsSnapshot.data!, + context, + ); + } else if (metadataSnapshot.hasError) { + return Text('Error: ${metadataSnapshot.error}'); + } else { + return personCard( + displayPubkey, + metadataSnapshot, + contactsSnapshot.data!, + context, + ); + } + }); + }); + }), + ); + } + + PersonCard personCard( + String displayPubkey, + AsyncSnapshot metadataSnapshot, + ContactList ownContactList, + BuildContext context) { + return PersonCard( + pubkey: displayPubkey, + name: metadataSnapshot.data?.name ?? "", + pictureUrl: metadataSnapshot.data?.picture ?? "", + about: metadataSnapshot.data?.about ?? "", + nip05: metadataSnapshot.data?.nip05 ?? "", + isFollowing: + ownContactList.contacts.any((element) => element == displayPubkey), + onTap: () { + // navigate to profile page + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ProfilePage( + pubkey: displayPubkey, + ), + ), + ); + }, + onFollowTab: (followState) { + _changeFollowing( + followState, + displayPubkey, + ownContactList, + ); + }, + ); + } +} diff --git a/lib/routes/nostr/profile/profile_page.dart b/lib/presentation_layer/routes/nostr/profile/profile_page.dart similarity index 53% rename from lib/routes/nostr/profile/profile_page.dart rename to lib/presentation_layer/routes/nostr/profile/profile_page.dart index 5566f27f..c258d7e0 100644 --- a/lib/routes/nostr/profile/profile_page.dart +++ b/lib/presentation_layer/routes/nostr/profile/profile_page.dart @@ -2,48 +2,42 @@ import 'dart:async'; import 'dart:developer'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:camelus/atoms/back_button_round.dart'; -import 'package:camelus/atoms/follow_button.dart'; -import 'package:camelus/atoms/long_button.dart'; -import 'package:camelus/components/note_card/note_card_container.dart'; -import 'package:camelus/db/database.dart'; -import 'package:camelus/helpers/bip340.dart'; +import 'package:camelus/domain_layer/entities/contact_list.dart'; +import 'package:camelus/domain_layer/entities/user_metadata.dart'; +import 'package:camelus/domain_layer/usecases/follow.dart'; +import 'package:camelus/domain_layer/usecases/get_user_metadata.dart'; +import 'package:camelus/presentation_layer/atoms/back_button_round.dart'; +import 'package:camelus/presentation_layer/atoms/follow_button.dart'; +import 'package:camelus/presentation_layer/atoms/long_button.dart'; import 'package:camelus/helpers/nprofile_helper.dart'; -import 'package:camelus/models/nostr_note.dart'; -import 'package:camelus/models/nostr_tag.dart'; -import 'package:camelus/providers/database_provider.dart'; -import 'package:camelus/providers/following_provider.dart'; -import 'package:camelus/providers/key_pair_provider.dart'; -import 'package:camelus/providers/metadata_provider.dart'; -import 'package:camelus/providers/nostr_service_provider.dart'; -import 'package:camelus/providers/relay_provider.dart'; -import 'package:camelus/routes/nostr/nostr_page/perspective_feed_page.dart'; -import 'package:camelus/services/nostr/feeds/user_and_replies_feed.dart'; -import 'package:camelus/services/nostr/metadata/following_pubkeys.dart'; -import 'package:camelus/services/nostr/metadata/user_metadata.dart'; +import 'package:camelus/presentation_layer/providers/following_provider.dart'; +import 'package:camelus/presentation_layer/providers/metadata_provider.dart'; +import 'package:camelus/presentation_layer/providers/nip05_provider.dart'; +import 'package:camelus/presentation_layer/routes/nostr/blockedUsers/block_page.dart'; +import 'package:camelus/presentation_layer/routes/nostr/nostr_page/perspective_feed_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:camelus/atoms/my_profile_picture.dart'; +import 'package:camelus/presentation_layer/atoms/my_profile_picture.dart'; import 'package:camelus/config/palette.dart'; import 'package:camelus/helpers/helpers.dart'; -import 'package:camelus/routes/nostr/profile/edit_profile_page.dart'; -import 'package:camelus/routes/nostr/profile/edit_relays_page.dart'; -import 'package:camelus/routes/nostr/profile/follower_page.dart'; - -import 'package:camelus/services/nostr/nostr_service.dart'; +import 'package:camelus/presentation_layer/routes/nostr/profile/edit_profile_page.dart'; +import 'package:camelus/presentation_layer/routes/nostr/profile/edit_relays_page.dart'; +import 'package:camelus/presentation_layer/routes/nostr/profile/follower_page.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:matomo_tracker/matomo_tracker.dart'; import 'package:photo_view/photo_view.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../../providers/event_signer_provider.dart'; + class ProfilePage extends ConsumerStatefulWidget { final String pubkey; late final String nProfile; late final String nProfileHr; late final String pubkeyBech32; - ProfilePage({Key? key, required this.pubkey}) : super(key: key) { + ProfilePage({super.key, required this.pubkey}) { nProfile = NprofileHelper().mapToBech32({ "pubkey": pubkey, "relays": [], @@ -66,16 +60,10 @@ class ProfilePage extends ConsumerStatefulWidget { class _ProfilePageState extends ConsumerState with TickerProviderStateMixin, TraceableClientMixin { - late NostrService _nostrService; final ScrollController _scrollController = ScrollController(); - late AppDatabase _db; - late UserFeedAndRepliesFeed _userFeedAndRepliesFeed; final List _subscriptions = []; - @override - String get traceTitle => "profilePage"; - String nip05verified = ""; String requestId = Helpers().getRandomString(14); @@ -83,11 +71,12 @@ class _ProfilePageState extends ConsumerState if (nip05.isEmpty) return; if (nip05verified.isNotEmpty) return; try { - var check = await _nostrService.checkNip05(nip05, pubkey); + var nip05Ref = await ref.watch(nip05provider.future); + var check = await nip05Ref.check(nip05, pubkey); - if (check["valid"] == true) { + if (check != null && check.valid == true) { setState(() { - nip05verified = check["nip05"]; + nip05verified = check.nip05; }); } // ignore: empty_catches @@ -99,45 +88,15 @@ class _ProfilePageState extends ConsumerState } _blockUser() async { - // open dialog - var result = await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text("Block user"), - content: const Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text("Are you sure you want to block this user?"), - SizedBox(height: 20), - Text("You will no longer see their posts."), - SizedBox(height: 10), - Text( - "This happens only locally if you login on another client you will see their posts again.") - ], - ), - actions: [ - TextButton( - child: const Text("Cancel"), - onPressed: () { - Navigator.of(context).pop(false); - }, - ), - TextButton( - child: const Text("Block"), - onPressed: () { - Navigator.of(context).pop(true); - }, - ), - ], - ); + // navigate to block page + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BlockPage(userPubkey: widget.pubkey), + ), + ).then((value) => { + Navigator.pop(context), }); - if (!result) return; - - // add to blocked list - await _nostrService.addToBlocklist(widget.pubkey); - - Navigator.pop(context); } _openLightningAddress(String lu06) async { @@ -227,9 +186,10 @@ class _ProfilePageState extends ConsumerState _perspectiveFeedTrackAndLaunch(String pubkey, bool feedback) async { MatomoTracker.instance.trackEvent( - eventCategory: 'perspectiveFeed', - action: 'perspectiveFeedLaunch', - eventValue: feedback ? 1 : 0, + eventInfo: EventInfo( + category: "perspectiveFeed", + action: "perspectiveFeedLaunch", + value: feedback ? 1 : 0), ); log("launching perspective feed for $pubkey, feedback: $feedback"); @@ -244,106 +204,9 @@ class _ProfilePageState extends ConsumerState ).then((value) => {}); } - void _initNostrService() { - _nostrService = ref.read(nostrServiceProvider); - } - - final Completer _feedReady = Completer(); - Future _initSequence() async { - _db = await ref.read(databaseProvider.future); - var relayCoordinator = ref.watch(relayServiceProvider); - _userFeedAndRepliesFeed = - UserFeedAndRepliesFeed(_db, [widget.pubkey], relayCoordinator); - await _userFeedAndRepliesFeed.feedRdy; - _feedReady.complete(); - - _initUserFeed(); - _setupScrollListener(); - - return; - } - - void _initUserFeed() { - int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - int latestTweet = now - 86400; // -1 day - - _userFeedAndRepliesFeed.requestRelayUserFeedAndReplies( - users: [widget.pubkey], - requestId: "profilePage-${widget.pubkey.substring(5, 15)}", - limit: 10, - since: latestTweet, - ); - } - - bool timelineFetchLock = false; - void _setupScrollListener() { - _scrollController.addListener(() async { - setState(() {}); - if (_scrollController.position.pixels >= - _scrollController.position.maxScrollExtent - 100) { - var latest = _userFeedAndRepliesFeed.feed.last.created_at; - if (timelineFetchLock) return; - timelineFetchLock = true; - // load more tweets - await _userFeedLoadMore(latest); - timelineFetchLock = false; - } - }); - } - - Future _userFeedLoadMore(int? until) async { - log("load more called"); - int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - // schould not be needed - int defaultUntil = now - 86400 * 1; // -1 day - - await _userFeedAndRepliesFeed.requestRelayUserFeedAndReplies( - users: [widget.pubkey], - requestId: "profilePage-timeLine-${widget.pubkey.substring(5, 15)}", - limit: 5, - until: until ?? defaultUntil, - ); - - return; - } - - //! disabled does not work with how @build is called now (on every frame on scroll) - void _userFeedCheckForNewData(NostrNote currentBuilNote) async { - return; - var latestSessionNote = _userFeedAndRepliesFeed.oldestNoteInSession; - if (latestSessionNote == null) { - return; - } - var difference = currentBuilNote.created_at - latestSessionNote.created_at; - log("${latestSessionNote.created_at} -- ${currentBuilNote.created_at} -- $difference"); - if (latestSessionNote.id == currentBuilNote.id) { - await _userFeedLoadMore(currentBuilNote.created_at); - } - } - - void _onNavigateAway() async { - try { - _userFeedAndRepliesFeed.cleanup(); - } catch (e) { - log("error in navigate away"); - } - } - - @override - void initState() { - super.initState(); - _initSequence(); - _initNostrService(); - WidgetsBinding.instance.addPostFrameCallback((_) { - _onNavigateAway(); - }); - } - @override void dispose() { _scrollController.dispose(); - _userFeedAndRepliesFeed.cleanup(); _closeSubscriptions(); super.dispose(); } @@ -356,75 +219,76 @@ class _ProfilePageState extends ConsumerState @override Widget build(BuildContext context) { - var metadata = ref.watch(metadataProvider); - var followingService = ref.watch(followingProvider); - var myKeyPairWrapper = ref.watch(keyPairProvider.future); + final metadata = ref.watch(metadataProvider); + final followingService = ref.watch(followingProvider); + final signerP = ref.watch(eventSignerProvider); + + final pubkey = signerP!.getPublicKey(); return Scaffold( backgroundColor: Palette.background, body: Stack( children: [ - FutureBuilder( - future: myKeyPairWrapper, - builder: (context, keyPairSnapshot) { - if (!keyPairSnapshot.hasData) { - return const SizedBox(); - } - KeyPair myKeyPair = keyPairSnapshot.data!.keyPair!; - return CustomScrollView( - controller: _scrollController, - slivers: [ - SliverAppBar( - expandedHeight: 150, - //toolbarHeight: 10, - backgroundColor: Palette.background, - pinned: true, - flexibleSpace: _bannerImage(metadata), - - actions: [ - PopupMenuButton( - tooltip: "More", - onSelected: (e) => { - //log(e), - // toast - if (e == "block") _blockUser() - }, - itemBuilder: (BuildContext context) { - return {'block'}.map((String choice) { - return PopupMenuItem( - value: choice, - child: Text(choice), - ); - }).toList(); - }, - ), - ], - // rounded back button - leading: const BackButtonRound(), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(0), - child: Container(), - ), - ), - SliverList( - delegate: SliverChildListDelegate( - [ - const SizedBox(height: 10), - _actionRow( - myKeyPair, followingService, metadata, context), + CustomScrollView( + controller: _scrollController, + slivers: [ + SliverAppBar( + expandedHeight: 150, + //toolbarHeight: 10, + backgroundColor: Palette.background, + pinned: true, + flexibleSpace: _bannerImage(metadata), + + actions: [ + PopupMenuButton( + tooltip: "More", + onSelected: (e) => { + //log(e), + // toast + if (e == "block") _blockUser() + }, + itemBuilder: (BuildContext context) { + return {'block'}.map((String choice) { + return PopupMenuItem( + value: choice, + child: Text(choice), + ); + }).toList(); + }, + ), + ], + // rounded back button + leading: const BackButtonRound(), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(0), + child: Container(), + ), + ), + SliverList( + delegate: SliverChildListDelegate( + [ + const SizedBox(height: 10), + _actionRow(pubkey, followingService, metadata, context), + + // // move up the profile info by 110 + _profileInformation(metadata), + _bottomInformationBar(context, followingService, pubkey) + ], + ), + ), - // move up the profile info by 110 - _profileInformation(metadata), - _bottomInformationBar( - context, followingService, myKeyPair) - ], - ), - ), + SliverList( + delegate: SliverChildListDelegate( + [ + const SizedBox(height: 20), _feed(), ], - ); - }), - _profileImage(_scrollController, widget, _nostrService, metadata), + ), + ), + // sliver list with random colors + ], + ), + _profileImage(_scrollController, widget, metadata), SafeArea( child: SizedBox( height: 55, @@ -438,20 +302,20 @@ class _ProfilePageState extends ConsumerState ? 1.0 : 0.0, duration: const Duration(milliseconds: 200), - child: FutureBuilder( - future: metadata.getMetadataByPubkey(widget.pubkey), + child: StreamBuilder( + stream: metadata.getMetadataByPubkey(widget.pubkey), builder: (BuildContext context, - AsyncSnapshot snapshot) { + AsyncSnapshot snapshot) { var name = ""; if (snapshot.hasData) { - name = snapshot.data?["name"] ?? + name = snapshot.data?.name ?? '${widget.nProfile.substring(0, 10)}...${widget.nProfile.substring(widget.pubkey.length - 10)}'; } else if (snapshot.hasError) { name = "error"; } else { - // loading - name = "loading"; + name = snapshot.data?.name ?? + '${widget.nProfile.substring(0, 10)}...${widget.nProfile.substring(widget.pubkey.length - 10)}'; } return Text( @@ -474,93 +338,11 @@ class _ProfilePageState extends ConsumerState } _feed() { - return FutureBuilder( - future: _feedReady.future, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return SliverList( - delegate: SliverChildListDelegate([ - const Column( - children: [ - SizedBox(height: 100), - Center( - child: CircularProgressIndicator( - color: Palette.white, - )), - ], - ) - ])); - } - if (snapshot.hasError) { - return SliverList( - delegate: SliverChildListDelegate([ - const Center( - child: Text('Error'), - ) - ])); - } - return StreamBuilder>( - stream: _userFeedAndRepliesFeed.feedStream, - initialData: _userFeedAndRepliesFeed.feed, - builder: (BuildContext context, - AsyncSnapshot> snapshot) { - if (snapshot.hasData) { - var notes = snapshot.data!; - - if (notes.isEmpty) { - return SliverList( - delegate: SliverChildListDelegate( - [ - const Column( - children: [ - SizedBox(height: 100), - Text("no notes found", - style: TextStyle( - fontSize: 20, color: Palette.white)) - ], - ), - ], - ), - ); - } - - return SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - var note = notes[index]; - _userFeedCheckForNewData(note); - return NoteCardContainer( - notes: [note], - key: ValueKey(note.id), - ); - }, - childCount: notes.length, - ), - ); - } - if (snapshot.hasError) { - return SliverList( - delegate: SliverChildListDelegate([ - Center( - //button - child: ElevatedButton( - onPressed: () {}, - child: Text(snapshot.error.toString(), - style: const TextStyle( - fontSize: 20, color: Colors.white)), - )) - ]), - ); - } - return const Text("waiting for stream trigger ", - style: TextStyle(fontSize: 20)); - }, - ); - }); + return Text("feed not implemented yet"); } - Row _bottomInformationBar(BuildContext context, - FollowingPubkeys followingService, KeyPair myKeyPair) { + Row _bottomInformationBar( + BuildContext context, Follow followingService, String pubkey) { return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ @@ -568,14 +350,14 @@ class _ProfilePageState extends ConsumerState children: [ const Icon(Icons.people, color: Palette.white, size: 17), const SizedBox(width: 5), - FutureBuilder>( - future: followingService.getFollowingPubkeys(widget.pubkey), + FutureBuilder( + future: followingService.getContacts(widget.pubkey), builder: (BuildContext context, - AsyncSnapshot> snapshot) { + AsyncSnapshot snapshot) { var contactsCountString = ""; if (snapshot.hasData) { - var count = snapshot.data?.length; + var count = snapshot.data?.contacts.length; contactsCountString = "$count following"; } else if (snapshot.hasError) { contactsCountString = "n.a. following"; @@ -586,7 +368,8 @@ class _ProfilePageState extends ConsumerState return GestureDetector( onTap: () { - if (snapshot.data == null || snapshot.data!.isEmpty) { + if (snapshot.data == null || + snapshot.data!.contacts.isEmpty) { return; } @@ -594,7 +377,7 @@ class _ProfilePageState extends ConsumerState context, MaterialPageRoute( builder: (context) => FollowerPage( - contacts: snapshot.data ?? [], + contactList: snapshot.data!, title: "Following", ), ), @@ -622,16 +405,16 @@ class _ProfilePageState extends ConsumerState )), ], ), - _relaysInfo(followingService, myKeyPair, context), + _relaysInfo(followingService, pubkey, context), ], ); } - GestureDetector _relaysInfo(FollowingPubkeys followingService, - KeyPair myKeyPair, BuildContext context) { + GestureDetector _relaysInfo( + Follow followingService, String pubkey, BuildContext context) { return GestureDetector( onTap: () { - if (widget.pubkey == myKeyPair.publicKey) { + if (widget.pubkey == pubkey) { Navigator.push( context, MaterialPageRoute( @@ -645,15 +428,15 @@ class _ProfilePageState extends ConsumerState const Icon(Icons.connect_without_contact, color: Palette.white, size: 17), const SizedBox(width: 5), - if (widget.pubkey == myKeyPair.publicKey) + if (widget.pubkey == pubkey) Text( - "${followingService.ownRelays.entries.length} relays", + "todo: relays", style: const TextStyle( color: Palette.white, fontSize: 14, ), ), - if (widget.pubkey != myKeyPair.publicKey) + if (widget.pubkey != pubkey) const Text( "n.a. relays", //nip 65 relays style: TextStyle( @@ -666,22 +449,22 @@ class _ProfilePageState extends ConsumerState ); } - Container _profileInformation(UserMetadata metadata) { + Container _profileInformation(GetUserMetadata metadata) { return Container( transform: Matrix4.translationValues(0.0, -10.0, 0.0), - child: FutureBuilder( - future: metadata.getMetadataByPubkey(widget.pubkey), - builder: (BuildContext context, AsyncSnapshot snapshot) { + child: StreamBuilder( + stream: metadata.getMetadataByPubkey(widget.pubkey), + builder: (BuildContext context, AsyncSnapshot snapshot) { var name = ""; var nip05 = ""; var picture = ""; var about = ""; if (snapshot.hasData) { - name = snapshot.data?["name"] ?? ""; - nip05 = snapshot.data?["nip05"] ?? ""; - picture = snapshot.data?["picture"] ?? ""; - about = snapshot.data?["about"] ?? ""; + name = snapshot.data?.name ?? ""; + nip05 = snapshot.data?.nip05 ?? ""; + picture = snapshot.data?.picture ?? ""; + about = snapshot.data?.about ?? ""; _checkNip05(nip05, widget.pubkey); } else if (snapshot.hasError) { @@ -691,10 +474,10 @@ class _ProfilePageState extends ConsumerState about = "error"; } else { // loading - name = "loading"; - nip05 = "loading"; - picture = ""; - about = "loading"; + name = snapshot.data?.name ?? ""; + nip05 = snapshot.data?.nip05 ?? ""; + picture = snapshot.data?.picture ?? ""; + about = snapshot.data?.about ?? ""; } return Column( @@ -810,82 +593,83 @@ class _ProfilePageState extends ConsumerState ); } - Row _actionRow(KeyPair myKeyPair, FollowingPubkeys followingService, - UserMetadata metadata, BuildContext context) { + Row _actionRow(String pubkey, Follow followingService, + GetUserMetadata metadata, BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - if (widget.pubkey != myKeyPair.publicKey) - SizedBox( - width: 35, - height: 35, - child: ElevatedButton( - onPressed: () { - _launchPerspectiveFeed(widget.pubkey); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Palette.background, - padding: const EdgeInsets.all(0), - enableFeedback: true, - shape: const CircleBorder( - side: BorderSide(color: Palette.white, width: 1)), - ), - child: SvgPicture.asset( - "assets/icons/eye.svg", - height: 25, - color: Palette.white, - ), - ), - ), - - // round message button with icon and white border - FutureBuilder( - future: metadata.getMetadataByPubkey(widget.pubkey), - builder: (BuildContext context, AsyncSnapshot snapshot) { - String lud06 = ""; - String lud16 = ""; - - if (snapshot.hasData) { - lud06 = snapshot.data?["lud06"] ?? ""; - lud16 = snapshot.data?["lud16"] ?? ""; - } + if (widget.pubkey != pubkey) + // disabled because of low usage && not well implemented + // SizedBox( + // width: 35, + // height: 35, + // child: ElevatedButton( + // onPressed: () { + // _launchPerspectiveFeed(widget.pubkey); + // }, + // style: ElevatedButton.styleFrom( + // backgroundColor: Palette.background, + // padding: const EdgeInsets.all(0), + // enableFeedback: true, + // shape: const CircleBorder( + // side: BorderSide(color: Palette.white, width: 1)), + // ), + // child: SvgPicture.asset( + // "assets/icons/eye.svg", + // height: 25, + // color: Palette.white, + // ), + // ), + // ), + + // round message button with icon and white border + StreamBuilder( + stream: metadata.getMetadataByPubkey(widget.pubkey), + builder: (BuildContext context, + AsyncSnapshot snapshot) { + String lud06 = ""; + String lud16 = ""; + + if (snapshot.hasData) { + lud06 = snapshot.data?.lud06 ?? ""; + lud16 = snapshot.data?.lud16 ?? ""; + } - if (lud06.isNotEmpty || lud16.isNotEmpty) { - return Container( - margin: const EdgeInsets.only(top: 0, right: 0, left: 0), - child: ElevatedButton( - onPressed: () { - if (lud06.isNotEmpty) { - _openLightningAddress(lud06); - } else if (lud16.isNotEmpty) { - _openLightningAddress(lud16); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: Palette.background, - padding: const EdgeInsets.all(0), - shape: const CircleBorder( - side: BorderSide(color: Palette.white, width: 1)), - ), - child: SvgPicture.asset( - "assets/icons/lightning-fill.svg", - height: 25, - color: Palette.white, + if (lud06.isNotEmpty || lud16.isNotEmpty) { + return Container( + margin: const EdgeInsets.only(top: 0, right: 0, left: 0), + child: ElevatedButton( + onPressed: () { + if (lud06.isNotEmpty) { + _openLightningAddress(lud06); + } else if (lud16.isNotEmpty) { + _openLightningAddress(lud16); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Palette.background, + padding: const EdgeInsets.all(0), + shape: const CircleBorder( + side: BorderSide(color: Palette.white, width: 1)), + ), + child: SvgPicture.asset( + "assets/icons/lightning-fill.svg", + height: 25, + color: Palette.white, + ), ), - ), - ); - } else { - return Container(); - } - }), + ); + } else { + return Container(); + } + }), // follow button black with white border - if (widget.pubkey != myKeyPair.publicKey) - _followButton(followingService), + if (widget.pubkey != pubkey) _followButton(followingService), // edit button - if (widget.pubkey == myKeyPair.publicKey) + if (widget.pubkey == pubkey) Container( margin: const EdgeInsets.only(top: 0, right: 10), child: ElevatedButton( @@ -896,7 +680,6 @@ class _ProfilePageState extends ConsumerState builder: (context) => const EditProfilePage(), ), ).then((value) => { - metadata.getMetadataByPubkey(widget.pubkey), setState(() { // refresh }) @@ -922,25 +705,26 @@ class _ProfilePageState extends ConsumerState ); } - Widget _followButton(FollowingPubkeys followingService) { - return StreamBuilder>( - stream: followingService.ownPubkeyContactsStreamDb, - initialData: followingService.ownContacts, + Widget _followButton(Follow followingService) { + return StreamBuilder( + stream: followingService.getContactsStreamSelf(), builder: (context, snapshot) { if (snapshot.hasData) { - var followingList = snapshot.data!.map((e) => e.value).toList(); + var followingList = snapshot.data!.contacts; if (followingList.contains(widget.pubkey)) { return followButton( isFollowing: true, onPressed: () { - followingService.unfollow(widget.pubkey); + followingService.unfollowUser(widget.pubkey); + setState(() {}); }); } else { return followButton( isFollowing: false, onPressed: () { - followingService.follow(widget.pubkey); + followingService.followUser(widget.pubkey); + setState(() {}); }); } } @@ -948,14 +732,14 @@ class _ProfilePageState extends ConsumerState }); } - FlexibleSpaceBar _bannerImage(UserMetadata metadata) { + FlexibleSpaceBar _bannerImage(GetUserMetadata metadata) { return FlexibleSpaceBar( - background: FutureBuilder>( - future: metadata.getMetadataByPubkey(widget.pubkey), + background: StreamBuilder( + stream: metadata.getMetadataByPubkey(widget.pubkey), builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data?["banner"] != null) { + if (snapshot.hasData && snapshot.data?.banner != null) { return CachedNetworkImage( - imageUrl: snapshot.data?["banner"], + imageUrl: snapshot.data?.banner ?? "", filterQuality: FilterQuality.high, progressIndicatorBuilder: (context, url, downloadProgress) => // progress indicator with download progress @@ -965,7 +749,8 @@ class _ProfilePageState extends ConsumerState LinearProgressIndicator( minHeight: 2, value: downloadProgress.progress, - color: Palette.lightGray, + color: Palette.gray, + backgroundColor: Palette.black, ), ], ), @@ -977,17 +762,18 @@ class _ProfilePageState extends ConsumerState fit: BoxFit.cover, ); } - return Image.asset( - 'assets/images/default_header.jpg', - fit: BoxFit.cover, + + /// default header + return Container( + decoration: const BoxDecoration(color: Palette.darkGray), ); }, )); } } -Widget _profileImage(ScrollController sController, widget, - NostrService nostrService, UserMetadata metadata) { +Widget _profileImage( + ScrollController sController, widget, GetUserMetadata metadata) { const double defaultMargin = 125; const double defaultStart = 125; const double defaultEnd = defaultStart / 2; @@ -1042,30 +828,19 @@ Widget _profileImage(ScrollController sController, widget, border: Border.all(color: Palette.background, width: 3), shape: BoxShape.circle, ), - child: FutureBuilder( - future: metadata.getMetadataByPubkey(widget.pubkey), - builder: (BuildContext context, AsyncSnapshot snapshot) { + child: StreamBuilder( + stream: metadata.getMetadataByPubkey(widget.pubkey), + builder: + (BuildContext context, AsyncSnapshot snapshot) { var picture = ""; - if (snapshot.hasData) { - picture = snapshot.data?["picture"] ?? - "https://avatars.dicebear.com/api/personas/${widget.pubkey}.svg"; - } else if (snapshot.hasError) { - picture = - "https://avatars.dicebear.com/api/personas/${widget.pubkey}.svg"; - } else { - // loading - picture = - "https://avatars.dicebear.com/api/personas/${widget.pubkey}.svg"; - } return GestureDetector( onTap: (() { openImage(NetworkImage(picture), context); }), - child: myProfilePicture( - pictureUrl: picture, + child: UserImage( + imageUrl: snapshot.data?.picture, pubkey: widget.pubkey, - filterQuality: FilterQuality.high, )); }), ), diff --git a/lib/presentation_layer/routes/nostr/relays_page.dart b/lib/presentation_layer/routes/nostr/relays_page.dart new file mode 100644 index 00000000..f5a56630 --- /dev/null +++ b/lib/presentation_layer/routes/nostr/relays_page.dart @@ -0,0 +1,29 @@ +import 'package:camelus/config/palette.dart'; + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class RelaysPage extends ConsumerStatefulWidget { + const RelaysPage({super.key}); + + @override + ConsumerState createState() => _RelaysPageState(); +} + +class _RelaysPageState extends ConsumerState { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Palette.background, + body: SafeArea( + child: Text("disabled"), + ), + ); + } +} diff --git a/lib/presentation_layer/routes/nostr/settings/settings_page.dart b/lib/presentation_layer/routes/nostr/settings/settings_page.dart new file mode 100644 index 00000000..237a4ca6 --- /dev/null +++ b/lib/presentation_layer/routes/nostr/settings/settings_page.dart @@ -0,0 +1,54 @@ +import 'package:camelus/domain_layer/usecases/app_auth.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../../config/palette.dart'; +import '../../../providers/event_signer_provider.dart'; + +class SettingsPage extends ConsumerStatefulWidget { + const SettingsPage({super.key}); + + @override + _SettingsPageState createState() => _SettingsPageState(); +} + +class _SettingsPageState extends ConsumerState { + @override + void initState() { + super.initState(); + } + + void _logout() async { + await AppAuth.clearKeys(); + + // save in provider + ref.read(eventSignerProvider.notifier).clearSigner(); + + if (!mounted) { + return; + } + + Navigator.pushNamedAndRemoveUntil(context, '/onboarding', (route) => false); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Palette.background, + appBar: AppBar( + title: const Text('Settings'), + backgroundColor: Palette.background, + ), + body: ListView( + children: [ + ListTile( + title: const Text('logout', style: TextStyle(color: Colors.white)), + onTap: () { + _logout(); + }, + ), + ], + ), + ); + } +} diff --git a/lib/routes/notification_page.dart b/lib/presentation_layer/routes/notification_page.dart similarity index 91% rename from lib/routes/notification_page.dart rename to lib/presentation_layer/routes/notification_page.dart index f109f08c..fb9e42a4 100644 --- a/lib/routes/notification_page.dart +++ b/lib/presentation_layer/routes/notification_page.dart @@ -3,7 +3,7 @@ import 'package:camelus/config/palette.dart'; import 'package:flutter/material.dart'; class NotificationPage extends StatefulWidget { - const NotificationPage({Key? key}) : super(key: key); + const NotificationPage({super.key}); @override State createState() => _NotificationPageState(); diff --git a/lib/routes/search_page.dart b/lib/presentation_layer/routes/search_page.dart similarity index 69% rename from lib/routes/search_page.dart rename to lib/presentation_layer/routes/search_page.dart index 85b2fc69..c2f8fe77 100644 --- a/lib/routes/search_page.dart +++ b/lib/presentation_layer/routes/search_page.dart @@ -2,31 +2,28 @@ import 'dart:async'; import 'dart:convert'; import 'dart:developer'; -import 'package:camelus/atoms/hashtag_card.dart'; -import 'package:camelus/atoms/person_card.dart'; +import 'package:camelus/domain_layer/entities/nostr_band_hashtags.dart'; +import 'package:camelus/domain_layer/entities/nostr_band_people.dart'; +import 'package:camelus/domain_layer/entities/user_metadata.dart'; +import 'package:camelus/presentation_layer/atoms/hashtag_card.dart'; +import 'package:camelus/presentation_layer/components/person_card.dart'; import 'package:camelus/config/palette.dart'; import 'package:camelus/helpers/helpers.dart'; import 'package:camelus/helpers/nprofile_helper.dart'; -import 'package:camelus/helpers/search.dart'; -import 'package:camelus/models/api_nostr_band_hashtags.dart'; -import 'package:camelus/models/api_nostr_band_people.dart'; -import 'package:camelus/models/nostr_request_event.dart'; -import 'package:camelus/models/nostr_tag.dart'; -import 'package:camelus/providers/following_provider.dart'; -import 'package:camelus/providers/key_pair_provider.dart'; -import 'package:camelus/providers/metadata_provider.dart'; -import 'package:camelus/providers/navigation_bar_provider.dart'; -import 'package:camelus/providers/relay_provider.dart'; -import 'package:camelus/routes/nostr/profile/profile_page.dart'; +import 'package:camelus/presentation_layer/providers/following_provider.dart'; +import 'package:camelus/presentation_layer/providers/metadata_provider.dart'; +import 'package:camelus/presentation_layer/providers/navigation_bar_provider.dart'; +import 'package:camelus/presentation_layer/providers/nostr_band_provider.dart'; +import 'package:camelus/presentation_layer/routes/nostr/profile/profile_page.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:camelus/providers/database_provider.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../domain_layer/entities/contact_list.dart'; + class SearchPage extends ConsumerStatefulWidget { - const SearchPage({Key? key}) : super(key: key); + const SearchPage({super.key}); @override ConsumerState createState() => _SearchPageState(); @@ -38,12 +35,8 @@ class _SearchPageState extends ConsumerState { final List _subscriptions = []; - late Search _search; - bool _isSearching = false; - List> _searchResults = []; - _listenToNavigationBar() { // listen to home bar final navigationBar = ref.watch(navigationBarProvider); @@ -73,12 +66,6 @@ class _SearchPageState extends ConsumerState { FocusScope.of(context).requestFocus(_searchFocusNode); } - void _setupSearchObj() async { - var database = await ref.watch(databaseProvider.future); - - _search = Search(database); - } - void _initSequence() async { // wait 1 second with then await Future.delayed(const Duration(milliseconds: 200)).then((value) { @@ -87,7 +74,7 @@ class _SearchPageState extends ConsumerState { _listenToNavigationBar(); } }); - _setupSearchObj(); + _setupFocusNodeListener(); } @@ -109,51 +96,9 @@ class _SearchPageState extends ConsumerState { } } - Future _getTrendingProfiles() async { - // cached network request from https://api.nostr.band/v0/trending/profiles - - var file = await DefaultCacheManager().getSingleFile( - 'https://camelus.app/api/v1/nostr-band-cache?type=profiles&limit=10', - key: 'trending_profiles_nostr_band', - // 2 hours - headers: {'Cache-Control': 'max-age=7200'}, - ); - var result = await file.readAsString(); - if (result.isEmpty) { - return null; - } - var json = jsonDecode(result); - - return ApiNostrBandPeople.fromJson(json); - } - - Future _getTrendingHashtags() async { - //var file = await DefaultCacheManager().getSingleFile(url); - var file = await DefaultCacheManager().getSingleFile( - 'https://camelus.app/api/v1/nostr-band-cache?type=hashtags&limit=10', - key: 'trending_hashtags_nostr_band', - // 2 hours min - headers: {'Cache-Control': 'max-age=7200'}, - ); - var result = await file.readAsString(); - if (result.isEmpty) { - return null; - } - var json = jsonDecode(result); - return ApiNostrBandHashtags.fromJson(json); - } - void _onSearchChanged(String value) async { - if (value.length <= 1) { - setState(() { - _searchResults = []; - }); - return; - } var metadata = ref.watch(metadataProvider); - List> workingMetadata = []; - - workingMetadata.addAll(_search.searchUsersMetadata(value)); + List workingMetadata = []; final pattern = RegExp(r"nostr:(nprofile|npub)[a-zA-Z0-9]+"); @@ -194,17 +139,18 @@ class _SearchPageState extends ConsumerState { hex = value; } - var personMetadata = await metadata.getMetadataByPubkey(hex); + var personMetadata = + await metadata.getMetadataByPubkey(hex).first.timeout( + const Duration(seconds: 1), + ); - if (personMetadata.keys.isNotEmpty) { - workingMetadata.add(personMetadata as Map); - personMetadata['pubkey'] = hex; + if (personMetadata != null) { + workingMetadata.add(personMetadata); + personMetadata.pubkey = hex; } else { - workingMetadata.add({ - 'pubkey': value, - 'name': value, - 'relays': [], - }); + final mockUser = + UserMetadata(pubkey: value, eventId: "", lastFetch: 0, name: value); + workingMetadata.add(mockUser); } } @@ -220,24 +166,19 @@ class _SearchPageState extends ConsumerState { } if (finalNip05 != null) { log('finalNip05 $finalNip05'); - var nip05Metadata = await _search.searchNip05(finalNip05); + var nip05Metadata = {}; //await _search.searchNip05(finalNip05); if (nip05Metadata != null) { final String nipPubkey = nip05Metadata['pubkey']; final List nipRelays = nip05Metadata['relays']; - var personMetadata = await metadata.getMetadataByPubkey(nipPubkey); - personMetadata['pubkey'] = nipPubkey; - - if (personMetadata.keys.isNotEmpty) { - workingMetadata.add(personMetadata as Map); - } else { - workingMetadata.add({ - 'pubkey': nipPubkey, - 'name': value, - 'relays': nipRelays, - }); - } + var personMetadata = await metadata + .getMetadataByPubkey(nipPubkey) + .first + .timeout(const Duration(seconds: 1)); + personMetadata!.pubkey = nipPubkey; + + workingMetadata.add(personMetadata); } } @@ -253,7 +194,7 @@ class _SearchPageState extends ConsumerState { } setState(() { - _searchResults = workingMetadata; + //_searchResultsUsers = workingMetadata; }); } @@ -264,50 +205,17 @@ class _SearchPageState extends ConsumerState { } } - void _changeFollowing(bool followChange, String pubkey, - List currentOwnContacts) async { - var mykeys = await ref.watch(keyPairProvider.future); - var db = await ref.watch(databaseProvider.future); - - var myLastNote = - (await db.noteDao.findPubkeyNotesByKind([mykeys.keyPair!.publicKey], 3)) - .first; - - List newContacts = [...currentOwnContacts]; + void _changeFollowing( + bool followChange, String pubkey, ContactList currentOwnContacts) async { + List newContacts = [...currentOwnContacts.contacts]; if (followChange) { - newContacts.add(NostrTag(type: 'p', value: pubkey)); + newContacts.add(pubkey); } else { - newContacts.removeWhere((element) => element.value == pubkey); + newContacts.removeWhere((element) => element == pubkey); } - _writeContacts( - publicKey: mykeys.keyPair!.publicKey, - privateKey: mykeys.keyPair!.privateKey, - content: myLastNote.content, - updatedContacts: newContacts, - ); - } - - Future _writeContacts({ - required String publicKey, - required String privateKey, - required String content, - required List updatedContacts, - }) async { - var relays = ref.watch(relayServiceProvider); - NostrRequestEventBody body = NostrRequestEventBody( - pubkey: publicKey, - privateKey: privateKey, - content: content, - kind: 3, - tags: updatedContacts, - ); - NostrRequestEvent myEvent = NostrRequestEvent(body: body); - - await relays.write(request: myEvent); - - return; + throw UnimplementedError("save contacts"); } @override @@ -327,9 +235,8 @@ class _SearchPageState extends ConsumerState { child: Scaffold( backgroundColor: Palette.background, // scrollable column - body: StreamBuilder>( - stream: followingService.ownPubkeyContactsStreamDb, - initialData: followingService.ownContacts, + body: StreamBuilder( + stream: followingService.getContactsStreamSelf(), builder: (context, ownFollowingSnapshot) { return Column( children: [ @@ -349,35 +256,7 @@ class _SearchPageState extends ConsumerState { Column( children: [ // search results - for (var result in _searchResults) - PersonCard( - name: result['name'] ?? '', - nip05: result['nip05'] ?? '', - pictureUrl: result['picture'] ?? '', - about: result['about'] ?? '', - pubkey: result['pubkey'] ?? '', - isFollowing: ownFollowingSnapshot.data!.any( - (element) => - element.value == result['pubkey']), - onTap: () { - // navigate to profile page - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ProfilePage( - pubkey: result['pubkey'], - ), - ), - ); - }, - onFollowTab: (followState) { - _changeFollowing( - followState, - result['pubkey'], - ownFollowingSnapshot.data!, - ); - }, - ), + Text("todo: search results") ], ) ], @@ -391,7 +270,7 @@ class _SearchPageState extends ConsumerState { ); } - Container _defaultView(List currentFollowing) { + Container _defaultView(ContactList currentFollowing) { return Container( padding: const EdgeInsets.only(left: 20, top: 20, bottom: 10), child: Column( @@ -418,10 +297,9 @@ class _SearchPageState extends ConsumerState { ), ], ), - FutureBuilder( - future: _getTrendingHashtags(), - builder: - (context, AsyncSnapshot snapshot) { + FutureBuilder( + future: ref.watch(nostrBandProvider).getTrendingHashtags(), + builder: (context, AsyncSnapshot snapshot) { if (snapshot.hasError) { log(snapshot.error.toString()); return const Text('Something went wrong'); @@ -448,10 +326,9 @@ class _SearchPageState extends ConsumerState { fontWeight: FontWeight.bold), ), const SizedBox(height: 10), - FutureBuilder( - future: _getTrendingProfiles(), - builder: - (context, AsyncSnapshot snapshot) { + FutureBuilder( + future: ref.watch(nostrBandProvider).getTrendingPeople(), + builder: (context, AsyncSnapshot snapshot) { if (snapshot.hasError) { log(snapshot.error.toString()); return const Text('Something went wrong'); @@ -470,7 +347,7 @@ class _SearchPageState extends ConsumerState { )); } - Widget _trendingHashtags({required ApiNostrBandHashtags api, int? limit}) { + Widget _trendingHashtags({required NostrBandHashtags api, int? limit}) { var myHashtags = api.hashtags; List hashtagWidgets = []; @@ -500,7 +377,7 @@ class _SearchPageState extends ConsumerState { } Widget _trendingPeople( - ApiNostrBandPeople api, int limit, List currentFollowing) { + NostrBandPeople api, int limit, ContactList currentFollowing) { List personCards = []; for (int i = 0; i < api.profiles.length; i++) { @@ -515,8 +392,8 @@ class _SearchPageState extends ConsumerState { pictureUrl: metadata['picture'] ?? '', about: metadata['about'] ?? '', nip05: metadata['nip05'] ?? '', - isFollowing: - currentFollowing.any((element) => element.value == profile.pubkey), + isFollowing: currentFollowing.contacts + .any((element) => element == profile.pubkey), onTap: () { Navigator.push( context, diff --git a/lib/providers/database_provider.dart b/lib/providers/database_provider.dart deleted file mode 100644 index 93998c8f..00000000 --- a/lib/providers/database_provider.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'dart:developer'; - -import 'package:camelus/db/database.dart'; -import 'package:riverpod/riverpod.dart'; - -final databaseProvider = FutureProvider((ref) async { - var db = await $FloorAppDatabase.databaseBuilder('app_database.db').build(); - log("databaseProviderINIT"); - return db; -}); diff --git a/lib/providers/following_provider.dart b/lib/providers/following_provider.dart deleted file mode 100644 index 30d73ec3..00000000 --- a/lib/providers/following_provider.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:camelus/providers/database_provider.dart'; -import 'package:camelus/providers/key_pair_provider.dart'; -import 'package:camelus/providers/relay_provider.dart'; -import 'package:camelus/services/nostr/metadata/following_pubkeys.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -var followingProvider = Provider((ref) { - final keyPair = ref.watch(keyPairProvider.future); - final db = ref.watch(databaseProvider.future); - final relays = ref.watch(relayServiceProvider); - - final followingPubkeys = - FollowingPubkeys(keyPair: keyPair, db: db, relays: relays); - - return followingPubkeys; -}); diff --git a/lib/providers/key_pair_provider.dart b/lib/providers/key_pair_provider.dart deleted file mode 100644 index 20ed3941..00000000 --- a/lib/providers/key_pair_provider.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'dart:convert'; - -import 'package:camelus/helpers/bip340.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:riverpod/riverpod.dart'; - -final keyPairProvider = FutureProvider((ref) async { - KeyPairWrapper keyPairWrapper; - // load keypair from storage - FlutterSecureStorage storage = const FlutterSecureStorage(); - var nostrKeysString = await storage.read(key: "nostrKeys"); - if (nostrKeysString == null) { - keyPairWrapper = KeyPairWrapper(); - } else { - var myKeyPair = KeyPair.fromJson(json.decode(nostrKeysString)); - keyPairWrapper = KeyPairWrapper(keyPair: myKeyPair); - } - - return keyPairWrapper; -}); - -class KeyPairWrapper { - KeyPair? keyPair; - KeyPairWrapper({this.keyPair}); - - setKeyPair(KeyPair myKeyPair) { - keyPair = myKeyPair; - } -} diff --git a/lib/providers/metadata_provider.dart b/lib/providers/metadata_provider.dart deleted file mode 100644 index e9d07bb4..00000000 --- a/lib/providers/metadata_provider.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:camelus/providers/database_provider.dart'; -import 'package:camelus/providers/relay_provider.dart'; -import 'package:camelus/services/nostr/metadata/user_metadata.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -final metadataProvider = Provider((ref) { - var relays = ref.watch(relayServiceProvider); - var db = ref.watch(databaseProvider.future); - - var metadata = UserMetadata( - relays: relays, - dbFuture: db, - ); - - return metadata; -}); diff --git a/lib/providers/nip05_provider.dart b/lib/providers/nip05_provider.dart deleted file mode 100644 index d459ec19..00000000 --- a/lib/providers/nip05_provider.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:camelus/services/nostr/metadata/nip_05.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -final nip05provider = Provider((ref) { - var nip05 = Nip05(); - - return nip05; -}); diff --git a/lib/providers/nostr_service_provider.dart b/lib/providers/nostr_service_provider.dart deleted file mode 100644 index 82a2b74f..00000000 --- a/lib/providers/nostr_service_provider.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:camelus/providers/database_provider.dart'; -import 'package:camelus/providers/key_pair_provider.dart'; -import 'package:camelus/services/nostr/nostr_service.dart'; -import 'package:riverpod/riverpod.dart'; - -final nostrServiceProvider = Provider((ref) { - var db = ref.watch(databaseProvider.future); - var keyPairWrapper = ref.watch(keyPairProvider.future); - var nostrService = NostrService(database: db, keyPairWrapper: keyPairWrapper); - - return nostrService; -}); diff --git a/lib/providers/relay_provider.dart b/lib/providers/relay_provider.dart deleted file mode 100644 index cacd2cfd..00000000 --- a/lib/providers/relay_provider.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:camelus/providers/database_provider.dart'; -import 'package:camelus/providers/key_pair_provider.dart'; -import 'package:camelus/services/nostr/relays/relay_coordinator.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -final relayServiceProvider = Provider((ref) { - var db = ref.watch(databaseProvider.future); - var keypair = ref.watch(keyPairProvider.future); - - var relayService = RelayCoordinator( - dbFuture: db, - keyPairFuture: keypair, - ); - - return relayService; -}); diff --git a/lib/routes/nostr/blockedUsers/block_page.dart b/lib/routes/nostr/blockedUsers/block_page.dart deleted file mode 100644 index 567b0717..00000000 --- a/lib/routes/nostr/blockedUsers/block_page.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:camelus/atoms/long_button.dart'; -import 'package:camelus/config/palette.dart'; -import 'package:camelus/providers/metadata_provider.dart'; -import 'package:camelus/providers/nostr_service_provider.dart'; -import 'package:camelus/services/nostr/nostr_service.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -class BlockPage extends ConsumerStatefulWidget { - String? userPubkey; - String? postId; - - BlockPage({Key? key, this.userPubkey, this.postId}) : super(key: key); - - @override - ConsumerState createState() => _BlockPageState(); -} - -class _BlockPageState extends ConsumerState { - late NostrService _nostrService; - bool isUserBlocked = false; - - void _initNostrService() { - _nostrService = ref.read(nostrServiceProvider); - } - - @override - void initState() { - super.initState(); - _initNostrService(); - } - - @override - Widget build(BuildContext context) { - var metadata = ref.watch(metadataProvider); - return Scaffold( - appBar: AppBar( - title: const Text('block/report'), - backgroundColor: Palette.background, - ), - backgroundColor: Palette.background, - body: SingleChildScrollView( - child: Column( - children: [ - // block user - Container( - padding: const EdgeInsets.all(10), - child: Column( - children: [ - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('user', - style: TextStyle( - color: Palette.lightGray, fontSize: 20)), - const SizedBox(width: 10), - FutureBuilder( - future: - metadata.getMetadataByPubkey(widget.userPubkey!), - builder: (context, snapshot) { - if (snapshot.hasData) { - return Text( - snapshot.data?['name'] ?? widget.userPubkey, - style: const TextStyle( - color: Palette.white, - fontSize: 30, - fontWeight: FontWeight.bold), - ); - } - return Container(); - }, - ), - ], - ), - const SizedBox(height: 20), - SizedBox( - height: 40, - width: MediaQuery.of(context).size.width * 0.75, - child: longButton( - name: isUserBlocked ? "unblock" : "block", - onPressed: () { - if (isUserBlocked) { - _nostrService - .removeFromBlocklist(widget.userPubkey!); - } else { - _nostrService.addToBlocklist(widget.userPubkey!); - } - setState(() { - isUserBlocked = !isUserBlocked; - }); - }), - ), - const SizedBox(height: 10), - // SizedBox( - // height: 40, - // width: MediaQuery.of(context).size.width * 0.75, - // child: longButton(name: "report", onPressed: () => {}), - // ), - ], - ), - ) - ], - ), - ), - ); - } -} diff --git a/lib/routes/nostr/blockedUsers/blocked_users.dart b/lib/routes/nostr/blockedUsers/blocked_users.dart deleted file mode 100644 index bb5b24a3..00000000 --- a/lib/routes/nostr/blockedUsers/blocked_users.dart +++ /dev/null @@ -1,139 +0,0 @@ -import 'dart:async'; - -import 'package:camelus/atoms/my_profile_picture.dart'; -import 'package:camelus/config/palette.dart'; -import 'package:camelus/helpers/helpers.dart'; -import 'package:camelus/providers/metadata_provider.dart'; -import 'package:camelus/providers/nostr_service_provider.dart'; -import 'package:camelus/services/nostr/metadata/user_metadata.dart'; - -import 'package:camelus/services/nostr/nostr_service.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -class BlockedUsers extends ConsumerStatefulWidget { - const BlockedUsers({Key? key}) : super(key: key); - - @override - ConsumerState createState() => _BlockedUsersState(); -} - -class _BlockedUsersState extends ConsumerState { - final StreamController _streamController = StreamController(); - - late NostrService _nostrService; - - void _initNostrService() { - _nostrService = ref.read(nostrServiceProvider); - } - - @override - void initState() { - super.initState(); - _initNostrService(); - _streamController.stream.listen((event) { - setState(() {}); - }); - } - - @override - void dispose() { - _streamController.close(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - var metadata = ref.watch(metadataProvider); - return Scaffold( - backgroundColor: Palette.background, - appBar: AppBar( - backgroundColor: Palette.background, - title: const Text('Blocked Users'), - ), - body: CustomScrollView( - slivers: [ - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - return _profile(_nostrService.blockedUsers[index], - _streamController, widget, metadata, metadata); - }, - childCount: _nostrService.blockedUsers.length, - ), - ), - if (_nostrService.blockedUsers.isEmpty) - const SliverFillRemaining( - child: Center( - child: Text( - "No blocked users", - style: TextStyle(color: Palette.gray, fontSize: 24), - ), - ), - ), - ], - )); - } -} - -Widget _profile(String pubkey, StreamController streamController, widget, - UserMetadata nostrService, UserMetadata userMetadata) { - return FutureBuilder( - future: userMetadata.getMetadataByPubkey(pubkey), - builder: (BuildContext context, AsyncSnapshot snapshot) { - String picture = ""; - String name = ""; - String about = ""; - - if (snapshot.hasData) { - picture = snapshot.data?["picture"] ?? - "https://avatars.dicebear.com/api/personas/$pubkey.svg"; - name = - snapshot.data?["name"] ?? Helpers().encodeBech32(pubkey, "npub"); - about = snapshot.data?["about"] ?? ""; - } else if (snapshot.hasError) { - picture = "https://avatars.dicebear.com/api/personas/$pubkey.svg"; - name = Helpers().encodeBech32(pubkey, "npub"); - about = ""; - } else { - // loading - picture = "https://avatars.dicebear.com/api/personas/$pubkey.svg"; - name = "loading..."; - about = ""; - } - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 12), - child: Row( - children: [ - myProfilePicture(pictureUrl: picture, pubkey: pubkey), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Palette.white, - ), - ), - const SizedBox(height: 4), - ], - ), - ), - IconButton( - icon: const Icon(Icons.block_flipped), - color: Palette.white, - onPressed: () { - //nostrService.removeFromBlocklist(pubkey); - streamController.add(true); - }, - ), - ], - ), - ); - }); -} diff --git a/lib/routes/nostr/event_view/event_view_page.dart b/lib/routes/nostr/event_view/event_view_page.dart deleted file mode 100644 index 78dfe0fe..00000000 --- a/lib/routes/nostr/event_view/event_view_page.dart +++ /dev/null @@ -1,365 +0,0 @@ -import 'dart:async'; -import 'dart:developer'; - -import 'package:camelus/atoms/refresh_indicator_no_need.dart'; -import 'package:camelus/components/note_card/note_card_container.dart'; -import 'package:camelus/config/palette.dart'; -import 'package:camelus/db/database.dart'; -import 'package:camelus/models/nostr_note.dart'; -import 'package:camelus/providers/database_provider.dart'; -import 'package:camelus/providers/relay_provider.dart'; -import 'package:camelus/scroll_controller/retainable_scroll_controller.dart'; -import 'package:camelus/services/nostr/feeds/event_feed.dart'; -import 'package:flutter/material.dart'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -class EventViewPage extends ConsumerStatefulWidget { - final String _rootId; - final String? _scrollIntoView; - - const EventViewPage( - {Key? key, required String rootId, String? scrollIntoView}) - : _scrollIntoView = scrollIntoView, - _rootId = rootId, - super(key: key); - - @override - _EventViewPageState createState() => _EventViewPageState(); -} - -class _EventViewPageState extends ConsumerState { - late AppDatabase db; - late EventFeed _eventFeed; - late final RetainableScrollController _scrollControllerFeed = - RetainableScrollController(); - - final Completer _servicesReady = Completer(); - - final String eventFeedFreshId = "fresh"; - - NostrNote? _lastNoteInFeed; - - void _setupScrollListener() { - _scrollControllerFeed.addListener(() { - if (_scrollControllerFeed.position.pixels == - _scrollControllerFeed.position.maxScrollExtent) { - log("reached end of scroll"); - - _eventFeedLoadMore(); - } - - if (_scrollControllerFeed.position.pixels < 100) { - // disable after sroll - // if (_newPostsAvailable) { - // setState(() { - // _newPostsAvailable = false; - // }); - // } - } - }); - } - - Future _initDb() async { - db = await ref.read(databaseProvider.future); - return; - } - - Future _initSequence() async { - await _initDb(); - - var relayCoordinator = ref.watch(relayServiceProvider); - - _eventFeed = EventFeed(db, widget._rootId, relayCoordinator); - await _eventFeed.feedRdy; - _servicesReady.complete(); - - _initUserFeed(); - _setupScrollListener(); - } - - @override - void initState() { - super.initState(); - _initSequence(); - } - - @override - void dispose() { - _eventFeed.cleanup(); - super.dispose(); - } - - void _initUserFeed() { - _eventFeed.requestRelayEventFeed( - eventIds: [widget._rootId], - requestId: eventFeedFreshId, - limit: 5, - ); - } - - void _eventFeedLoadMore() async { - log("load more called"); - - int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - // schould not be needed - int defaultUntil = now - 86400 * 7; // -1 week - - _eventFeed.requestRelayEventFeed( - eventIds: [widget._rootId], - requestId: eventFeedFreshId, - limit: 5, - until: _lastNoteInFeed?.created_at ?? defaultUntil, - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Palette.background, - appBar: AppBar( - foregroundColor: Palette.white, - backgroundColor: Palette.background, - title: const Text("thread"), - ), - body: FutureBuilder( - future: _servicesReady.future, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center( - child: CircularProgressIndicator( - color: Palette.white, - )); - } - if (snapshot.hasError) { - return const Center(child: Text('Error')); - } - if (snapshot.connectionState == ConnectionState.done) { - return RefreshIndicatorNoNeed( - onRefresh: () { - return Future.delayed(const Duration(milliseconds: 0)); - }, - child: StreamBuilder( - stream: _eventFeed.feedStream, - initialData: _eventFeed.feed, - builder: (BuildContext context, - AsyncSnapshot> snapshot) { - if (snapshot.hasData) { - var notes = snapshot.data!; - if (notes.isEmpty) { - return const Center( - child: Text("no notes found", - style: - TextStyle(fontSize: 20, color: Palette.white)), - ); - } - return _buildScrollView(notes); - } - if (snapshot.hasError) { - return Center( - //button - child: ElevatedButton( - onPressed: () {}, - child: Text(snapshot.error.toString(), - style: const TextStyle(fontSize: 20, color: Colors.white)), - )); - } - return const Text("waiting for stream trigger ", - style: TextStyle(fontSize: 20)); - }, - ), - ); - } - return const Center(child: Text('Error')); - }, - ), - ); - } - - CustomScrollView _buildScrollView(List notes) { - _lastNoteInFeed = notes.last; - return CustomScrollView( - physics: const BouncingScrollPhysics(), - controller: _scrollControllerFeed, - slivers: [ - SliverList( - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - return _buildReplyTree(notes, widget._rootId); - }, - childCount: 1, - ), - ), - ], - ); - } - - // return the root - NoteCardContainer _buildReplyTree(List notes, String rootId) { - var workingList = [...notes]; - var rootNote = workingList.firstWhere( - (element) => element.id == rootId, - orElse: () => NostrNote.empty(id: rootId), - ); - // remove the root from the working list - try { - workingList.removeWhere((element) => element.id == rootId); - } catch (e) { - log("root not in tree"); - } - - // get author first level replies - List authorFirstLevelSelfReplies = workingList - .where((element) => - element.getRootReply?.value == rootId && - element.pubkey == rootNote.pubkey && - element.getDirectReply == null) - .toList(); - - // get root level replies and build containers - List rootLevelReplies = workingList.where((element) { - return element.getDirectReply?.value == rootNote.id; - }).toList(); - - // remove root level replies from working list - for (var element in rootLevelReplies) { - workingList.removeWhere((e) => e.id == element.id); - } - - List rootLevelRepliesContainers = rootLevelReplies - .map((e) => NoteCardContainer( - notes: [e], - )) - .toList(); - - // add remaining replies to containers - var foundNotes = []; - for (var container in rootLevelRepliesContainers) { - for (var note in workingList) { - for (var tag in note.getTagEvents) { - if (container.notes.map((e) => e.id).contains(tag.value)) { - container.notes.add(note); - // remove note from working list - foundNotes.add(note); - break; - } else {} - } - } - } - // remove found notes from working list - for (var note in foundNotes) { - workingList.removeWhere((e) => e.id == note.id); - } - - log("unresolved notes: ${workingList.length}"); - - _tryToFetchUnresolvedNotes(workingList, rootNote); - - // add unresolved notes to root level replies with missing Note - - for (var note in workingList) { - rootLevelRepliesContainers.add(NoteCardContainer( - notes: [NostrNote.empty(id: note.getDirectReply?.value ?? ""), note], - )); - } - - return NoteCardContainer( - notes: [ - rootNote, - ], - otherContainers: rootLevelRepliesContainers, - ); - } - - List unresolvedNotesLoopWaiting = []; - _tryToFetchUnresolvedNotes(List notes, NostrNote rootNote) async { - if (notes.isEmpty) return; - - if (!unresolvedNotesLoopWaiting.contains(notes.first) || - !unresolvedNotesLoop.contains(notes.first)) { - unresolvedNotesLoopWaiting.addAll(notes); - } - - if (unresolvedNotesLoop.isEmpty) { - unresolvedNotesLoop.addAll(unresolvedNotesLoopWaiting); - unresolvedNotesLoopWaiting.clear(); - await _unresolvedLoop(rootNote); - unresolvedNotesLoop.clear(); - return; - } - - // if the loop is already running then it will be triggered again after the loop is done - - await Future.delayed(const Duration(milliseconds: 500)); - - _tryToFetchUnresolvedNotes(notes, rootNote); - } - - List unresolvedNotesLoop = []; - Future _unresolvedLoop(NostrNote rootNote) async { - List myEventIds = []; - List myAuthorPubkeys = []; - List myRelayCandidates = []; - - for (var note in unresolvedNotesLoop) { - var noteIdReply = note.getDirectReply?.value; - var relayReplyRelay = note.getDirectReply?.recommended_relay; - - var noteIdRoot = note.getRootReply?.value; - var relayRootRelay = note.getRootReply?.recommended_relay; - - List authorPubkeys = - note.getTagPubkeys.map((e) => e.value).toList(); - - List authorPubkeysRelays = []; - for (var tag in note.getTagPubkeys) { - if (tag.recommended_relay != null) { - authorPubkeysRelays.add(tag.recommended_relay!); - } - } - - log("noteIdReplyRelay: $relayReplyRelay, noteIdRootRelay: $relayRootRelay"); - - if (noteIdReply != null) { - myEventIds.add(noteIdReply); - } - if (noteIdRoot != null) { - myEventIds.add(noteIdRoot); - } - if (relayReplyRelay != null) { - myRelayCandidates.add(relayReplyRelay); - } - if (relayRootRelay != null) { - myRelayCandidates.add(relayRootRelay); - } - if (authorPubkeys.isNotEmpty) { - myAuthorPubkeys.addAll(authorPubkeys); - } - if (authorPubkeysRelays.isNotEmpty) { - myRelayCandidates.addAll(authorPubkeysRelays); - } - } - - if (myRelayCandidates.isEmpty) { - //return; - } - - // remove duplicates - myRelayCandidates = myRelayCandidates.toSet().toList(); - myAuthorPubkeys = myAuthorPubkeys.toSet().toList(); - myEventIds = myEventIds.toSet().toList(); - - var result = await _eventFeed.requestRelayEventFeedFixedRelays( - pubkeys: myAuthorPubkeys, - eventIds: myEventIds, - relayCandidates: myRelayCandidates, - requestId: "efeed-tmp-unresolvedLoop", - timeout: const Duration(seconds: 1), - limit: 5, - until: rootNote.created_at // root note - ); - log("resultRelay: $result"); - _eventFeed.closeRelaySubscription("efeed-tmp-unresolvedLoop"); - return; - } -} diff --git a/lib/routes/nostr/nostr_page/hashtag_feed_view.dart b/lib/routes/nostr/nostr_page/hashtag_feed_view.dart deleted file mode 100644 index 8c18ebab..00000000 --- a/lib/routes/nostr/nostr_page/hashtag_feed_view.dart +++ /dev/null @@ -1,253 +0,0 @@ -import 'dart:async'; -import 'dart:developer'; - -import 'package:camelus/atoms/new_posts_available.dart'; -import 'package:camelus/atoms/refresh_indicator_no_need.dart'; -import 'package:camelus/components/note_card/note_card_container.dart'; -import 'package:camelus/config/palette.dart'; -import 'package:camelus/db/database.dart'; -import 'package:camelus/models/nostr_note.dart'; -import 'package:camelus/providers/database_provider.dart'; -import 'package:camelus/providers/relay_provider.dart'; -import 'package:camelus/scroll_controller/retainable_scroll_controller.dart'; -import 'package:camelus/services/nostr/feeds/hashtag_feed.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -class HashtagFeedView extends ConsumerStatefulWidget { - final String hashtag; - - const HashtagFeedView({Key? key, required this.hashtag}) : super(key: key); - - @override - ConsumerState createState() => _HashtagFeedViewState(); -} - -class _HashtagFeedViewState extends ConsumerState { - late AppDatabase db; - final List _subscriptions = []; - late HashtagFeed _hashtagFeed; - final String hashtagFeedFreshId = "fresh"; - final String hashtagFeedTimelineFetchId = "timeline"; - - late final RetainableScrollController _scrollControllerFeed = - RetainableScrollController(); - bool _newPostsAvailable = false; - - final Completer _servicesReady = Completer(); - - void _setupScrollListener() { - _scrollControllerFeed.addListener(() { - if (_scrollControllerFeed.position.pixels == - _scrollControllerFeed.position.maxScrollExtent) { - log("reached end of scroll"); - - var latest = - _hashtagFeed.feed.last.created_at; //- 86400 * 7; // -1 week - - _hashtagFeedLoadMore(latest); - } - - if (_scrollControllerFeed.position.pixels < 100) { - // disable after sroll - // if (_newPostsAvailable) { - // setState(() { - // _newPostsAvailable = false; - // }); - // } - } - }); - } - - void _setupNewNotesListener() { - _subscriptions.add( - _hashtagFeed.newNotesStream.listen((event) { - log("new notes stream event"); - setState(() { - _newPostsAvailable = true; - }); - }), - ); - } - - void _integrateNewNotes() { - _hashtagFeed.integrateNewNotes(); - _scrollControllerFeed.animateTo( - _scrollControllerFeed.position.minScrollExtent, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - setState(() { - _newPostsAvailable = false; - }); - } - - void _initHashtagFeed() { - int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - int latestTweet = now - 86400; // -1 day - - _hashtagFeed.requestRelayHashtagFeed( - hashtags: [widget.hashtag], - requestId: hashtagFeedFreshId, - limit: 5, - //since: latestTweet, - ); - } - - void _hashtagFeedLoadMore(int? until) async { - log("load more called"); - - int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - // schould not be needed - int defaultUntil = now - 86400 * 7; // -1 week - - _hashtagFeed.requestRelayHashtagFeed( - hashtags: [widget.hashtag], - requestId: hashtagFeedTimelineFetchId, - limit: 5, - until: until ?? defaultUntil, - ); - } - - void _hashtagFeedCheckForNewData(NostrNote currentBuilNote) { - var latestSessionNote = _hashtagFeed.oldestNoteInSession; - if (latestSessionNote == null) { - return; - } - var difference = currentBuilNote.created_at - latestSessionNote.created_at; - log("${latestSessionNote.created_at} -- ${currentBuilNote.created_at} -- $difference"); - if (latestSessionNote.id == currentBuilNote.id) { - log("### load more please #################################"); - _hashtagFeedLoadMore(currentBuilNote.created_at); - } - } - - Future _initDb() async { - db = await ref.read(databaseProvider.future); - return; - } - - Future _initSequence() async { - await _initDb(); - - var relayCoordinator = ref.watch(relayServiceProvider); - - _hashtagFeed = HashtagFeed(db, relayCoordinator, widget.hashtag); - await _hashtagFeed.feedRdy; - - _servicesReady.complete(); - - _initHashtagFeed(); - _setupScrollListener(); - _setupNewNotesListener(); - - return; - } - - @override - void initState() { - super.initState(); - _initSequence(); - } - - @override - void dispose() { - super.dispose(); - _hashtagFeed.cleanup(); - _disposeSubscriptions(); - } - - void _disposeSubscriptions() { - for (var s in _subscriptions) { - s.cancel(); - } - } - - @override - Widget build(BuildContext context) { - return FutureBuilder( - future: _servicesReady.future, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center( - child: CircularProgressIndicator( - color: Palette.white, - )); - } - if (snapshot.hasError) { - return const Center(child: Text('Error')); - } - - if (snapshot.connectionState == ConnectionState.done) { - return Stack( - children: [ - RefreshIndicatorNoNeed( - onRefresh: () { - return Future.delayed(const Duration(milliseconds: 0)); - }, - child: StreamBuilder>( - stream: _hashtagFeed.feedStream, - initialData: _hashtagFeed.feed, - builder: (BuildContext context, - AsyncSnapshot> snapshot) { - if (snapshot.hasData) { - var notes = snapshot.data!; - - if (notes.isEmpty) { - return const Center( - child: Text("no notes found", - style: TextStyle( - fontSize: 20, color: Palette.white)), - ); - } - - return CustomScrollView( - physics: const BouncingScrollPhysics(), - controller: _scrollControllerFeed, - slivers: [ - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - var note = notes[index]; - _hashtagFeedCheckForNewData(note); - return NoteCardContainer(notes: [note]); - }, - childCount: notes.length, - ), - ), - ], - ); - } - if (snapshot.hasError) { - return Center( - //button - child: ElevatedButton( - onPressed: () {}, - child: Text(snapshot.error.toString(), - style: const TextStyle( - fontSize: 20, color: Colors.white)), - )); - } - return const Text("waiting for stream trigger ", - style: TextStyle(fontSize: 20)); - }, - ), - ), - if (_newPostsAvailable) - Container( - margin: const EdgeInsets.only(top: 20), - child: newPostsAvailable( - name: "new posts", - onPressed: () { - _integrateNewNotes(); - }), - ), - ], - ); - } - return const Center(child: Text('Error')); - }, - ); - } -} diff --git a/lib/routes/nostr/nostr_page/nostr_page.dart b/lib/routes/nostr/nostr_page/nostr_page.dart deleted file mode 100644 index a04b087d..00000000 --- a/lib/routes/nostr/nostr_page/nostr_page.dart +++ /dev/null @@ -1,323 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:camelus/providers/metadata_provider.dart'; -import 'package:camelus/providers/relay_provider.dart'; -import 'package:camelus/routes/nostr/nostr_page/user_feed_and_replies_view.dart'; -import 'package:camelus/routes/nostr/nostr_page/user_feed_original_view.dart'; -import 'package:camelus/routes/nostr/relays_page.dart'; -import 'package:camelus/services/nostr/relays/my_relay.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; -import 'package:camelus/atoms/my_profile_picture.dart'; - -import 'package:badges/badges.dart' as badges; -import 'package:url_launcher/url_launcher.dart'; - -import '../../../config/palette.dart'; - -class NostrPage extends ConsumerStatefulWidget { - final GlobalKey parentScaffoldKey; - final String pubkey; - - const NostrPage( - {Key? key, required this.parentScaffoldKey, required this.pubkey}) - : super(key: key); - @override - ConsumerState createState() => _NostrPageState(); -} - -class _NostrPageState extends ConsumerState - with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { - @override - bool get wantKeepAlive => true; - - List followingPubkeys = []; - - late final ScrollController _scrollControllerPage = ScrollController(); - - late TabController _tabController; - - void _betaCheckForUpdates() async { - await Future.delayed(const Duration(seconds: 15)); - - // network get request to check for updates - Response response = await http - .get(Uri.parse("https://lox.de/.well-known/app-update-beta.json")); - - if (response.statusCode != 200) { - return; - } - - var updateInfo = jsonDecode(response.body); - - if (updateInfo["version"] <= 18) { - // <-- current version - - return; - } - - var title = updateInfo["title"]; - var body = updateInfo["body"]; - var url = updateInfo["url"]; - - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: Text(title), - content: Text(body), - actions: [ - TextButton( - child: const Text("cancel"), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - TextButton( - child: const Text("update"), - onPressed: () { - var u = Uri.parse(url); - launchUrl(u, mode: LaunchMode.externalApplication); - Navigator.of(context).pop(); - }, - ), - ], - ); - }); - } - - _openRelaysView() { - Navigator.of(context).push( - PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => const RelaysPage(), - transitionsBuilder: (context, animation, secondaryAnimation, child) { - var begin = const Offset(0.0, 0.2); - var end = Offset.zero; - var curve = Curves.linear; - - var tween = - Tween(begin: begin, end: end).chain(CurveTween(curve: curve)); - - return ScaleTransition( - alignment: Alignment.topRight, - scale: animation, - child: SlideTransition( - position: animation.drive(tween), - child: child, - ), - ); - }, - ), - ); - } - - @override - void initState() { - super.initState(); - - _betaCheckForUpdates(); - - _tabController = TabController(length: 2, vsync: this); - - _tabController.addListener(() { - if (_tabController.indexIsChanging) {} - }); - - _tabController.animation?.addListener(() { - if ((_tabController.offset >= 1 || _tabController.offset <= -1)) { - //log("animation tab changed to ${_tabController.index}"); - } - if ((_tabController.offset >= 0.5 || _tabController.offset <= -0.5)) { - //log("0,5###: ${_tabController.index}"); - } - }); - - _scrollControllerPage.addListener(() { - //log("scrolling page"); - }); - } - - @override - void dispose() { - _scrollControllerPage.dispose(); - - _tabController.dispose(); - - super.dispose(); - } - - @override - Widget build(BuildContext context) { - super.build(context); - var metadata = ref.watch(metadataProvider); - var myRelays = ref.watch(relayServiceProvider); - - return Scaffold( - backgroundColor: Palette.background, - appBar: null, - body: SafeArea( - child: NestedScrollView( - headerSliverBuilder: (context, value) { - return [ - SliverAppBar( - floating: false, - snap: false, - pinned: false, - elevation: 1.0, - backgroundColor: Palette.background, - leading: InkWell( - onTap: () => - widget.parentScaffoldKey.currentState!.openDrawer(), - child: Container( - margin: const EdgeInsets.fromLTRB(10, 10, 10, 10), - decoration: const BoxDecoration( - color: Palette.primary, - shape: BoxShape.circle, - ), - child: FutureBuilder( - future: metadata.getMetadataByPubkey(widget.pubkey), - builder: (BuildContext context, - AsyncSnapshot snapshot) { - var picture = ""; - var defaultPicture = - "https://avatars.dicebear.com/api/personas/${widget.pubkey}.svg"; - if (snapshot.hasData) { - picture = - snapshot.data?["picture"] ?? defaultPicture; - } else if (snapshot.hasError) { - picture = defaultPicture; - } else { - // loading - picture = defaultPicture; - } - - return myProfilePicture( - pictureUrl: picture, - pubkey: widget.pubkey, - filterQuality: FilterQuality.medium, - ); - }), - ), - ), - centerTitle: true, - title: GestureDetector( - onTap: () => {}, - child: badges.Badge( - animationType: badges.BadgeAnimationType.fade, - toAnimate: false, - showBadge: false, - badgeColor: Palette.primary, - badgeContent: const Text( - "", - style: TextStyle(color: Colors.white), - ), - child: GestureDetector( - onTap: () {}, - child: const Text( - "camelus - nostr", - style: TextStyle( - color: Palette.lightGray, - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - )), - ), - actions: [ - GestureDetector( - onTap: () => _openRelaysView(), - child: StreamBuilder>( - stream: myRelays.relaysStream, - initialData: myRelays.relays, - builder: (context, snapshot) { - if (!snapshot.hasData || snapshot.data!.isEmpty) { - return Row( - children: [ - SvgPicture.asset( - 'assets/icons/cell-signal-slash.svg', - color: Palette.gray, - height: 22, - width: 22, - ), - const SizedBox(width: 5), - if (!kReleaseMode) - Text( - "0".toString(), - style: const TextStyle( - color: Palette.lightGray), - ), - const SizedBox(width: 5), - ], - ); - } else { - return Row( - children: [ - SvgPicture.asset( - 'assets/icons/cell-signal-full.svg', - color: Palette.gray, - height: 22, - width: 22, - ), - const SizedBox(width: 5), - // check if dev build - if (!kReleaseMode) - Text( - // count how many relays are ready - snapshot.data! - .where((element) => element.connected) - .length - .toString(), - style: const TextStyle( - color: Palette.lightGray), - ), - const SizedBox(width: 5), - ], - ); - } - }), - ), - ], - bottom: PreferredSize( - preferredSize: const Size.fromHeight(20), - child: TabBar( - controller: _tabController, - indicatorColor: Palette.primary, - indicatorSize: TabBarIndicatorSize.label, - automaticIndicatorColorAdjustment: true, - indicator: const UnderlineTabIndicator( - borderRadius: BorderRadius.all(Radius.circular(10)), - borderSide: BorderSide( - width: 2, - color: Palette.primary, - ), - ), - indicatorWeight: 5.0, - tabs: const [ - Text("feed", style: TextStyle(color: Palette.lightGray)), - Text("feed & replies", - style: TextStyle(color: Palette.lightGray)), - ], - ), - ), - ), - ]; - }, - body: TabBarView( - controller: _tabController, - physics: const BouncingScrollPhysics(), - children: [ - UserFeedOriginalView(pubkey: widget.pubkey), - UserFeedAndRepliesView(pubkey: widget.pubkey), - ], - ), - ), - ), - ); - } -} diff --git a/lib/routes/nostr/nostr_page/user_feed_and_replies_view.dart b/lib/routes/nostr/nostr_page/user_feed_and_replies_view.dart deleted file mode 100644 index 14a785a1..00000000 --- a/lib/routes/nostr/nostr_page/user_feed_and_replies_view.dart +++ /dev/null @@ -1,313 +0,0 @@ -import 'dart:async'; -import 'dart:developer'; - -import 'package:camelus/atoms/new_posts_available.dart'; -import 'package:camelus/atoms/refresh_indicator_no_need.dart'; -import 'package:camelus/components/note_card/note_card_container.dart'; -import 'package:camelus/config/palette.dart'; -import 'package:camelus/db/database.dart'; -import 'package:camelus/models/nostr_note.dart'; -import 'package:camelus/providers/database_provider.dart'; -import 'package:camelus/providers/following_provider.dart'; -import 'package:camelus/providers/navigation_bar_provider.dart'; -import 'package:camelus/providers/relay_provider.dart'; -import 'package:camelus/scroll_controller/retainable_scroll_controller.dart'; -import 'package:camelus/services/nostr/feeds/user_and_replies_feed.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -class UserFeedAndRepliesView extends ConsumerStatefulWidget { - final String pubkey; - - const UserFeedAndRepliesView({Key? key, required this.pubkey}) - : super(key: key); - - @override - UserFeedAndRepliesViewState createState() => UserFeedAndRepliesViewState(); -} - -class UserFeedAndRepliesViewState - extends ConsumerState { - late AppDatabase db; - final List _subscriptions = []; - late List _followingPubkeys; - late UserFeedAndRepliesFeed _userFeedAndRepliesFeed; - final Completer _servicesReady = Completer(); - - late final RetainableScrollController _scrollControllerFeed = - RetainableScrollController(); - bool _newPostsAvailable = false; - - final String userFeedFreshId = "fresh"; - final String userFeedTimelineFetchId = "timeline"; - - NostrNote? _lastNoteInFeed; - - void _setupScrollListener() { - _scrollControllerFeed.addListener(() { - if (_scrollControllerFeed.position.pixels == - _scrollControllerFeed.position.maxScrollExtent) { - log("reached end of scroll"); - - var latest = _userFeedAndRepliesFeed.feed.last.created_at; - - _userFeedLoadMore(latest); - } - - if (_scrollControllerFeed.position.pixels < 100) { - // disable after sroll - // if (_newPostsAvailable) { - // setState(() { - // _newPostsAvailable = false; - // }); - // } - } - }); - } - - void _setupNewNotesListener() { - _subscriptions.add( - _userFeedAndRepliesFeed.newNotesStream.listen((event) { - log("new notes stream event"); - setState(() { - _newPostsAvailable = true; - }); - // notify navigation bar - ref.read(navigationBarProvider).newNotesCount = event.length; - }), - ); - } - - void _setupNavBarHomeListener() { - var provider = ref.read(navigationBarProvider); - _subscriptions.add(provider.onTabHome.listen((event) { - _handleHomeBarTab(); - })); - } - - void _handleHomeBarTab() { - if (_newPostsAvailable) { - _integrateNewNotes(); - } - ref.watch(navigationBarProvider).resetNewNotesCount(); - // scroll to top - _scrollControllerFeed.animateTo( - _scrollControllerFeed.position.minScrollExtent, - duration: const Duration(milliseconds: 500), - curve: Curves.easeOutCubic, - ); - } - - void _integrateNewNotes() { - _userFeedAndRepliesFeed.integrateNewNotes(); - _scrollControllerFeed.animateTo( - _scrollControllerFeed.position.minScrollExtent, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - setState(() { - _newPostsAvailable = false; - }); - ref.watch(navigationBarProvider).resetNewNotesCount(); - } - - void _initUserFeed() { - int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - int latestTweet = now - 86400; // -1 day - - // add own pubkey to list - var combinedPubkeys = [ - ..._followingPubkeys, - widget.pubkey, - ]; - - _userFeedAndRepliesFeed.requestRelayUserFeedAndReplies( - users: combinedPubkeys, - requestId: userFeedFreshId, - limit: 15, - since: latestTweet, - ); - } - - void _userFeedLoadMore(int? until) async { - log("load more called"); - - if (_followingPubkeys.isEmpty) { - log("!!! no following users found !!!"); - return; - } - // add own pubkey to list - var combinedPubkeys = [ - ..._followingPubkeys, - widget.pubkey, - ]; - - int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - // schould not be needed - int defaultUntil = now - 86400 * 1; // -1 day - - _userFeedAndRepliesFeed.requestRelayUserFeedAndReplies( - users: combinedPubkeys, - requestId: userFeedTimelineFetchId, - limit: 5, - until: until ?? defaultUntil, - ); - } - - void _hashtagFeedCheckForNewData(NostrNote currentBuilNote) { - var latestSessionNote = _userFeedAndRepliesFeed.oldestNoteInSession; - if (latestSessionNote == null) { - return; - } - if (latestSessionNote.id == currentBuilNote.id) { - _userFeedLoadMore(currentBuilNote.created_at); - } - } - - Future _getFollowingPubkeys() async { - var followingP = ref.read(followingProvider); - await followingP.servicesReady; - - _followingPubkeys = followingP.ownContacts.map((e) => e.value).toList(); - return; - } - - Future _initDb() async { - db = await ref.read(databaseProvider.future); - return; - } - - Future _initSequence() async { - log("init sequence"); - - await _initDb(); - await _getFollowingPubkeys(); - - var relayCoordinator = ref.watch(relayServiceProvider); - - _userFeedAndRepliesFeed = - UserFeedAndRepliesFeed(db, _followingPubkeys, relayCoordinator); - await _userFeedAndRepliesFeed.feedRdy; - - _servicesReady.complete(); - - // reset home bar new notes count - ref.watch(navigationBarProvider).resetNewNotesCount(); - - _initUserFeed(); - _setupScrollListener(); - _setupNewNotesListener(); - _setupNavBarHomeListener(); - - return; - } - - @override - void initState() { - super.initState(); - _initSequence(); - } - - @override - void dispose() { - _userFeedAndRepliesFeed.cleanup(); - _disposeSubscriptions(); - super.dispose(); - } - - void _disposeSubscriptions() { - for (var s in _subscriptions) { - s.cancel(); - } - } - - @override - Widget build(BuildContext context) { - return FutureBuilder( - future: _servicesReady.future, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center( - child: CircularProgressIndicator( - color: Palette.white, - )); - } - if (snapshot.hasError) { - return const Center(child: Text('Error')); - } - - if (snapshot.connectionState == ConnectionState.done) { - return Stack( - children: [ - RefreshIndicatorNoNeed( - onRefresh: () { - return Future.delayed(const Duration(milliseconds: 0)); - }, - child: StreamBuilder>( - stream: _userFeedAndRepliesFeed.feedStream, - initialData: _userFeedAndRepliesFeed.feed, - builder: (BuildContext context, - AsyncSnapshot> snapshot) { - if (snapshot.hasData) { - var notes = snapshot.data!; - - if (notes.isEmpty) { - return const Center( - child: Text("no notes found", - style: TextStyle( - fontSize: 20, color: Palette.white)), - ); - } - - _lastNoteInFeed = notes.last; - - return CustomScrollView( - physics: const BouncingScrollPhysics(), - controller: _scrollControllerFeed, - slivers: [ - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - var note = notes[index]; - _hashtagFeedCheckForNewData(note); - return NoteCardContainer(notes: [note]); - }, - childCount: notes.length, - ), - ), - ], - ); - } - if (snapshot.hasError) { - return Center( - //button - child: ElevatedButton( - onPressed: () {}, - child: Text(snapshot.error.toString(), - style: const TextStyle( - fontSize: 20, color: Colors.white)), - )); - } - return const Text("waiting for stream trigger ", - style: TextStyle(fontSize: 20)); - }, - ), - ), - if (_newPostsAvailable) - Container( - margin: const EdgeInsets.only(top: 20), - child: newPostsAvailable( - name: "new posts", - onPressed: () { - _integrateNewNotes(); - }), - ), - ], - ); - } - return const Center(child: Text('Error')); - }, - ); - } -} diff --git a/lib/routes/nostr/nostr_page/user_feed_original_view.dart b/lib/routes/nostr/nostr_page/user_feed_original_view.dart deleted file mode 100644 index 4ffc4278..00000000 --- a/lib/routes/nostr/nostr_page/user_feed_original_view.dart +++ /dev/null @@ -1,299 +0,0 @@ -import 'dart:async'; -import 'dart:developer'; -import 'package:camelus/atoms/new_posts_available.dart'; -import 'package:camelus/atoms/refresh_indicator_no_need.dart'; -import 'package:camelus/components/note_card/note_card_container.dart'; -import 'package:camelus/config/palette.dart'; -import 'package:camelus/db/database.dart'; -import 'package:camelus/models/nostr_note.dart'; -import 'package:camelus/providers/database_provider.dart'; -import 'package:camelus/providers/following_provider.dart'; -import 'package:camelus/providers/navigation_bar_provider.dart'; -import 'package:camelus/providers/relay_provider.dart'; -import 'package:camelus/scroll_controller/retainable_scroll_controller.dart'; -import 'package:camelus/services/nostr/feeds/user_feed.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -class UserFeedOriginalView extends ConsumerStatefulWidget { - final String pubkey; - - const UserFeedOriginalView({Key? key, required this.pubkey}) - : super(key: key); - - @override - ConsumerState createState() => - _UserFeedOriginalViewState(); -} - -class _UserFeedOriginalViewState extends ConsumerState { - late AppDatabase db; - final List _subscriptions = []; - late List _followingPubkeys; - late UserFeed _userFeed; - final Completer _servicesReady = Completer(); - - late final RetainableScrollController _scrollControllerFeed = - RetainableScrollController(); - bool _newPostsAvailable = false; - - final String userFeedFreshId = "fresh"; - final String userFeedTimelineFetchId = "timeline"; - - NostrNote? _lastNoteInFeed; - - void _setupScrollListener() { - _scrollControllerFeed.addListener(() { - if (_scrollControllerFeed.position.pixels == - _scrollControllerFeed.position.maxScrollExtent) { - log("reached end of scroll"); - - var latest = _userFeed.feed.last.created_at; //- 86400 * 7; // -1 week - - _userFeedLoadMore(latest); - } - - if (_scrollControllerFeed.position.pixels < 100) { - // disable after sroll - // if (_newPostsAvailable) { - // setState(() { - // _newPostsAvailable = false; - // }); - // } - } - }); - } - - void _setupNewNotesListener() { - _subscriptions.add( - _userFeed.newNotesStream.listen((event) { - log("new notes stream event"); - setState(() { - _newPostsAvailable = true; - }); - // notify navigation bar - ref.read(navigationBarProvider).newNotesCount = event.length; - }), - ); - } - - void _setupNavBarHomeListener() { - var provider = ref.read(navigationBarProvider); - _subscriptions.add(provider.onTabHome.listen((event) { - _handleHomeBarTab(); - })); - } - - void _handleHomeBarTab() { - if (_newPostsAvailable) { - _integrateNewNotes(); - } - ref.watch(navigationBarProvider).resetNewNotesCount(); - // scroll to top - _scrollControllerFeed.animateTo( - _scrollControllerFeed.position.minScrollExtent, - duration: const Duration(milliseconds: 500), - curve: Curves.easeOutCubic, - ); - } - - void _integrateNewNotes() { - _userFeed.integrateNewNotes(); - _scrollControllerFeed.animateTo( - _scrollControllerFeed.position.minScrollExtent, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - setState(() { - _newPostsAvailable = false; - }); - ref.watch(navigationBarProvider).resetNewNotesCount(); - } - - void _initUserFeed() { - int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - int latestTweet = now - 86400; // -1 day - - _userFeed.requestRelayUserFeed( - users: _followingPubkeys, - requestId: userFeedFreshId, - limit: 15, - since: latestTweet, - ); - } - - void _userFeedLoadMore(int? until) async { - log("load more called"); - - if (_followingPubkeys.isEmpty) { - log("!!! no following users found !!!"); - return; - } - - int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - // schould not be needed - int defaultUntil = now - 86400 * 1; // -1 day - - _userFeed.requestRelayUserFeed( - users: _followingPubkeys, - requestId: userFeedTimelineFetchId, - limit: 5, - until: until ?? defaultUntil, - ); - } - - void _userFeedCheckForNewData(NostrNote currentBuilNote) { - var latestSessionNote = _userFeed.oldestNoteInSession; - if (latestSessionNote == null) { - return; - } - if (latestSessionNote.id == currentBuilNote.id) { - _userFeedLoadMore(currentBuilNote.created_at); - } - } - - Future _getFollowingPubkeys() async { - var followingP = ref.read(followingProvider); - await followingP.servicesReady; - - _followingPubkeys = followingP.ownContacts.map((e) => e.value).toList(); - _followingPubkeys = [..._followingPubkeys, widget.pubkey]; // add own pubkey - return; - } - - Future _initDb() async { - db = await ref.read(databaseProvider.future); - return; - } - - Future _initSequence() async { - await _initDb(); - await _getFollowingPubkeys(); - - var relayCoordinator = ref.watch(relayServiceProvider); - - _userFeed = UserFeed(db, _followingPubkeys, relayCoordinator); - await _userFeed.feedRdy; - - _servicesReady.complete(); - - // reset home bar new notes count - ref.watch(navigationBarProvider).resetNewNotesCount(); - - _initUserFeed(); - _setupScrollListener(); - _setupNewNotesListener(); - _setupNavBarHomeListener(); - - return; - } - - @override - void initState() { - super.initState(); - _initSequence(); - } - - @override - void dispose() { - _userFeed.cleanup(); - _disposeSubscriptions(); - super.dispose(); - } - - void _disposeSubscriptions() { - for (var s in _subscriptions) { - s.cancel(); - } - } - - @override - Widget build(BuildContext context) { - return FutureBuilder( - future: _servicesReady.future, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center( - child: CircularProgressIndicator( - color: Palette.white, - )); - } - if (snapshot.hasError) { - return const Center(child: Text('Error')); - } - - if (snapshot.connectionState == ConnectionState.done) { - return Stack( - children: [ - RefreshIndicatorNoNeed( - onRefresh: () { - return Future.delayed(const Duration(milliseconds: 0)); - }, - child: StreamBuilder>( - stream: _userFeed.feedStream, - initialData: _userFeed.feed, - builder: (BuildContext context, - AsyncSnapshot> snapshot) { - if (snapshot.hasData) { - var notes = snapshot.data!; - - if (notes.isEmpty) { - return const Center( - child: Text("no notes found", - style: TextStyle( - fontSize: 20, color: Palette.white)), - ); - } - - _lastNoteInFeed = notes.last; - - return CustomScrollView( - physics: const BouncingScrollPhysics(), - controller: _scrollControllerFeed, - slivers: [ - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - var note = notes[index]; - _userFeedCheckForNewData(note); - return NoteCardContainer(notes: [note]); - }, - childCount: notes.length, - ), - ), - ], - ); - } - if (snapshot.hasError) { - return Center( - //button - child: ElevatedButton( - onPressed: () {}, - child: Text(snapshot.error.toString(), - style: const TextStyle( - fontSize: 20, color: Colors.white)), - )); - } - return const Text("waiting for stream trigger ", - style: TextStyle(fontSize: 20)); - }, - ), - ), - if (_newPostsAvailable) - Container( - margin: const EdgeInsets.only(top: 20), - child: newPostsAvailable( - name: "new posts", - onPressed: () { - _integrateNewNotes(); - }), - ), - ], - ); - } - return const Center(child: Text('Error')); - }, - ); - } -} diff --git a/lib/routes/nostr/onboarding/onboarding.dart b/lib/routes/nostr/onboarding/onboarding.dart deleted file mode 100644 index a9e5e055..00000000 --- a/lib/routes/nostr/onboarding/onboarding.dart +++ /dev/null @@ -1,329 +0,0 @@ -import 'dart:convert'; - -import 'package:camelus/providers/key_pair_provider.dart'; -import 'package:camelus/providers/nostr_service_provider.dart'; -import 'package:camelus/routes/home_page.dart'; -import 'package:flutter/material.dart'; -import 'package:camelus/config/palette.dart'; -import 'package:camelus/helpers/bip340.dart'; -import 'package:flutter/services.dart'; -import 'package:camelus/helpers/helpers.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:camelus/services/nostr/nostr_service.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class NostrOnboarding extends ConsumerStatefulWidget { - const NostrOnboarding({Key? key}) : super(key: key); - - @override - ConsumerState createState() => _NostrOnboardingState(); -} - -class _NostrOnboardingState extends ConsumerState { - late NostrService _nostrService; - var myKeys = Bip340().generatePrivateKey(); - - bool _termsAndConditions = false; - - void _initNostrService() { - _nostrService = ref.read(nostrServiceProvider); - } - - @override - void initState() { - super.initState(); - _initNostrService(); - } - - @override - void dispose() { - super.dispose(); - } - - Future copyToClipboard(String data) async { - await Clipboard.setData(ClipboardData(text: data)); - } - - void _pasteFromClipboard() async { - final data = await Clipboard.getData(Clipboard.kTextPlain); - //check if data starts not with with nsec - if (data == null || !(data.text!.startsWith('nsec'))) { - showPasteError(); - return; - } - - var privkey = Helpers().decodeBech32(data.text!)[0]; - var pubkey = Bip340().getPublicKey(privkey); - var privKeyHr = data.text!; - var publicKeyHr = Helpers().encodeBech32(pubkey, 'npub'); - - setState(() { - myKeys = KeyPair(privkey, pubkey, privKeyHr, publicKeyHr); - }); - showPasteSuccess(); - } - - void showPasteSuccess() { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Private key successfully imported'), - ), - ); - } - - void showPasteError() { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Invalid private key'), - ), - ); - } - - _onSubmit() async { - if (!_termsAndConditions) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: - Text('Please read and accept the terms and conditions first'), - ), - ); - return; - } - - // store in secure storage - const storage = FlutterSecureStorage(); - storage.write(key: "nostrKeys", value: json.encode(myKeys.toJson())); - // save in provider - - var provider = await ref.watch(keyPairProvider.future); - provider.setKeyPair(myKeys); - - setState(() {}); - - // ignore: use_build_context_synchronously - Navigator.pushReplacement(context, MaterialPageRoute(builder: (context) { - return HomePage(pubkey: myKeys.publicKey); - })); - - //Navigator.popAndPushNamed(context, '/'); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: null, - backgroundColor: Palette.background, - body: SafeArea( - // input for the user to enter their private key, should be visible on a dark background. - child: Container( - width: MediaQuery.of(context).size.width, - height: MediaQuery.of(context).size.height, - padding: const EdgeInsets.all(30), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Spacer(), - // the logo - const Text( - "camelus", - style: TextStyle( - color: Palette.white, - fontSize: 45, - fontWeight: FontWeight.bold), - ), - const SizedBox(height: 20), - // the title - const Text( - "early preview", - style: TextStyle( - color: Palette.white, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 20), - // the subtitle - - const SizedBox(height: 20), - // the input field - - const Text( - "This is your private key:", - style: TextStyle( - color: Palette.white, - fontSize: 24, - fontWeight: FontWeight.normal, - ), - ), - const SizedBox(height: 20), - Container( - width: MediaQuery.of(context).size.width, - height: 60, - decoration: BoxDecoration( - color: Palette.white, - borderRadius: BorderRadius.circular(10), - ), - child: Center( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: GestureDetector( - onTap: () { - copyToClipboard(myKeys.privateKeyHr); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('private key copied to clipboard'), - duration: Duration(seconds: 3), - ), - ); - }, - child: Text( - myKeys.privateKeyHr, - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.black, - fontSize: 15, - fontWeight: FontWeight.normal, - ), - ), - ), - ), - ), - ), - - const SizedBox(height: 10), - const Text( - "keep it safe and secret!", - style: TextStyle( - color: Palette.white, - fontSize: 12, - fontWeight: FontWeight.normal, - ), - ), - const SizedBox(height: 50), - // checkbox to accept the privacy policy - - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - ElevatedButton( - onPressed: () { - _pasteFromClipboard(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Palette.background, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - side: const BorderSide(color: Palette.white, width: 1), - ), - ), - child: const Text( - 'paste', - style: TextStyle( - color: Palette.white, - fontSize: 16, - ), - ), - ), - ElevatedButton( - onPressed: () { - _onSubmit(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Palette.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - side: const BorderSide( - color: Palette.background, width: 1), - ), - ), - child: const Text( - 'next', - style: TextStyle( - color: Palette.background, - fontSize: 16, - ), - ), - ), - ], - ), - - const SizedBox(height: 15), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Checkbox( - value: _termsAndConditions, - onChanged: (value) { - setState(() { - _termsAndConditions = value!; - }); - }, - activeColor: Palette.white, - checkColor: Palette.black, - fillColor: MaterialStateProperty.all(Palette.white), - //overlayColor: MaterialStateProperty.all(Palette.primary), - ), - const Text( - "I have read and accept the ", - style: TextStyle( - color: Palette.white, - fontSize: 12, - fontWeight: FontWeight.normal, - ), - ), - GestureDetector( - onTap: () { - Uri url = Uri.parse("https://camelus.app/terms/"); - launchUrl(url, mode: LaunchMode.externalApplication); - }, - child: const Text( - "terms and conditions", - style: TextStyle( - color: Palette.white, - fontSize: 12, - fontWeight: FontWeight.bold, - decoration: TextDecoration.underline, - ), - ), - ), - ], - ), - - const SizedBox(height: 5), - GestureDetector( - onTap: () { - Uri url = Uri.parse("https://camelus.app/privacy/"); - launchUrl(url, mode: LaunchMode.externalApplication); - }, - child: const Text( - "privacy policy", - style: TextStyle( - color: Palette.white, - fontSize: 12, - fontWeight: FontWeight.bold, - decoration: TextDecoration.underline, - ), - ), - ), - - const Spacer(), - - // the bottom text - const Text( - "This is a very early version of the app, use at your own risk. \n\nI would not recommend using this app with your personal keys. Just use the generated ones for testing.", - style: TextStyle( - color: Palette.gray, - fontSize: 12, - fontWeight: FontWeight.normal, - ), - ), - const SizedBox(height: 20), - ], - ), - ), - ), - ); - } -} diff --git a/lib/routes/nostr/profile/edit_relays_page.dart b/lib/routes/nostr/profile/edit_relays_page.dart deleted file mode 100644 index 193e0e2f..00000000 --- a/lib/routes/nostr/profile/edit_relays_page.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'dart:convert'; - -import 'package:camelus/components/edit_relays_view.dart'; -import 'package:camelus/models/nostr_tag.dart'; -import 'package:camelus/providers/following_provider.dart'; -import 'package:camelus/services/nostr/relays/relay_address_parser.dart'; - -import 'package:flutter/material.dart'; -import 'package:camelus/config/palette.dart'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -class EditRelaysPage extends ConsumerStatefulWidget { - const EditRelaysPage({Key? key}) : super(key: key); - - @override - ConsumerState createState() => _EditRelaysPageState(); -} - -class _EditRelaysPageState extends ConsumerState { - @override - void initState() { - super.initState(); - } - - @override - void dispose() { - super.dispose(); - } - - Future onSave(Map> changedRelays) async { - Map> parsedMap = {}; - // parse all relays - for (var relay in changedRelays.entries) { - var parsedKey = RelayAddressParser.parseAddress(relay.key); - parsedMap[parsedKey] = relay.value; - } - - var contacts = _saveInContacts(parsedMap); - var nip65 = _saveNip65(parsedMap); - - await Future.wait([contacts, nip65]); - - return; - } - - Future _saveInContacts(Map> changedRelays) async { - var followingService = ref.read(followingProvider); - - String myUpdatedContent = jsonEncode(changedRelays); - - await followingService.updateContent(myUpdatedContent); - } - - Future _saveNip65(Map> changedRelays) async { - //["r", "wss://alicerelay.example.com"], - //["r", "wss://brando-relay.com"], - //["r", "wss://expensive-relay.example2.com", "write"], - //["r", "wss://nostr-relay.example.com", "read"], - - var newNip65Tags = List.empty(growable: true); - - for (var relay in changedRelays.entries) { - if (relay.value['read'] == true && relay.value['write'] == true) { - newNip65Tags.add(NostrTag( - type: 'r', - value: relay.key, - )); - } else if (relay.value['read'] == true) { - newNip65Tags.add(NostrTag( - type: 'r', - value: relay.key, - recommended_relay: 'read', - )); - } else if (relay.value['write'] == true) { - newNip65Tags.add(NostrTag( - type: 'w', - value: relay.key, - recommended_relay: 'write', - )); - } - } - var followingService = ref.read(followingProvider); - await followingService.publishNip65(newNip65Tags); - - return; - } - - @override - Widget build(BuildContext context) { - return WillPopScope( - onWillPop: () async { - return true; - }, - child: Scaffold( - backgroundColor: Palette.background, - appBar: AppBar( - title: const Text('Edit Relays'), - backgroundColor: Palette.background, - foregroundColor: Palette.lightGray, - ), - // show loading indicator when reconnecting - body: EditRelaysView( - onSave: onSave, - ), - ), - ); - } -} diff --git a/lib/routes/nostr/profile/follower_page.dart b/lib/routes/nostr/profile/follower_page.dart deleted file mode 100644 index 2fc4c9ab..00000000 --- a/lib/routes/nostr/profile/follower_page.dart +++ /dev/null @@ -1,141 +0,0 @@ -import 'package:camelus/atoms/person_card.dart'; -import 'package:camelus/models/nostr_request_event.dart'; -import 'package:camelus/models/nostr_tag.dart'; -import 'package:camelus/providers/database_provider.dart'; -import 'package:camelus/providers/following_provider.dart'; -import 'package:camelus/providers/key_pair_provider.dart'; -import 'package:camelus/providers/metadata_provider.dart'; -import 'package:camelus/providers/relay_provider.dart'; -import 'package:flutter/material.dart'; -import 'package:camelus/config/palette.dart'; -import 'package:camelus/routes/nostr/profile/profile_page.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -class FollowerPage extends ConsumerStatefulWidget { - final String title; - final List contacts; - - const FollowerPage({ - Key? key, - required this.title, - required this.contacts, - }) : super(key: key); - - @override - ConsumerState createState() => _FollowerPageState(); -} - -class _FollowerPageState extends ConsumerState { - /// follow Change - true to add, false to remove - void _changeFollowing(bool followChange, String pubkey, - List currentOwnContacts) async { - var mykeys = await ref.watch(keyPairProvider.future); - var db = await ref.watch(databaseProvider.future); - - var myLastNote = - (await db.noteDao.findPubkeyNotesByKind([mykeys.keyPair!.publicKey], 3)) - .first; - - List newContacts = [...currentOwnContacts]; - - if (followChange) { - newContacts.add(NostrTag(type: 'p', value: pubkey)); - } else { - newContacts.removeWhere((element) => element.value == pubkey); - } - - _writeContacts( - publicKey: mykeys.keyPair!.publicKey, - privateKey: mykeys.keyPair!.privateKey, - content: myLastNote.content, - updatedContacts: newContacts, - ); - } - - Future _writeContacts({ - required String publicKey, - required String privateKey, - required String content, - required List updatedContacts, - }) async { - var relays = ref.watch(relayServiceProvider); - NostrRequestEventBody body = NostrRequestEventBody( - pubkey: publicKey, - privateKey: privateKey, - content: content, - kind: 3, - tags: updatedContacts, - ); - NostrRequestEvent myEvent = NostrRequestEvent(body: body); - - await relays.write(request: myEvent); - - return; - } - - @override - void initState() { - super.initState(); - } - - @override - void dispose() { - super.dispose(); - } - - @override - Widget build(BuildContext context) { - var metadata = ref.watch(metadataProvider); - var followingService = ref.watch(followingProvider); - return Scaffold( - backgroundColor: Palette.background, - appBar: AppBar( - backgroundColor: Palette.background, - title: Text(widget.title), - foregroundColor: Palette.white, - ), - body: StreamBuilder>( - stream: followingService.ownPubkeyContactsStreamDb, - initialData: followingService.ownContacts, - builder: (context, ownFollowingSnapshot) { - return ListView.builder( - physics: const BouncingScrollPhysics(), - itemCount: widget.contacts.length, - itemBuilder: (context, index) { - var displayPubkey = widget.contacts[index].value; - return FutureBuilder>( - future: metadata.getMetadataByPubkey(displayPubkey), - builder: (BuildContext context, metadataSnapshot) { - return PersonCard( - pubkey: displayPubkey, - name: metadataSnapshot.data?["name"] ?? "", - pictureUrl: metadataSnapshot.data?["picture"] ?? "", - about: metadataSnapshot.data?["about"] ?? "", - nip05: metadataSnapshot.data?["nip05"] ?? "", - isFollowing: ownFollowingSnapshot.data! - .any((element) => element.value == displayPubkey), - onTap: () { - // navigate to profile page - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ProfilePage( - pubkey: displayPubkey, - ), - ), - ); - }, - onFollowTab: (followState) { - _changeFollowing( - followState, - displayPubkey, - ownFollowingSnapshot.data!, - ); - }, - ); - }); - }); - }), - ); - } -} diff --git a/lib/routes/nostr/relays_page.dart b/lib/routes/nostr/relays_page.dart deleted file mode 100644 index 2422e4d5..00000000 --- a/lib/routes/nostr/relays_page.dart +++ /dev/null @@ -1,260 +0,0 @@ -import 'package:camelus/config/palette.dart'; -import 'package:camelus/providers/nostr_service_provider.dart'; -import 'package:camelus/providers/relay_provider.dart'; -import 'package:camelus/services/nostr/nostr_service.dart'; -import 'package:camelus/services/nostr/relays/my_relay.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -class RelaysPage extends ConsumerStatefulWidget { - const RelaysPage({Key? key}) : super(key: key); - - @override - ConsumerState createState() => _RelaysPageState(); -} - -class _RelaysPageState extends ConsumerState { - late NostrService _nostrService; - - void _initNostrService() { - _nostrService = ref.read(nostrServiceProvider); - } - - @override - void initState() { - super.initState(); - _initNostrService(); - } - - @override - Widget build(BuildContext context) { - var myRelays = ref.watch(relayServiceProvider); - return Scaffold( - backgroundColor: Palette.background, - body: SafeArea( - child: Column( - //mainAxisAlignment: MainAxisAlignment.start, - children: [ - const SizedBox(height: 4.0), - // close x icon - Padding( - padding: const EdgeInsets.fromLTRB(18.0, 16.0, 0.0, 0.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - // big text title - const Padding( - padding: EdgeInsets.only(left: 8.0, top: 4), - child: Text("relays", - style: TextStyle( - color: Palette.white, - fontSize: 38.0, - fontWeight: FontWeight.w500, - )), - ), - GestureDetector( - onTap: () { - Navigator.pop(context); - }, - child: Padding( - padding: const EdgeInsets.only(right: 18.0), - child: SvgPicture.asset( - 'assets/icons/x.svg', - color: Palette.white, - height: 27, - width: 27, - ), - ), - ), - ], - ), - ), - const Center( - child: Text('Relays'), - ), - //const SizedBox(height: 30.0), - - Padding( - padding: const EdgeInsets.fromLTRB(12.0, 0.0, 12.0, 0.0), - child: SizedBox( - height: MediaQuery.of(context).size.height - 140, - child: Column( - children: [ - StreamBuilder>( - initialData: myRelays.relays, - stream: myRelays.relaysStream, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator()); - } - return SizedBox( - height: MediaQuery.of(context).size.height - 400, - child: ListView.builder( - itemCount: snapshot.data!.length, - itemBuilder: (context, index) { - return Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - snapshot.data![index].relayUrl, - style: const TextStyle( - color: Palette.white, - fontSize: 14.0, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(width: 12.0), - Expanded( - child: Text( - "conn: ${snapshot.data![index].connected.toString()}", - textAlign: TextAlign.start, - style: const TextStyle( - color: Palette.white, - fontSize: 14.0, - fontWeight: FontWeight.w400, - ), - ), - ), - const SizedBox(width: 12.0), - ], - ), - const SizedBox(height: 2.0), - Text( - "read ${snapshot.data![index].read.toString()}", - style: const TextStyle( - color: Palette.white, - fontSize: 14.0, - fontWeight: FontWeight.w400, - )), - Text( - "write ${snapshot.data![index].write.toString()}", - style: const TextStyle( - color: Palette.white, - fontSize: 14.0, - fontWeight: FontWeight.w400, - )), - Text( - "persistance ${snapshot.data![index].persistance.toString()}", - style: const TextStyle( - color: Palette.white, - fontSize: 14.0, - fontWeight: FontWeight.bold, - )), - const SizedBox(height: 25.0), - ], - ); - }, - ), - ); - }, - ), - const SizedBox(height: 60.0), - _explainerText(), - ], - ), - ), - ), - ], - ), - ), - ); - } -} - -Widget _explainerText() { - return const Padding( - padding: EdgeInsets.fromLTRB(8.0, 0.0, 8.0, 30.0), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "dynamic:", - style: TextStyle( - color: Palette.gray, - fontSize: 14.0, - fontWeight: FontWeight.w500, - ), - ), - SizedBox(width: 12.0), - Expanded( - child: Text( - textAlign: TextAlign.start, - 'camelus uses data on your device to determine the relays that cover the most users you are following. Therefore using less data and battery. This mode is also called gossip', - style: TextStyle( - color: Palette.gray, - fontSize: 14.0, - fontWeight: FontWeight.w400, - ), - ), - ), - ], - ), - SizedBox(height: 8.0), - Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "static:", - style: TextStyle( - color: Palette.gray, - fontSize: 14.0, - fontWeight: FontWeight.w500, - ), - ), - SizedBox(width: 30.0), - Expanded( - child: Text( - textAlign: TextAlign.start, - 'the relays you manually selected. If the gossip model works fine for you only enable these as write relays', - style: TextStyle( - color: Palette.gray, - fontSize: 14.0, - fontWeight: FontWeight.w400, - ), - ), - ), - ], - ), - SizedBox(height: 8.0), - Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "failing:", - style: TextStyle( - color: Palette.gray, - fontSize: 14.0, - fontWeight: FontWeight.w500, - ), - ), - SizedBox(width: 27.0), - Expanded( - child: Text( - textAlign: TextAlign.start, - 'relays that failed to connect', - style: TextStyle( - color: Palette.gray, - fontSize: 14.0, - fontWeight: FontWeight.w400, - ), - ), - ), - ], - ) - ], - ), - ); -} diff --git a/lib/routes/nostr/settings/settings_page.dart b/lib/routes/nostr/settings/settings_page.dart deleted file mode 100644 index 720c3995..00000000 --- a/lib/routes/nostr/settings/settings_page.dart +++ /dev/null @@ -1,90 +0,0 @@ -// stateful widget - -import 'dart:developer'; - -import 'package:camelus/config/palette.dart'; -import 'package:camelus/providers/database_provider.dart'; -import 'package:camelus/providers/nostr_service_provider.dart'; -import 'package:camelus/services/nostr/nostr_service.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -class SettingsPage extends ConsumerStatefulWidget { - const SettingsPage({Key? key}) : super(key: key); - - @override - _SettingsPageState createState() => _SettingsPageState(); -} - -class _SettingsPageState extends ConsumerState { - late NostrService _nostrService; - - void initNostrService() async { - _nostrService = await ref.read(nostrServiceProvider); - } - - @override - void initState() { - super.initState(); - initNostrService(); - } - - void _clearSqlDb() async { - var db = await ref.watch(databaseProvider.future); - db.database.delete( - "Note", - where: null, - ); - log("cleared sql db"); - } - - void _deleteKind01() async { - var db = await ref.watch(databaseProvider.future); - db.noteDao.deleteNotesByKind(1); - log("deleted kind 01"); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Palette.background, - appBar: AppBar( - title: const Text('Settings'), - backgroundColor: Palette.background, - ), - body: ListView( - children: [ - // clear cache - ListTile( - title: const Text('Clear cache', - style: TextStyle(color: Colors.white)), - onTap: () { - _nostrService.clearCache(); - }, - ), - ListTile( - title: const Text('Clear everything DANGEROUS!', - style: TextStyle(color: Colors.white)), - onTap: () { - _nostrService.clearCacheReset(); - }, - ), - ListTile( - title: const Text('Clear sql db!', - style: TextStyle(color: Colors.white)), - onTap: () { - _clearSqlDb(); - }, - ), - ListTile( - title: const Text('delete all kind01!', - style: TextStyle(color: Colors.white)), - onTap: () { - _deleteKind01(); - }, - ) - ], - ), - ); - } -} diff --git a/lib/scroll_controller/retainable_scroll_controller.dart b/lib/scroll_controller/retainable_scroll_controller.dart deleted file mode 100644 index dae8a10e..00000000 --- a/lib/scroll_controller/retainable_scroll_controller.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:flutter/material.dart'; - -class RetainableScrollController extends ScrollController { - RetainableScrollController({ - super.initialScrollOffset, - super.keepScrollOffset, - super.debugLabel, - }); - - @override - ScrollPosition createScrollPosition( - ScrollPhysics physics, - ScrollContext context, - ScrollPosition? oldPosition, - ) { - return RetainableScrollPosition( - physics: physics, - context: context, - initialPixels: initialScrollOffset, - keepScrollOffset: keepScrollOffset, - oldPosition: oldPosition, - debugLabel: debugLabel, - ); - } - - void retainOffset() { - position.retainOffset(); - } - - @override - RetainableScrollPosition get position => - super.position as RetainableScrollPosition; -} - -class RetainableScrollPosition extends ScrollPositionWithSingleContext { - RetainableScrollPosition({ - required super.physics, - required super.context, - super.initialPixels = 0.0, - super.keepScrollOffset, - super.oldPosition, - super.debugLabel, - }); - - double? _oldPixels; - double? _oldMaxScrollExtent; - - bool get shouldRestoreRetainedOffset => - _oldMaxScrollExtent != null && _oldPixels != null; - - void retainOffset() { - if (!hasPixels) return; - _oldPixels = pixels; - _oldMaxScrollExtent = maxScrollExtent; - } - - /// when the viewport layouts its children, it would invoke [applyContentDimensions] to - /// update the [minScrollExtent] and [maxScrollExtent]. - /// When it happens, [shouldRestoreRetainedOffset] would determine if correcting the current [pixels], - /// so that the final scroll offset is matched to the previous items' scroll offsets. - /// Therefore, avoiding scrolling down/up when the new item is inserted into the first index of the list. - @override - bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) { - final applied = - super.applyContentDimensions(minScrollExtent, maxScrollExtent); - - bool isPixelsCorrected = false; - - if (shouldRestoreRetainedOffset) { - final diff = maxScrollExtent - _oldMaxScrollExtent!; - if (_oldPixels! > minScrollExtent && diff > 0) { - correctPixels(pixels + diff); - isPixelsCorrected = true; - } - _oldMaxScrollExtent = null; - _oldPixels = null; - } - - return applied && !isPixelsCorrected; - } -} diff --git a/lib/services/nostr/feeds/event_feed.dart b/lib/services/nostr/feeds/event_feed.dart deleted file mode 100644 index 4f955bb6..00000000 --- a/lib/services/nostr/feeds/event_feed.dart +++ /dev/null @@ -1,206 +0,0 @@ -import 'dart:async'; -import 'dart:developer'; - -import 'package:camelus/db/database.dart'; -import 'package:camelus/db/entities/db_note_view.dart'; -import 'package:camelus/models/nostr_note.dart'; -import 'package:camelus/models/nostr_request_query.dart'; -import 'package:camelus/services/nostr/relays/relay_coordinator.dart'; - -class EventFeed { - final AppDatabase _db; - final String _rootNoteId; - final RelayCoordinator _relays; - final List _requestIds = []; - - final List _subscriptions = []; - final StreamController> _newNotesController = - StreamController>.broadcast(); - final StreamController> _feedStreamController = - StreamController>(); - - final Completer> _feedRdy = Completer>(); - - List _feed = []; - final List _feedNewNotes = []; - - EventFeed(this._db, this._rootNoteId, this._relays) { - init(); - } - - /// streams the current feed with updates as they come in below the fixedTopNote - Stream> get feedStream => _feedStreamController.stream; - - List get feed => _feed; - - /// notifies when new notes are available with a list of new notes @call integrateNewNotes() - Stream> get newNotesStream => _newNotesController.stream; - - Future> get feedRdy => _feedRdy.future; - - void init() async { - await _initFeed(); - await _streamFeed(); - } - - void cleanup() { - log("debug: Event: cleanupCalled"); - _closeAllRelaySubscriptions(); - _disposeSubscriptions(); - } - - void _disposeSubscriptions() { - for (var s in _subscriptions) { - s.cancel(); - } - } - - Future _initFeed() async { - _feed = []; - var notes = await _getCurrentNotes(); - - _feed = notes; - _feedStreamController.add(_feed); - _feedRdy.complete(_feed); - return; - } - - Future> _getCurrentNotes() async { - var getresult = await _db.noteDao.findRepliesByIdAndByKind(_rootNoteId, 1); - - return getresult.map((e) => e.toNostrNote()).toList(); - } - - _streamFeed() async { - Stream> stream = - _db.noteDao.findRepliesByIdAndByKindStream(_rootNoteId, 1); - - _subscriptions.add( - stream.listen((event) async { - if (event.isEmpty) { - return; - } - - /// new notes need to be fetched from the db, because floor stream from a view is buggy - // var allNotes = await _getCurrentNotes(); - - var newNotes = event.map((e) => e.toNostrNote()).toList(); - - /// used so notes that belong in the feed are updated and - - List feedUpdates = []; - for (var note in newNotes) { - // check for duplicates - if (_feed.any((element) => element.id == note.id)) { - continue; - } - if (feedUpdates.any((element) => element.id == note.id)) { - continue; - } - - feedUpdates.add(note); - } - log("feed updates: ${feedUpdates.length}"); - _insertUnsortedNotesIntoFeed(feedUpdates, _feed); - - _removeDuplicates(_feed); - _feedStreamController.add(_feed); - }), - ); - } - - void _removeDuplicates(List notes) { - var copy = [...notes]; - var ids = {}; - for (var note in copy) { - if (ids.contains(note.id)) { - notes.remove(note); - } else { - ids.add(note.id); - } - } - } - - void _insertUnsortedNotesIntoFeed( - List notesToIntegrate, List sourceFeed) { - // copy list - var copy = [...notesToIntegrate]; - // add to feed sorted by created_at - sourceFeed.addAll(copy); - sourceFeed.sort((a, b) => a.created_at.compareTo(b.created_at)); - } - - void requestRelayEventFeed({ - required List eventIds, - required String requestId, - int? since, - int? until, - int? limit, - }) { - var reqId = "efeed-$requestId"; - - // skip if already requested - if (_requestIds.contains(reqId)) { - return; - } else { - _requestIds.add(reqId); - } - - const defaultLimit = 5; - - var myBody = NostrRequestQueryBody( - hastagE: eventIds, - kinds: [1], - limit: limit ?? defaultLimit, - since: since, - until: until, - ); - - var myRequest = NostrRequestQuery(subscriptionId: reqId, body: myBody); - - _relays.request(request: myRequest); - } - - Future requestRelayEventFeedFixedRelays({ - required List relayCandidates, - required Duration timeout, - required List eventIds, - required List pubkeys, - required String requestId, - int? since, - int? until, - int? limit, - }) { - const defaultLimit = 5; - - var myBody = NostrRequestQueryBody( - authors: pubkeys, - hastagE: eventIds, - kinds: [1], - limit: limit ?? defaultLimit, - since: since, - until: until, - ); - - var myRequest = NostrRequestQuery(subscriptionId: requestId, body: myBody); - - return _relays.requestFromRelays( - request: myRequest, - relayCandidates: relayCandidates, - timeout: timeout, - ); - } - - void _closeAllRelaySubscriptions() { - List copy = List.from(_requestIds); - for (var reqId in copy) { - closeRelaySubscription(reqId); - } - } - - void closeRelaySubscription(String subId) { - _relays.closeSubscription(subId); - - _requestIds.remove(subId); - } -} diff --git a/lib/services/nostr/feeds/hashtag_feed.dart b/lib/services/nostr/feeds/hashtag_feed.dart deleted file mode 100644 index eb343125..00000000 --- a/lib/services/nostr/feeds/hashtag_feed.dart +++ /dev/null @@ -1,189 +0,0 @@ -import 'dart:async'; -import 'dart:developer'; - -import 'package:camelus/db/database.dart'; -import 'package:camelus/db/entities/db_note_view.dart'; -import 'package:camelus/models/nostr_note.dart'; -import 'package:camelus/models/nostr_request_query.dart'; -import 'package:camelus/services/nostr/relays/relay_coordinator.dart'; - -class HashtagFeed { - final AppDatabase _db; - final RelayCoordinator _relays; - final String _hashtag; - - final List _subscriptions = []; - final List _requestIds = []; // nostr request ids - - final StreamController> _newNotesController = - StreamController>.broadcast(); - final StreamController> _feedStreamController = - StreamController>(); - - List _feed = []; - List _feedNewNotes = []; - - final Completer> _feedRdy = Completer>(); - - int _feedFixedTopAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - NostrNote? - _oldestNoteInSession; // oldest note recived by relays in current session - NostrNote? get oldestNoteInSession => _oldestNoteInSession; - - Stream> get feedStream => _feedStreamController.stream; - List get feed => _feed; - Stream> get newNotesStream => _newNotesController.stream; - Future> get feedRdy => _feedRdy.future; - - HashtagFeed(this._db, this._relays, this._hashtag) { - init(); - } - - void init() async { - await _initFeed(); - _streamFeed(); - } - - void cleanup() { - _closeAllRelaySubscriptions(); - _disposeSubscriptions(); - } - - void _closeAllRelaySubscriptions() { - List copy = List.from(_requestIds); - for (var reqId in copy) { - _closeRelaySubscription(reqId); - } - } - - void _closeRelaySubscription(String subId) { - _relays.closeSubscription(subId); - _requestIds.remove(subId); - } - - void _disposeSubscriptions() { - for (var s in _subscriptions) { - s.cancel(); - } - } - - Future _initFeed() async { - _feed = []; - var notesTmp = await _db.noteDao.findTagByKind(1, '%,$_hashtag,%'); - var notes = notesTmp.map((e) => e.toNostrNote()).toList(); - // set latest note ("fixed" stream after this) - - _feed = notes; - _feedStreamController.add(_feed); - _feedRdy.complete(_feed); - return; - } - - void _streamFeed() { - Stream> stream = - _db.noteDao.findTagByKindStream(1, '%,$_hashtag,%'); - _subscriptions.add( - stream.listen((event) { - var notes = event.map((e) => e.toNostrNote()).toList(); - - List newNotes = []; - List feedUpdates = []; - - for (var note in notes) { - // set oldest note in session when fetched from db - try { - _oldestNoteInSession ??= _feed[5]; - } catch (e) { - // feed is empty - } - - if (_feed.any((element) => element.id == note.id)) { - continue; - } - // usually one note gets served from two relays, so we need to check for duplicates - if (newNotes.any((element) => element.id == note.id)) { - continue; - } - - if (note.created_at > _feedFixedTopAt) { - newNotes.add(note); - } - if (note.created_at < _feedFixedTopAt) { - feedUpdates.add(note); - } - - // update oldest note in session - if (note.created_at < (_oldestNoteInSession?.created_at ?? 0)) { - _oldestNoteInSession = note; - } - } - - if (newNotes.isNotEmpty) { - _insertUnsortedNotesIntoFeed(newNotes, _feedNewNotes); - _newNotesController.add(_feedNewNotes); - } - if (feedUpdates.isNotEmpty) { - log("feed updates Hastag: ${feedUpdates.length}"); - _insertUnsortedNotesIntoFeed(feedUpdates, _feed); - _feedStreamController.add(_feed); - } - }), - ); - } - - void _insertUnsortedNotesIntoFeed( - List notesToIntegrate, List sourceFeed) { - // copy list - var copy = [...notesToIntegrate]; - // add to feed sorted by created_at - sourceFeed.addAll(copy); - sourceFeed.sort((a, b) => b.created_at.compareTo(a.created_at)); - } - - void integrateNewNotes() { - List copyCleaned = []; - // remove duplicates - for (var note in _feedNewNotes) { - if (copyCleaned.any((element) => element.id == note.id)) { - continue; - } - copyCleaned.add(note); - } - - _insertUnsortedNotesIntoFeed(copyCleaned, _feed); - _feedFixedTopAt = _feed[0].created_at; - _feedNewNotes = []; - _feedStreamController.add(_feed); - } - - Future requestRelayHashtagFeed({ - required List hashtags, - required String requestId, - int? since, - int? until, - int? limit, - }) async { - var reqId = "hastag-$requestId"; - - // skip if already requested - if (_requestIds.contains(reqId)) { - //return; - } else { - _requestIds.add(reqId); - } - - const defaultLimit = 10; - - var myBody = NostrRequestQueryBody( - hastagT: hashtags, - kinds: [1], - limit: limit ?? defaultLimit, - since: since, - until: until, - ); - var myRequest = NostrRequestQuery(subscriptionId: reqId, body: myBody); - - _relays.request(request: myRequest); - } -} diff --git a/lib/services/nostr/feeds/user_and_replies_feed.dart b/lib/services/nostr/feeds/user_and_replies_feed.dart deleted file mode 100644 index 5b197efe..00000000 --- a/lib/services/nostr/feeds/user_and_replies_feed.dart +++ /dev/null @@ -1,229 +0,0 @@ -import 'dart:async'; -import 'dart:developer'; - -import 'package:camelus/db/database.dart'; -import 'package:camelus/db/entities/db_note_view.dart'; -import 'package:camelus/models/nostr_note.dart'; -import 'package:camelus/models/nostr_request_query.dart'; -import 'package:camelus/services/nostr/relays/relay_coordinator.dart'; - -class UserFeedAndRepliesFeed { - final AppDatabase _db; - final List _followingPubkeys; - final RelayCoordinator _relays; - final List _requestIds = []; - - final List _subscriptions = []; - final StreamController> _newNotesController = - StreamController>.broadcast(); - final StreamController> _feedStreamController = - StreamController>(); - - final Completer> _feedRdy = Completer>(); - - NostrNote? _fixedTopNote; - - List _feed = []; - List _feedNewNotes = []; - - UserFeedAndRepliesFeed(this._db, this._followingPubkeys, this._relays) { - init(); - } - - NostrNote? - _oldestNoteInSession; // oldest note recived by relays in current session - NostrNote? get oldestNoteInSession => _oldestNoteInSession; - - /// streams the current feed with updates as they come in below the fixedTopNote - Stream> get feedStream => _feedStreamController.stream; - - List get feed => _feed; - - /// notifies when new notes are available with a list of new notes @call integrateNewNotes() - Stream> get newNotesStream => _newNotesController.stream; - - /// returns the fixed top note, the feed only updates below this note, unless you call integrateNewNotes() - NostrNote? get fixedTopNote => _fixedTopNote; - - Future> get feedRdy => _feedRdy.future; - - void init() async { - await _initFeed(); - await _streamFeed(); - } - - /// cancels relay subscriptions and stream subscriptions - void cleanup() { - log("debug: cleanupCalled"); - _closeAllRelaySubscriptions(); - _disposeSubscriptions(); - } - - void _disposeSubscriptions() { - for (var s in _subscriptions) { - s.cancel(); - } - } - - /// database/stream handeling - - Future _initFeed() async { - _feed = []; - var notes = await _getCurrentNotes(); - // set latest note ("fixed" stream after this) - if (notes.isNotEmpty) { - _fixedTopNote = notes[0]; - } - _feed = notes; - _feedStreamController.add(_feed); - _feedRdy.complete(_feed); - return; - } - - /// updates and calls feedStream with new notes, you should call this to - void integrateNewNotes() { - List copyCleaned = []; - // remove duplicates - for (var note in _feedNewNotes) { - if (copyCleaned.any((element) => element.id == note.id)) { - continue; - } - copyCleaned.add(note); - } - - _insertUnsortedNotesIntoFeed(copyCleaned, _feed); - _fixedTopNote = _feed[0]; - _feedNewNotes = []; - _feedStreamController.add(_feed); - } - - Future> _getCurrentNotes() async { - var getresult = - await _db.noteDao.findPubkeyNotesByKind(_followingPubkeys, 1); - - return getresult.map((e) => e.toNostrNote()).toList(); - } - - _streamFeed() async { - Stream> stream = - _db.noteDao.findPubkeyNotesByKindStreamNotifyOnly(_followingPubkeys, 1); - - _subscriptions.add( - stream.listen((event) async { - if (event.isEmpty) { - return; - } - - /// new notes need to be fetched from the db, because floor stream from a view is buggy - var allNotes = await _getCurrentNotes(); - - /// used so notes that belong in the feed are updated and - List newNotes = []; - List feedUpdates = []; - for (var note in allNotes) { - _fixedTopNote ??= note; - - // set oldest note in session when fetched from db - try { - _oldestNoteInSession ??= _feed[5]; - } catch (e) { - // feed is empty - } - - // check for duplicates - if (_feed.any((element) => element.id == note.id)) { - continue; - } - // usually one note gets served from two relays, so we need to check for duplicates - if (newNotes.any((element) => element.id == note.id)) { - continue; - } - - if (note.created_at > _fixedTopNote!.created_at) { - newNotes.add(note); - } - if (note.created_at < _fixedTopNote!.created_at) { - feedUpdates.add(note); - } - - // update oldest note in session - if (note.created_at < (_oldestNoteInSession?.created_at ?? 0)) { - _oldestNoteInSession = note; - } - } - if (newNotes.isNotEmpty) { - _insertUnsortedNotesIntoFeed(newNotes, _feedNewNotes); - _newNotesController.add(_feedNewNotes); - } - if (feedUpdates.isNotEmpty) { - log("feed updates: ${feedUpdates.length}"); - _insertUnsortedNotesIntoFeed(feedUpdates, _feed); - _feedStreamController.add(_feed); - } - }), - ); - } - - void _insertUnsortedNotesIntoFeed( - List notesToIntegrate, List sourceFeed) { - // copy list - var copy = [...notesToIntegrate]; - // add to feed sorted by created_at - sourceFeed.addAll(copy); - sourceFeed.sort((a, b) => b.created_at.compareTo(a.created_at)); - } - - /// Relay handeling - - Future requestRelayUserFeedAndReplies({ - required List users, - required String requestId, - int? since, - int? until, - int? limit, - }) async { - var reqId = "ufeedAndReplies-$requestId"; - - // add if not already in list - if (!_requestIds.contains(reqId)) { - _requestIds.add(reqId); - } - - const defaultLimit = 5; - - var myBodyOriginal = NostrRequestQueryBody( - authors: users, - kinds: [1], - limit: limit ?? defaultLimit, - since: since, - until: until, - ); - - var myBody = NostrRequestQueryBody( - hastagP: users, - kinds: [1], - limit: limit ?? defaultLimit, - since: since, - until: until, - ); - - var myRequest = NostrRequestQuery( - subscriptionId: reqId, body: myBody, body2: myBodyOriginal); - - await _relays.request(request: myRequest); - return; - } - - void _closeAllRelaySubscriptions() { - List copy = List.from(_requestIds); - for (var reqId in copy) { - _closeRelaySubscription(reqId); - } - } - - void _closeRelaySubscription(String subId) { - _relays.closeSubscription(subId); - - _requestIds.remove(subId); - } -} diff --git a/lib/services/nostr/feeds/user_feed.dart b/lib/services/nostr/feeds/user_feed.dart deleted file mode 100644 index f930af41..00000000 --- a/lib/services/nostr/feeds/user_feed.dart +++ /dev/null @@ -1,217 +0,0 @@ -import 'dart:async'; -import 'dart:developer'; - -import 'package:camelus/db/database.dart'; -import 'package:camelus/db/entities/db_note_view.dart'; -import 'package:camelus/models/nostr_note.dart'; -import 'package:camelus/models/nostr_request_query.dart'; -import 'package:camelus/services/nostr/relays/relay_coordinator.dart'; - -class UserFeed { - final AppDatabase _db; - final List _followingPubkeys; - final RelayCoordinator _relays; - final List _requestIds = []; - - final List _subscriptions = []; - final StreamController> _newNotesController = - StreamController>.broadcast(); - final StreamController> _feedStreamController = - StreamController>(); - - final Completer> _feedRdy = Completer>(); - - NostrNote? _fixedTopNote; - - List _feed = []; - List _feedNewNotes = []; - - UserFeed(this._db, this._followingPubkeys, this._relays) { - init(); - } - - NostrNote? - _oldestNoteInSession; // oldest note recived by relays in current session - NostrNote? get oldestNoteInSession => _oldestNoteInSession; - - /// streams the current feed with updates as they come in below the fixedTopNote - Stream> get feedStream => _feedStreamController.stream; - - List get feed => _feed; - - /// notifies when new notes are available with a list of new notes @call integrateNewNotes() - Stream> get newNotesStream => _newNotesController.stream; - - /// returns the fixed top note, the feed only updates below this note, unless you call integrateNewNotes() - NostrNote? get fixedTopNote => _fixedTopNote; - - Future> get feedRdy => _feedRdy.future; - - void init() async { - await _initFeed(); - await _streamFeed(); - } - - /// cancels relay subscriptions and stream subscriptions - void cleanup() { - log("debug: cleanupCalled"); - _closeAllRelaySubscriptions(); - _disposeSubscriptions(); - } - - void _disposeSubscriptions() { - for (var s in _subscriptions) { - s.cancel(); - } - } - - Future _initFeed() async { - _feed = []; - var notes = await _getCurrentNotes(); - // set latest note ("fixed" stream after this) - if (notes.isNotEmpty) { - _fixedTopNote = notes[0]; - } - _feed = notes; - _feedStreamController.add(_feed); - _feedRdy.complete(_feed); - return; - } - - /// updates and calls feedStream with new notes, you should call this to - void integrateNewNotes() { - List copyCleaned = []; - // remove duplicates - for (var note in _feedNewNotes) { - if (copyCleaned.any((element) => element.id == note.id)) { - continue; - } - copyCleaned.add(note); - } - - _insertUnsortedNotesIntoFeed(copyCleaned, _feed); - _fixedTopNote = _feed[0]; - _feedNewNotes = []; - _feedStreamController.add(_feed); - } - - Future> _getCurrentNotes() async { - //! todo - var getresult = - await _db.noteDao.findPubkeyRootNotesByKind(_followingPubkeys, 1); - - return getresult.map((e) => e.toNostrNote()).toList(); - } - - _streamFeed() async { - //! todo - Stream> stream = _db.noteDao - .findPubkeyRootNotesByKindStreamNotifyOnly(_followingPubkeys, 1); - - _subscriptions.add( - stream.listen((event) async { - if (event.isEmpty) { - return; - } - - /// new notes need to be fetched from the db, because floor stream from a view is buggy - var allNotes = await _getCurrentNotes(); - - /// used so notes that belong in the feed are updated and - List newNotes = []; - List feedUpdates = []; - for (var note in allNotes) { - _fixedTopNote ??= note; - - // set oldest note in session when fetched from db - try { - _oldestNoteInSession ??= _feed[5]; - } catch (e) { - // feed is empty - } - - // check for duplicates - if (_feed.any((element) => element.id == note.id)) { - continue; - } - // usually one note gets served from two relays, so we need to check for duplicates - if (newNotes.any((element) => element.id == note.id)) { - continue; - } - - if (note.created_at > _fixedTopNote!.created_at) { - newNotes.add(note); - } - if (note.created_at < _fixedTopNote!.created_at) { - feedUpdates.add(note); - } - - // update oldest note in session - if (note.created_at < (_oldestNoteInSession?.created_at ?? 0)) { - _oldestNoteInSession = note; - } - } - if (newNotes.isNotEmpty) { - _insertUnsortedNotesIntoFeed(newNotes, _feedNewNotes); - _newNotesController.add(_feedNewNotes); - } - if (feedUpdates.isNotEmpty) { - log("feed updates: ${feedUpdates.length}"); - _insertUnsortedNotesIntoFeed(feedUpdates, _feed); - _feedStreamController.add(_feed); - } - }), - ); - } - - void _insertUnsortedNotesIntoFeed( - List notesToIntegrate, List sourceFeed) { - // copy list - var copy = [...notesToIntegrate]; - // add to feed sorted by created_at - sourceFeed.addAll(copy); - sourceFeed.sort((a, b) => b.created_at.compareTo(a.created_at)); - } - - Future requestRelayUserFeed({ - required List users, - required String requestId, - int? since, - int? until, - int? limit, - }) async { - var reqId = "ufeed-$requestId"; - - // skip if already requested - if (_requestIds.contains(reqId)) { - //return; - } else { - _requestIds.add(reqId); - } - - const defaultLimit = 5; - - var myBody = NostrRequestQueryBody( - authors: users, - kinds: [1], - limit: limit ?? defaultLimit, - since: since, - until: until, - ); - var myRequest = NostrRequestQuery(subscriptionId: reqId, body: myBody); - - _relays.request(request: myRequest); - } - - void _closeAllRelaySubscriptions() { - List copy = List.from(_requestIds); - for (var reqId in copy) { - _closeRelaySubscription(reqId); - } - } - - void _closeRelaySubscription(String subId) { - _relays.closeSubscription(subId); - _requestIds.remove(subId); - } -} diff --git a/lib/services/nostr/metadata/following_pubkeys.dart b/lib/services/nostr/metadata/following_pubkeys.dart deleted file mode 100644 index 3852ec59..00000000 --- a/lib/services/nostr/metadata/following_pubkeys.dart +++ /dev/null @@ -1,341 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:camelus/db/database.dart'; -import 'package:camelus/helpers/helpers.dart'; -import 'package:camelus/models/nostr_request_event.dart'; -import 'package:camelus/models/nostr_request_query.dart'; -import 'package:camelus/models/nostr_tag.dart'; -import 'package:camelus/providers/key_pair_provider.dart'; -import 'package:camelus/services/nostr/relays/relay_coordinator.dart'; -import 'package:cross_local_storage/cross_local_storage.dart'; -import 'package:json_cache/json_cache.dart'; - -class FollowingPubkeys { - final Future keyPair; - late String _myPubkey; - late String _myPrivkey; - final Future db; - late AppDatabase _db; - final RelayCoordinator relays; - - final List _subscriptions = []; - - final List _currentlyFetching = []; - - // own contacts - final StreamController> _contactsController = - StreamController>.broadcast(); - Stream> get ownPubkeyContactsStreamDb => - _contactsController.stream; - - final List _ownContacts = []; - List get ownContacts => _ownContacts; - - // own relays - final StreamController> _ownRelaysController = - StreamController>.broadcast(); - Stream> get ownRelaysStreamDb => - _ownRelaysController.stream; - - Map _ownRelays = {}; - Map get ownRelays => _ownRelays; - - // nip 65 - final StreamController> _ownNip65Controller = - StreamController>.broadcast(); - Stream> get ownNip65StreamDb => - _ownNip65Controller.stream; - final List _ownNip65 = []; - List get ownNip65 => _ownNip65; - - int _fetchLatestEventAt = 0; - final int _FetchLatestNip65At = 0; - - final Completer _servicesReady = Completer(); - final Completer _dbStreamReady = Completer(); - - Future get servicesReady => _servicesReady.future; - - var followingLastFetch = {}; - late JsonCache _jsonCache; - - FollowingPubkeys({ - required this.keyPair, - required this.db, - required this.relays, - }) { - _init(); - } - - void _init() async { - _myPubkey = (await keyPair).keyPair!.publicKey; - _myPrivkey = (await keyPair).keyPair!.privateKey; - _db = await db; - _initStream(_myPubkey); - await _restoreCache(); - - await _dbStreamReady.future; - _servicesReady.complete(); - } - - Future _restoreCache() async { - LocalStorageInterface prefs = await LocalStorage.getInstance(); - _jsonCache = JsonCacheCrossLocalStorage(prefs); - - var cache = await _jsonCache.value( - 'followingLastFetch', - ); - if (cache != null) { - followingLastFetch = Map.from(cache); - } - } - - void cleanup() { - _disposeSubscriptions(); - } - - void _disposeSubscriptions() { - for (var s in _subscriptions) { - s.cancel(); - } - } - - _initStream(String pubkey) { - // fill with inital data - - _subscriptions.add( - _db.noteDao.findPubkeyNotesByKindStream([pubkey], 3).listen((dbList) { - if (dbList.isEmpty) { - //_contactsController.add([]); - return; - } - - var kind3 = dbList.first.toNostrNote(); - // got something older than latest event - if (_fetchLatestEventAt != 0 && kind3.created_at <= _fetchLatestEventAt) { - return; - } - - _fetchLatestEventAt = kind3.created_at; - - var newContacts = kind3.getTagPubkeys; - Map newRelays = {}; - try { - newRelays = jsonDecode(kind3.content); - } catch (e) { - // - } - - _ownContacts.clear(); - _ownContacts.addAll(newContacts); - _contactsController.add(newContacts); - - if (newRelays.isNotEmpty) { - _ownRelays.clear(); - _ownRelays = newRelays; - _ownRelaysController.add(newRelays); - } - - if (!_servicesReady.isCompleted) { - _dbStreamReady.complete(); - } - })); - - _subscriptions.add( - _db.noteDao.findPubkeyNotesByKindStream([pubkey], 10002).listen( - (dbList) { - if (dbList.isEmpty) { - return; - } - var nip65 = dbList.first.toNostrNote(); - - if (_FetchLatestNip65At != 0 && - nip65.created_at <= _FetchLatestNip65At) { - return; - } - - _ownNip65.clear(); - _ownNip65.addAll(nip65.tags); - }, - ), - ); - } - - /// gets the data directly from the db without dispaching a request - Future> getFollowingPubkeysDb(String pubkey) async { - await _servicesReady.future; - var dbList = (await _db.noteDao.findPubkeyNotesByKind([pubkey], 3)); - if (dbList.isEmpty) { - return []; - } - - var kind3 = dbList.first.toNostrNote(); - - return kind3.getTagPubkeys; - } - - /// get the data from the db and disposes a network request if needed - Future> getFollowingPubkeys(String pubkey) async { - await _servicesReady.future; - var dbList = (await _db.noteDao.findPubkeyNotesByKind([pubkey], 3)); - - int? lastFetch = followingLastFetch[pubkey]; - - int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - String requestId = "contacts-${Helpers().getRandomString(4)}"; - - if (dbList.isEmpty) { - // no data in db, request it - - return _checkIfFetching(pubkey, requestId, now); - } - - // check how fresh the data is / 4 hours - if (lastFetch != null && now - lastFetch < 14400) { - var kind3 = dbList.first.toNostrNote(); - // data is fresh enough, return it - return kind3.tags; - } - - // data is old, request it - return _checkIfFetching(pubkey, requestId, now); - } - - Future> _checkIfFetching( - String pubkey, String requestId, int now) async { - if (_currentlyFetching.map((e) => e.pubkey).contains(pubkey)) { - // already fetching - var result = await _currentlyFetching - .firstWhere((e) => e.pubkey == pubkey) - .fetchNew; - _currentlyFetching.removeWhere((e) => e.pubkey == pubkey); - return result; - } - var current = CurrentlyFetching( - pubkey: pubkey, - fetchNew: _fetchNew(pubkey: pubkey, requestId: requestId, now: now)); - _currentlyFetching.add( - current, - ); - var result = await current.fetchNew; - _currentlyFetching.removeWhere((e) => e.pubkey == pubkey); - return result; - } - - Future> _fetchNew( - {required String pubkey, - required String requestId, - required int now}) async { - await _requestContacts(pubkeys: [pubkey], requestId: requestId); - followingLastFetch[pubkey] = now; - await _jsonCache.refresh('followingLastFetch', followingLastFetch); - // wait 500 ms - await Future.delayed(const Duration(milliseconds: 500)); - var dbListNew = (await _db.noteDao.findPubkeyNotesByKind([pubkey], 3)); - if (dbListNew.isEmpty) { - return []; // nothing found - } - var kind3New = dbListNew.first.toNostrNote(); - return kind3New.tags; - } - - Future _requestContacts( - {required List pubkeys, required String requestId}) async { - var body = NostrRequestQueryBody(kinds: [3], authors: pubkeys); - var request = NostrRequestQuery(subscriptionId: requestId, body: body); - await relays.request(request: request); - relays.closeSubscription(requestId); - return; - } - - Future follow( - String toFollow, - ) async { - var myLastNote = - (await _db.noteDao.findPubkeyNotesByKind([_myPubkey], 3)).first; - - List newContacts = [..._ownContacts]; - newContacts.add(NostrTag(type: 'p', value: toFollow)); - await _writeContacts( - publicKey: _myPubkey, - privateKey: _myPrivkey, - content: myLastNote.content, - updatedContacts: newContacts, - ); - return; - } - - Future unfollow(String toUnfollow) async { - var myLastNote = - (await _db.noteDao.findPubkeyNotesByKind([_myPubkey], 3)).first; - - List newContacts = [..._ownContacts]; - - newContacts.removeWhere((element) => element.value == toUnfollow); - - await _writeContacts( - publicKey: _myPubkey, - privateKey: _myPrivkey, - content: myLastNote.content, - updatedContacts: newContacts, - ); - return; - } - - Future updateContent(String updatedContent) async { - await _writeContacts( - publicKey: _myPubkey, - privateKey: _myPrivkey, - content: updatedContent, - updatedContacts: _ownContacts, - ); - return; - } - - Future _writeContacts({ - required String publicKey, - required String privateKey, - required String content, - required List updatedContacts, - }) async { - NostrRequestEventBody body = NostrRequestEventBody( - pubkey: publicKey, - privateKey: privateKey, - content: content, - kind: 3, - tags: updatedContacts, - ); - NostrRequestEvent myEvent = NostrRequestEvent(body: body); - - await relays.write(request: myEvent); - - return; - } - - // todo: maybe additonal blaster service? - Future publishNip65(List updatedTags) async { - NostrRequestEventBody body = NostrRequestEventBody( - pubkey: _myPubkey, - privateKey: _myPrivkey, - content: "", - kind: 10002, - tags: updatedTags, - ); - - NostrRequestEvent myEvent = NostrRequestEvent(body: body); - await relays.write(request: myEvent); - return; - } -} - -class CurrentlyFetching { - final String pubkey; - final Future> fetchNew; - - CurrentlyFetching({ - required this.pubkey, - required this.fetchNew, - }); -} diff --git a/lib/services/nostr/metadata/metadata_injector.dart b/lib/services/nostr/metadata/metadata_injector.dart deleted file mode 100644 index e33fdf5e..00000000 --- a/lib/services/nostr/metadata/metadata_injector.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:camelus/services/nostr/metadata/nip_05.dart'; - -class MetadataInjector { - static final _singleton = MetadataInjector._internal(); - static MetadataInjector? _injector; - - Nip05? _nip05; - - factory MetadataInjector() { - return _injector != null ? _injector! : _singleton; - } - - MetadataInjector._internal(); - - static void configure(MetadataInjector injector) { - _injector = injector; - } - - Nip05 get nip05 { - return _nip05 ??= Nip05(); - } -} diff --git a/lib/services/nostr/metadata/nip_05.dart b/lib/services/nostr/metadata/nip_05.dart deleted file mode 100644 index cabc1596..00000000 --- a/lib/services/nostr/metadata/nip_05.dart +++ /dev/null @@ -1,149 +0,0 @@ -import 'dart:convert'; -import 'dart:developer'; -import 'package:cross_local_storage/cross_local_storage.dart'; -//import 'package:cross_local_storage/cross_json_storage.dart'; -import 'package:json_cache/json_cache.dart'; -import 'package:http/http.dart' as http; - -class Nip05 { - Map _history = {}; - final List _inFlight = []; - http.Client client = http.Client(); - - late JsonCache jsonCache; - - Nip05() { - _initJsonCache(); - } - - void _initJsonCache() async { - LocalStorageInterface? prefs = await LocalStorage.getInstance(); - jsonCache = JsonCacheCrossLocalStorage(prefs); - _restoreFromCache(); - } - - void _restoreFromCache() async { - var cache = (await jsonCache.value('nip05')); - - if (cache == null) { - return; - } - - // purge entries older than 24h - int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - cache.removeWhere((key, value) => now - value["lastCheck"] > 60 * 60 * 24); - - _history = cache; - } - - _updateCache() async { - await jsonCache.refresh('nip05', _history); - } - - /// returns {nip05, valid, lastCheck, relayHints} or exception - Future> checkNip05(String nip05, String pubkey) async { - if (nip05.isEmpty || pubkey.isEmpty) { - throw Exception("nip05 or pubkey empty"); - } - - if (_history.containsKey(nip05)) { - Map result = _history[nip05]; - int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - int lastCheck = result["lastCheck"]; - if (now - lastCheck < 60 * 60 * 24) { - return result; - } - } - - if (_inFlight.contains(nip05)) { - // wait for result - while (_inFlight.contains(nip05)) { - await Future.delayed(const Duration(milliseconds: 500)); - } - return _history[nip05] ?? {}; - } - - _inFlight.add(nip05); - - var result = await _checkNip05Request(nip05, pubkey); - _inFlight.remove(nip05); - _updateCache(); - return result; - } - - /// returns {nip05, valid, lastCheck, relays} - Future> _checkNip05Request( - String nip05, String pubkey) async { - int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - // split in username and url/domain - String username = nip05.split("@")[0]; - try { - String url = nip05.split("@")[1]; - } catch (e) { - log("invalid nip05: $nip05"); - return {}; - throw Exception("invalid nip05 $nip05"); - } - - var json; - try { - json = await rawNip05Request(nip05, client); - } catch (e) { - log("error fetching nip05: $e"); - return {}; - } - - Map names = json["names"]; - - Map relays = json["relays"] ?? {}; - - List pRelays = []; - if (relays[pubkey] != null) { - pRelays = List.from(relays[pubkey]); - } - - Map result = { - "nip05": nip05, - "valid": false, - "lastCheck": now - }; - if (pRelays.isNotEmpty) { - result["relays"] = pRelays; - } - - if (names[username] == pubkey) { - result["valid"] = true; - _history[nip05] = result; - - return result; - } else { - if (result.isNotEmpty) { - _history[nip05] = result; - } - - return result; - } - } - - static Future rawNip05Request(String nip05, http.Client client) async { - String username = nip05.split("@")[0]; - String url = nip05.split("@")[1]; - // make get request - try { - String myUrl = "https://$url/.well-known/nostr.json?name=$username"; - http.Response response = await client - .get(Uri.parse(myUrl), headers: {"Accept": "application/json"}); - - if (response.statusCode != 200) { - return throw Exception( - "error fetching nip05.json STATUS: ${response.statusCode}}, Link: $myUrl"); - } - - var json = jsonDecode(response.body); - return json; - } catch (e) { - throw Exception("error fetching nip05.json $e"); - } - } -} diff --git a/lib/services/nostr/metadata/nip_65.dart b/lib/services/nostr/metadata/nip_65.dart deleted file mode 100644 index 85f53469..00000000 --- a/lib/services/nostr/metadata/nip_65.dart +++ /dev/null @@ -1,200 +0,0 @@ -import 'dart:developer'; - -import 'package:camelus/db/database.dart'; -import 'package:camelus/models/nostr_note.dart'; -import 'package:camelus/models/nostr_request_query.dart'; -import 'package:camelus/services/nostr/relays/relay_address_parser.dart'; -import 'package:camelus/services/nostr/relays/relays_picker.dart'; - -class Nip65 { - final AppDatabase _db; - - Nip65(this._db); - - Future calcMinimalRelaySet({ - required List pubkeys, - int desiredCoverage = 2, - List preferConnectedRelays = const [], - }) async { - Map pubkeyCounts = { - //'pubkey2': 2, - //'pubkey3': 1, - }; - for (var pubkey in pubkeys) { - pubkeyCounts[pubkey] = desiredCoverage; - } - - var relayMetadataTmp = - await _db.noteDao.findPubkeyNotesByKind(pubkeys, 10002); - - var relayMetadata = relayMetadataTmp.map((e) => e.toNostrNote()).toList(); - - // if duplicate pubkey keep the latest - var relayMetadataMap = {}; - for (var note in relayMetadata) { - if (relayMetadataMap.containsKey(note.pubkey)) { - if (note.created_at > relayMetadataMap[note.pubkey]!.created_at) { - relayMetadataMap[note.pubkey] = note; - } - } else { - relayMetadataMap[note.pubkey] = note; - } - } - - //log("relayMetadataMap: $relayMetadataMap"); - - // caclute relay scores => higher score means included by x pubkeys in tags - Map relayScores = {}; - for (var note in relayMetadataMap.values) { - var writeTags = note.tags.where((element) { - return element.recommended_relay == null || - element.recommended_relay == "write"; - }); - for (var tag in writeTags) { - String relayUrl; - try { - relayUrl = RelayAddressParser.parseAddress(tag.value); - } catch (e) { - log(e.toString()); - continue; - } - - var boost = 0; - if (preferConnectedRelays.contains(relayUrl)) { - boost = 2; - } - relayScores[tag.value] ??= RelayScore(relayUrl: relayUrl, boost: boost); - relayScores[tag.value]!.addPubkey(note.pubkey); - } - } - - // sort relays by score - var sortedRelays = relayScores.values.toList(); - sortedRelays.sort((a, b) => b.score.compareTo(a.score)); - - //log("relayScores: $relayScores"); - - // int => how mutch coverage the relay has - Map> finalRelays = {}; - - for (var relayScore in sortedRelays) { - for (var pubkeyEntry in pubkeyCounts.entries) { - if (relayScore.pubkeys.contains(pubkeyEntry.key)) { - if (pubkeyEntry.value == 0) { - continue; - } - - finalRelays[relayScore.relayUrl] ??= []; - finalRelays[relayScore.relayUrl]!.add(pubkeyEntry.key); - pubkeyCounts[pubkeyEntry.key] = pubkeyEntry.value - 1; - } - } - } - - //log("finalRelays: $finalRelays"); - pubkeyCounts.removeWhere((key, value) => value == 0); - //log("missingRelays: $pubkeyCounts"); - var missingPubkeys = pubkeyCounts.keys.toList(); - - List relayAssignments = []; - for (var relayEntry in finalRelays.entries) { - relayAssignments.add( - RelayAssignment(relayUrl: relayEntry.key, pubkeys: relayEntry.value)); - } - - return MinimalRelaySet( - relayAssignments: relayAssignments, missingWithNoRelay: missingPubkeys); - } - - // split up the request into multiple requests and remove all authors that are not in the assignment - // string is relay url - static Map splitUpRequests({ - required NostrRequestQuery request, - required List assignments, - }) { - if (assignments.isEmpty) { - return {}; - } - - Map result = {}; - - for (var assignment in assignments) { - NostrRequestQuery newRequest = NostrRequestQuery.clone(request); - - // remove all authors that are not in the assignment - if (newRequest.body.authors != null) { - newRequest.body.authors! - .removeWhere((element) => !assignment.pubkeys.contains(element)); - } - - if (newRequest.body2?.authors != null) { - newRequest.body2!.authors! - .removeWhere((element) => !assignment.pubkeys.contains(element)); - } - - if (newRequest.body.hastagP != null) { - newRequest.body.hastagP! - .removeWhere((element) => !assignment.pubkeys.contains(element)); - } - - if (newRequest.body2?.hastagP != null) { - newRequest.body2!.hastagP! - .removeWhere((element) => !assignment.pubkeys.contains(element)); - } - - if (newRequest.body.authors?.isEmpty ?? false) { - newRequest.body.authors = null; - } - - if (newRequest.body2?.authors?.isEmpty ?? false) { - newRequest.body2!.authors = null; - } - - if (newRequest.body.authors == null && - newRequest.body.hastagP == null && - newRequest.body2?.hastagP == null && - newRequest.body2?.hastagP == null) { - continue; - } - - result[assignment.relayUrl] = newRequest; - } - return result; - } -} - -class MinimalRelaySet { - List relayAssignments = []; - List missingWithNoRelay = []; - - MinimalRelaySet({ - required this.relayAssignments, - required this.missingWithNoRelay, - }); - - @override - String toString() { - return "relayAssignments: $relayAssignments, missingPubkeys: $missingWithNoRelay"; - } -} - -class RelayScore { - String relayUrl; - List pubkeys = []; - int boost; - int get score => pubkeys.length + boost; - RelayScore({ - required this.relayUrl, - this.boost = 0, - }); - addPubkey(String pubkey) { - if (!pubkeys.contains(pubkey)) { - pubkeys.add(pubkey); - } - } - - @override - String toString() { - return "relayUrl: $relayUrl, score: $score"; - } -} diff --git a/lib/services/nostr/metadata/user_contacts.dart b/lib/services/nostr/metadata/user_contacts.dart deleted file mode 100644 index 2020d550..00000000 --- a/lib/services/nostr/metadata/user_contacts.dart +++ /dev/null @@ -1,219 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:developer'; - -import 'package:camelus/helpers/helpers.dart'; -import 'package:camelus/models/socket_control.dart'; -import 'package:camelus/services/nostr/relays/relays.dart'; -import 'package:camelus/services/nostr/relays/relays_injector.dart'; -import 'package:cross_local_storage/cross_local_storage.dart'; -import 'package:json_cache/json_cache.dart'; - -class UserContacts { - late Relays _relays; - late String ownPubkey; - - /// map with pubkey as identifier, second list [0] is p, [1] is pubkey, [2] is the relay url - var following = >{}; - var followingLastFetch = {}; - - late JsonCache _jsonCache; - - List _contactsWaitingPool = []; - late Timer _contactsWaitingPoolTimer; - var _contactsWaitingPoolTimerRunning = false; - Map>> _contactsFutureHolder = {}; - - UserContacts() { - RelaysInjector injector = RelaysInjector(); - _relays = injector.relays; - - _init(); - } - - _init() async { - LocalStorageInterface prefs = await LocalStorage.getInstance(); - _jsonCache = JsonCacheCrossLocalStorage(prefs); - - _restoreCache().then((_) async => { - // lauch fix this gets to the new db anyway - await Future.delayed(const Duration(seconds: 5)), - _removeOldData() - }); - } - - Future _restoreCache() async { - var cache = await _jsonCache.value( - 'followingLastFetch', - ); - if (cache != null) { - followingLastFetch = Map.from(cache); - } - - // restore following - var followingCache = (await _jsonCache.value('following')); - if (followingCache != null) { - // cast using for loop to avoid type error - for (var key in followingCache.keys) { - following[key] = []; - var value = followingCache[key]; - for (List parentList in value) { - following[key]!.add(parentList); - } - } - } - return; - } - - _removeOldData() {} - - getContactsByPubkey(String pubkey, {bool force = false}) { - var now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - if (following.containsKey(pubkey) && !force) { - // check if no relation - if (followingLastFetch[pubkey] == null) { - // update in background - getContactsByPubkey(pubkey, force: true); - } - - // return from cache - return Future(() => following[pubkey]!); - } - - //set relation - followingLastFetch[pubkey] = now; - //update cache - _jsonCache.refresh('followingLastFetch', followingLastFetch); - - Completer result = Completer(); - - // check if pubkey is already in waiting pool - if (!_contactsWaitingPool.contains(pubkey)) { - _contactsWaitingPool.add(pubkey); - } - - // if pool is full submit request - if (_contactsWaitingPool.length >= 10) { - _contactsWaitingPoolTimer.cancel(); - _contactsWaitingPoolTimerRunning = false; - - // submit request - result.complete(_prepareContactsRequest(pubkey)); - } else if (!_contactsWaitingPoolTimerRunning) { - _contactsWaitingPoolTimerRunning = true; - _contactsWaitingPoolTimer = Timer(const Duration(milliseconds: 500), () { - _contactsWaitingPoolTimerRunning = false; - // submit request - result.complete(_prepareContactsRequest(pubkey)); - }); - } else { - // cancel previous timer - _contactsWaitingPoolTimer.cancel(); - // start timer again - _contactsWaitingPoolTimerRunning = true; - _contactsWaitingPoolTimer = Timer(const Duration(milliseconds: 500), () { - _contactsWaitingPoolTimerRunning = false; - - // submit request - result.complete(_prepareContactsRequest(pubkey)); - }); - } - if (_contactsFutureHolder[pubkey] == null) { - _contactsFutureHolder[pubkey] = Completer>(); - } - result.future.then((value) => { - for (var key in _contactsFutureHolder.keys) - { - if (!_contactsFutureHolder[key]!.isCompleted) - { - _contactsFutureHolder[key]!.complete(value[key] ?? []), - } - }, - _contactsFutureHolder = {} - }); - - return _contactsFutureHolder[pubkey]!.future; - } - - Future> _prepareContactsRequest(String pubkey) { - // gets notified when first or last (on no data) request is received - Completer completer = Completer(); - - var requestId = "contacts-${Helpers().getRandomString(4)}"; - - List poolCopy = [..._contactsWaitingPool]; - - _requestContacts(poolCopy, requestId, completer); - - // free pool - _contactsWaitingPool = []; - - return completer.future.then((value) { - if (following.containsKey(pubkey)) { - // wait 300ms for the contacts to be received - return Future.delayed(const Duration(milliseconds: 300), () { - return Future(() => following); - }); - } - return Future(() => {}); - }); - } - - void _requestContacts( - List users, requestId, Completer? completer) async { - var data = [ - "REQ", - requestId, - { - "authors": users, - "kinds": [3], - "limit": users.length - }, - ]; - - _relays.requestEvents(data, completer: completer); - } - - receiveNostrEvent(event, SocketControl socketControl) { - var eventMap = event[2]; - - var pubkey = eventMap["pubkey"]; - - // cast with for loop - List> tags = []; - for (List t in eventMap["tags"]) { - tags.add(t); - } - - // cast to list of lists - following[pubkey] = tags; - //following[pubkey] = tags as List; - - // callback - if (socketControl.completers.containsKey(event[1])) { - if (!socketControl.completers[event[1]]!.isCompleted) { - socketControl.completers[event[1]]!.complete(); - } - } - - if (pubkey == ownPubkey) { - // update my following - following[pubkey] = tags; - - try { - Map cast = json.decode(eventMap["content"]); - // cast every entry to Map> - Map> casted = cast - .map((key, value) => MapEntry(key, value as Map)); - - // update relays - _relays.setManualRelays(casted); - } catch (e) { - log("error: $e"); - } - } - //update cache - _jsonCache.refresh('following', following); - } -} diff --git a/lib/services/nostr/metadata/user_metadata.dart b/lib/services/nostr/metadata/user_metadata.dart deleted file mode 100644 index 020502eb..00000000 --- a/lib/services/nostr/metadata/user_metadata.dart +++ /dev/null @@ -1,207 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:camelus/db/database.dart'; -import 'package:camelus/helpers/helpers.dart'; -import 'package:camelus/models/nostr_note.dart'; -import 'package:camelus/models/nostr_request_query.dart'; -import 'package:camelus/services/nostr/relays/relay_coordinator.dart'; -import 'package:cross_local_storage/cross_local_storage.dart'; -import 'package:json_cache/json_cache.dart'; - -/// -/// how metadata request works -/// -/// batches of pubkeys in metadata request -/// request pool -/// mark no metadata available in cache do prevent double requests -/// - -class UserMetadata { - Map usersMetadata = {}; - var metadataLastFetch = {}; - - final RelayCoordinator relays; - final Future dbFuture; - late AppDatabase _db; - - late JsonCache _jsonCache; - - List _metadataWaitingPool = []; - late Timer _metadataWaitingPoolTimer; - var _metadataWaitingPoolTimerRunning = false; - Map> _metadataFutureHolder = {}; - - final Completer _serviceRdy = Completer(); - - UserMetadata({ - required this.relays, - required this.dbFuture, - }) { - _init(); - } - - _init() async { - LocalStorageInterface prefs = await LocalStorage.getInstance(); - _jsonCache = JsonCacheCrossLocalStorage(prefs); - _restoreCache().then((_) => {_removeOldData()}); - _db = await dbFuture; - _initDb(); - _serviceRdy.complete(); - } - - _initDb() { - _db.noteDao.findAllNotesByKindStream(0).listen((event) { - List notes = event.map((e) => e.toNostrNote()).toList(); - _receiveNostrEvents(notes); - }); - } - - Future _restoreCache() async { - var cache = await _jsonCache.value( - 'metadataLastFetch', - ); - if (cache != null) { - metadataLastFetch = Map.from(cache); - } - - return; - } - - _removeOldData() { - // 12 hours //todo move magic number to settings - int timeBarrier = 60 * 60 * 12; - var now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - var oldData = {}; - for (var key in metadataLastFetch.keys) { - if (now - metadataLastFetch[key]! > timeBarrier) { - oldData[key] = metadataLastFetch[key]!; - } - } - for (var key in oldData.keys) { - metadataLastFetch.remove(key); - } - _jsonCache.refresh('metadataLastFetch', metadataLastFetch); - } - - Future getMetadataByPubkey(String pubkey, {bool force = false}) async { - await _serviceRdy.future; - var now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - if (pubkey.isEmpty) { - return Future(() => {}); - } - - if (usersMetadata.containsKey(pubkey) && !force) { - // check if no relation - if (metadataLastFetch[pubkey] == null) { - // update in background - getMetadataByPubkey(pubkey, force: true); - } - - // return from cache - return Future(() => usersMetadata[pubkey]); - } - - //set relation - metadataLastFetch[pubkey] = now; - //update cache - _jsonCache.refresh('metadataLastFetch', metadataLastFetch); - - // check if pubkey is already in waiting pool - if (!(_metadataWaitingPool.contains(pubkey))) { - _metadataWaitingPool.add(pubkey); - } - - Completer metadataResult = Completer(); - - // if pool is full submit request - if (_metadataWaitingPool.length >= 50) { - _metadataWaitingPoolTimer.cancel(); - _metadataWaitingPoolTimerRunning = false; - - // submit request - metadataResult.complete(_prepareMetadataRequest()); - } else if (!_metadataWaitingPoolTimerRunning) { - _metadataWaitingPoolTimerRunning = true; - _metadataWaitingPoolTimer = Timer(const Duration(milliseconds: 200), () { - _metadataWaitingPoolTimerRunning = false; - _metadataWaitingPoolTimer.cancel(); - - // submit request - metadataResult.complete(_prepareMetadataRequest()); - }); - } else { - // cancel previous timer - _metadataWaitingPoolTimer.cancel(); - // start timer again - _metadataWaitingPoolTimerRunning = true; - _metadataWaitingPoolTimer = Timer(const Duration(milliseconds: 200), () { - _metadataWaitingPoolTimerRunning = false; - - // submit request - metadataResult.complete(_prepareMetadataRequest()); - }); - } - - // don't add to future holder if already in there (double requests from future builder) - if (_metadataFutureHolder[pubkey] == null) { - _metadataFutureHolder[pubkey] = Completer(); - } - - metadataResult.future.then((value) => { - for (var key in _metadataFutureHolder.keys) - { - _metadataFutureHolder[key]!.complete(value[key] ?? {}), - // remove - }, - _metadataFutureHolder = {}, - }); - - return _metadataFutureHolder[pubkey]!.future; - } - - /// prepare metadata request - Future _prepareMetadataRequest() async { - // gets notified when first or last (on no data) request is received - - var requestId = "metadata-${Helpers().getRandomString(4)}"; - - List poolCopy = [..._metadataWaitingPool]; - - await _requestMetadata(poolCopy, requestId); - - // free pool - _metadataWaitingPool = []; - - // wait 300ms for the contacts to be received - await Future.delayed(const Duration(milliseconds: 300)); - return usersMetadata; - } - - Future _requestMetadata(List users, requestId) async { - var body = NostrRequestQueryBody( - authors: users, - kinds: [0, 10002], // + nip 65 - limit: users.length, - ); - var request = NostrRequestQuery(subscriptionId: requestId, body: body); - await relays.request(request: request, timeout: const Duration(seconds: 2)); - relays.closeSubscription(requestId); - return; - } - - _receiveNostrEvents(List notes) { - for (var note in notes) { - String pubkey = note.pubkey; - try { - usersMetadata[pubkey] = jsonDecode(note.content); - } catch (e) { - return; - } - - // add access time - int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - usersMetadata[pubkey]["accessTime"] = now; - } - } -} diff --git a/lib/services/nostr/nostr_injector.dart b/lib/services/nostr/nostr_injector.dart deleted file mode 100644 index 4ebbd43a..00000000 --- a/lib/services/nostr/nostr_injector.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:http/http.dart'; -import 'nostr_service.dart'; - -class NostrServiceInjector { - static final _singleton = NostrServiceInjector._internal(); - static NostrServiceInjector? _injector; - - NostrService? _nostrService; - Client? _client; - - factory NostrServiceInjector() { - return _injector != null ? _injector! : _singleton; - } - - NostrServiceInjector._internal(); - - static void configure(NostrServiceInjector injector) { - _injector = injector; - } - - Client get client { - return _client ??= Client(); - } -} diff --git a/lib/services/nostr/nostr_service.dart b/lib/services/nostr/nostr_service.dart deleted file mode 100644 index 89ed7d65..00000000 --- a/lib/services/nostr/nostr_service.dart +++ /dev/null @@ -1,409 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:developer'; - -import 'package:camelus/db/database.dart'; -import 'package:camelus/models/nostr_note.dart'; -import 'package:camelus/providers/key_pair_provider.dart'; - -import 'package:camelus/services/nostr/metadata/metadata_injector.dart'; -import 'package:camelus/services/nostr/metadata/nip_05.dart'; -import 'package:camelus/services/nostr/metadata/user_contacts.dart'; -import 'package:camelus/services/nostr/relays/relay_tracker.dart'; -import 'package:camelus/services/nostr/relays/relays.dart'; -import 'package:camelus/services/nostr/relays/relays_injector.dart'; -import 'package:camelus/services/nostr/relays/relays_ranking.dart'; -import 'package:crypto/crypto.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/src/widgets/framework.dart'; -import 'package:hex/hex.dart'; -import 'package:camelus/helpers/helpers.dart'; -import 'package:camelus/helpers/bip340.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:json_cache/json_cache.dart'; -import 'package:cross_local_storage/cross_local_storage.dart'; - -import 'package:camelus/models/socket_control.dart'; - -class NostrService { - final Future database; - - final Future keyPairWrapper; - - late Future isNostrServiceConnected; - - static String ownPubkeySubscriptionId = - "own-${Helpers().getRandomString(20)}"; - - var userContactsObj = UserContacts(); - - late JsonCache jsonCache; - - late Relays relays; - late RelayTracker relayTracker; - - late KeyPair myKeys; - - late Nip05 nip05service; - - late RelaysRanking relaysRanking; - - // blocked users - List blockedUsers = []; - - Map get usersMetadata => {}; - Map>> get following => userContactsObj.following; - - NostrService({required this.database, required this.keyPairWrapper}) { - RelaysInjector relaysInjector = RelaysInjector(); - MetadataInjector metadataInjector = MetadataInjector(); - - nip05service = metadataInjector.nip05; - relays = relaysInjector.relays; - relayTracker = relaysInjector.relayTracker; - relaysRanking = relaysInjector.relaysRanking; - isNostrServiceConnected = relays.isNostrServiceConnectedCompleter.future; - - relays.receiveEventStream.listen((e) { - _receiveEvent(e["event"], e["socketControl"]); - }); - - _init(); - } - - _init() async { - SystemChannels.lifecycle.setMessageHandler((msg) { - log('SystemChannels> $msg'); - switch (msg) { - case "AppLifecycleState.resumed": - relays.checkRelaysForConnection(); - break; - case "AppLifecycleState.inactive": - break; - case "AppLifecycleState.paused": - break; - case "AppLifecycleState.detached": - relays.closeRelays(); - break; - } - // reconnect to relays - return Future(() { - return; - }); - }); - - await _loadKeyPair(); - - // init streams - - // init json cache - LocalStorageInterface prefs = await LocalStorage.getInstance(); - jsonCache = JsonCacheCrossLocalStorage(prefs); - - // restore blocked users - var blockedUsersCache = (await jsonCache.value('blockedUsers')); - if (blockedUsersCache != null) { - // cast using for loop to avoid type error - var list = blockedUsersCache["blockedUsers"]; - for (String u in list) { - blockedUsers.add(u); - } - } - } - - Future _loadKeyPair() async { - var wrapper = await keyPairWrapper; - if (wrapper.keyPair == null) { - return; - } - myKeys = wrapper.keyPair!; - } - - finishedOnboarding() async { - _loadKeyPair(); - - await relays.connectToRelays(useDefault: true); - - // subscribe to own pubkey - - var data = [ - "REQ", - ownPubkeySubscriptionId, - { - "authors": [myKeys.publicKey], - "kinds": [0, 2, 3], // 0=> metadata, 2=> relays, 3=> contacts - }, - ]; - - var jsonString = json.encode(data); - - for (var relay in relays.connectedRelaysRead.entries) { - relay.value.socket.add(jsonString); - } - } - - /// used for debugging - void clearCache() async { - // clears everything including shared preferences! don't use this! - //await jsonCache.clear(); - - // clear only nostr related stuff - await jsonCache.remove('userFeed'); - await jsonCache.remove('usersMetadata'); - await jsonCache.remove('following'); - - // don't clear relays and blocked users - } - - // clears everything, potentially dangerous - void clearCacheReset() async { - await jsonCache.clear(); - } - - _receiveEvent(event, SocketControl socketControl) async { - if (event[0] != "EVENT") { - log("not an event: $event"); - } - - if (event[0] == "NOTICE") { - log("notice: $event, socket: ${socketControl.connectionUrl}, url: ${socketControl.connectionUrl}"); - return; - } - - if (event[0] == "OK") { - log("ok: $event"); - return; - } - - // blocked users - - if (event.length >= 3) { - if (event[2] != null) { - var eventMap = event[2]; - if (blockedUsers.contains(eventMap["pubkey"])) { - log("blocked user: ${eventMap["pubkey"]}"); - return; - } - } - } - - // ! debug only - if (event[0] == "EVENT") { - var eventBody = event[2]; - // check if its already in the db - await database - .then( - (db) => db.noteDao.insertNostrNote(NostrNote.fromJson(eventBody)), - ) - .onError((error, stackTrace) => { - // probably already in db - }); - } - - // filter by subscription id - - if (event[1] == ownPubkeySubscriptionId) { - if (event[0] == "EOSE") {} - - Map eventMap = event[2]; - // metadata - if (eventMap["kind"] == 0) { - // goes through to normal metadata cache - } - // recommended relays - if (eventMap["kind"] == 2) { - // todo - } - } - - var eventMap = {}; - try { - eventMap = event[2]; //json.decode(event[2]) - } catch (e) {} - - /// global following / contacts - if (eventMap["kind"] == 3) { - userContactsObj.receiveNostrEvent(event, socketControl); - } - - // global EOSE - if (event[0] == "EOSE") { - var now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - if (socketControl.requestInFlight[event[1]] != null) { - var requestsLeft = _howManyRequestsLeft( - event[1], socketControl, relays.connectedRelaysRead); - if (requestsLeft < 2) { - // callback - if (socketControl.completers.containsKey(event[1])) { - // wait 200ms for other events to arrive - Future.delayed(const Duration(milliseconds: 200)).then((value) { - if (!socketControl.completers[event[1]]!.isCompleted) { - socketControl.completers[event[1]]!.complete(); - } - }); - } - } - - // send close request - var req = ["CLOSE", event[1]]; - var reqJson = jsonEncode(req); - - // close the stream - socketControl.socket.add(reqJson); - socketControl.requestInFlight.remove(event[1]); - log("CLOSE request sent to socket Metadata: ${socketControl.id}"); - } - - // contacts - if (socketControl.requestInFlight[event[1]] != null) { - // callback - if (socketControl.completers.containsKey(event[1])) { - if (!socketControl.completers[event[1]]!.isCompleted) { - socketControl.completers[event[1]]!.complete(); - } - } - - // send close request - var req = ["CLOSE", event[1]]; - var reqJson = jsonEncode(req); - socketControl.socket.add(reqJson); - socketControl.requestInFlight.remove(event[1]); - log("CLOSE request sent to socket Contacts: ${socketControl.id}"); - } - - return; - } - } - - writeEvent(String content, int kind, List tags) { - int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - var calcId = [0, myKeys.publicKey, now, kind, tags, content]; - // serialize - String calcIdJson = jsonEncode(calcId); - - // hash - Digest id = sha256.convert(utf8.encode(calcIdJson)); - String idString = id.toString(); - - // hex encode - String idHex = HEX.encode(id.bytes); - - // sign - String sig = Bip340().sign(idHex, myKeys.privateKey); - - var req = [ - "EVENT", - { - "id": idString, - "pubkey": myKeys.publicKey, - "created_at": now, - "kind": kind, - "tags": tags, - "content": content, - "sig": sig - } - ]; - - log("write event: $req"); - - var reqJson = jsonEncode(req); - for (var relay in relays.connectedRelaysWrite.entries) { - if (!(relay.value.socketIsRdy)) { - log("socket not ready"); - continue; - } - relay.value.socket.add(reqJson); - } - } - - _howManyRequestsLeft(String requestId, SocketControl currentSocket, - Map pool) { - int count = 0; - for (var socket in pool.entries) { - if (socket.value.requestInFlight.containsKey(requestId)) { - if (socket.value.id == currentSocket.id) { - continue; - } - count++; - } - } - return count; - } - - void requestAuthors( - {required List authors, - required String requestId, - int? since, - int? until, - int? limit}) {} - - void closeSubscription(String subId) { - var data = [ - "CLOSE", - subId, - ]; - - var jsonString = json.encode(data); - for (var relay in relays.connectedRelaysRead.entries) { - relay.value.socket.add(jsonString); - relay.value.requestInFlight[subId] = true; - //todo add stream - } - } - - /// get user metadata from cache and if not available request it from network - Future>> getUserContacts(String pubkey, - {bool force = false}) async { - return []; - } - - /// returns {nip05, valid, lastCheck, relayHint} exception - Future checkNip05(String nip05, String pubkey) async { - return await nip05service.checkNip05(nip05, pubkey); - } - - Future addToBlocklist(String pubkey) async { - if (!blockedUsers.contains(pubkey)) { - blockedUsers.add(pubkey); - await jsonCache.refresh("blockedUsers", {"blockedUsers": blockedUsers}); - } - - return; - } - - Future removeFromBlocklist(String pubkey) async { - if (blockedUsers.contains(pubkey)) { - blockedUsers.remove(pubkey); - await jsonCache.refresh("blockedUsers", {"blockedUsers": blockedUsers}); - } - return; - } - - Future debug() async { - List userFollows = (await getUserContacts(myKeys.publicKey)) - .map((e) => e[1]) - .toList(); - log("userFollows: $userFollows"); - log("debug"); - relays.getOptimalRelays(userFollows); - } - - Future pickAndReconnect() async { - log("pickAndReconnect"); - var userFollows = (await getUserContacts(myKeys.publicKey)) - .map((e) => e[1]) - .toList(); - log("userFollows: $userFollows"); - - await relays.closeRelays(); - await relays.start(userFollows); - return; - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - throw UnimplementedError(); - } -} diff --git a/lib/services/nostr/relays/my_relay.dart b/lib/services/nostr/relays/my_relay.dart deleted file mode 100644 index ab1cf5d4..00000000 --- a/lib/services/nostr/relays/my_relay.dart +++ /dev/null @@ -1,202 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:developer'; - -import 'package:camelus/db/database.dart'; -import 'package:camelus/models/nostr_note.dart'; -import 'package:camelus/models/nostr_request.dart'; -import 'package:camelus/models/nostr_request_close.dart'; -import 'package:camelus/models/nostr_request_event.dart'; -import 'package:camelus/models/nostr_request_query.dart'; -import 'package:camelus/services/nostr/relays/relay_tracker.dart'; -import 'package:camelus/services/nostr/relays/relays_injector.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -class MyRelay { - final AppDatabase database; - final RelayPersistance persistance; - final String relayUrl; - final bool read; - final bool write; - final int createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - bool connected = false; - int reconnectCount = 0; - bool failing = false; - - late WebSocketChannel _channel; - - // stream - final StreamController _eoseStreamController = - StreamController.broadcast(); - - Stream get eoseStream => _eoseStreamController.stream; - - final Map> _completers = {}; - - late RelayTracker relayTracker; - - MyRelay({ - required this.database, - required this.persistance, - required this.relayUrl, - required this.read, - required this.write, - }) { - RelaysInjector injector = RelaysInjector(); - relayTracker = injector.relayTracker; - } - - /// connects to the relay and listens for events - Future connect() async { - final wssUrl = Uri.parse(relayUrl); - WebSocketChannel channel = WebSocketChannel.connect(wssUrl); - _channel = channel; - - channel.ready.catchError((error) { - log(error.toString()); - //throw Exception("Error in socket"); - failing = true; - return; - }); - - await channel.ready; - - connected = true; - _listen(channel); - log("connteected to relay: $relayUrl"); - return; - } - - _listen(WebSocketChannel channel) { - channel.stream.listen((message) { - _handleIncommingMessage(message); - }); - channel.stream.handleError((error) { - log(error); - throw Exception("Error in socket"); - }); - } - - /// sends a request to the relay - /// if request is a query, it returns the subscriptionId when the server sends EOSE - /// if request is an event, it returns the eventId when the server sends OK - Future request(NostrRequest request) { - var requestJson = request.toRawList(); - _write(_channel, requestJson); - - return _buildCompleter(request); - } - - Future _buildCompleter(NostrRequest request) { - var completer = Completer(); - - // returns EOSE with subscriptionId - if (request is NostrRequestQuery) { - if (_completers.containsKey(request.subscriptionId)) { - try { - _completers[request.subscriptionId]?.complete("closed by new query"); - } catch (e) { - // probably already completed - } - // delete completer - _completers.remove(request.subscriptionId); - } - - _completers[request.subscriptionId] = completer; - } - - // returns OK with event Id - if (request is NostrRequestEvent) { - _completers[request.body.id] = completer; - } - - if (request is NostrRequestClose) { - return Future.value("closed"); - } - - var future = - completer.future.timeout(const Duration(seconds: 10), onTimeout: () { - log("Rtimeout: ${request.subscriptionId}, $relayUrl"); - return "timeout"; - }); - return future; - } - - _write(WebSocketChannel channel, dynamic data) { - channel.sink.add(data); - } - - /// closes the websocket - Future close() { - return _close(_channel); - } - - Future _close(WebSocketChannel channel) { - return channel.sink.close(); - } - - _handleIncommingMessage(dynamic message) async { - List eventJson = json.decode(message); - - if (eventJson[0] == 'OK') { - //nip 20 used to notify clients if an EVENT was successful - log("OK: ${eventJson[1]}"); - - // used for await on query - _completers[eventJson[1]]?.complete(eventJson[1]); - return; - } - - if (eventJson[0] == 'NOTICE') { - log("NOTICE: ${eventJson[1]}"); - return; - } - - if (eventJson[0] == 'EVENT') { - var note = NostrNote.fromJson(eventJson[2]); - - if (eventJson[1] == 'efeed-tmp-unresolvedLoop') { - log("efeed-tmp-unresolvedLoop-WORKS: ${note.id}"); - } - - _insertNoteIntoDb(note); - relayTracker.analyzeNostrEvent(note, relayUrl); - return; - } - if (eventJson[0] == 'EOSE') { - log("EOSE: ${eventJson[1]}, $relayUrl"); - _eoseStreamController.add(eventJson); - // used for await on query - _completers[eventJson[1]]?.complete(eventJson[1]); - return; - } - if (eventJson[0] == 'AUTH') { - log("AUTH: ${eventJson[1]}"); - // nip 42 used to send authentication challenges - return; - } - - if (eventJson[0] == 'COUNT') { - log("COUNT: ${eventJson[1]}"); - // nip 45 used to send requested event counts to clients - return; - } - - log("unknown event: $eventJson"); - } - - _insertNoteIntoDb(NostrNote note) { - database.noteDao.stackInsertNotes([note]); - } - - @override - String toString() { - return "MyRelay: $relayUrl, read: $read, write: $write, persistance: $persistance"; - } -} - -/// manual - added by user -/// gossip - added by gossip -/// tmp - used to query some events -enum RelayPersistance { manual, gossip, tmp, auto } diff --git a/lib/services/nostr/relays/relay_address_parser.dart b/lib/services/nostr/relays/relay_address_parser.dart deleted file mode 100644 index 1d8c4ed8..00000000 --- a/lib/services/nostr/relays/relay_address_parser.dart +++ /dev/null @@ -1,24 +0,0 @@ -class RelayAddressParser { - static String parseAddress(String address) { - // Check if input starts with "wss://" - if (!address.startsWith("wss://")) { - address = "wss://$address"; - } - - // Remove trailing slash if there is one - if (address.endsWith("/")) { - address = address.substring(0, address.length - 1); - } - - // Check if input is in the correct format - RegExp regexDomain = - RegExp(r'^(?:wss?:\/\/)?(?:[a-z0-9-]+\.)+[a-z]{2,}(\/.*)?$'); - if (!regexDomain.hasMatch(address)) { - throw Exception("Invalid address format $address"); - } - if (address.endsWith("/")) { - throw Exception("Invalid address format, tailing slash $address"); - } - return address; - } -} diff --git a/lib/services/nostr/relays/relay_coordinator.dart b/lib/services/nostr/relays/relay_coordinator.dart deleted file mode 100644 index 9bdc07a8..00000000 --- a/lib/services/nostr/relays/relay_coordinator.dart +++ /dev/null @@ -1,624 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:developer'; - -import 'package:camelus/db/database.dart'; -import 'package:camelus/helpers/bip340.dart'; -import 'package:camelus/models/nostr_note.dart'; -import 'package:camelus/models/nostr_request_event.dart'; -import 'package:camelus/models/nostr_request_query.dart'; -import 'package:camelus/models/nostr_tag.dart'; -import 'package:camelus/providers/key_pair_provider.dart'; -import 'package:camelus/services/nostr/metadata/nip_65.dart'; -import 'package:camelus/services/nostr/relays/my_relay.dart'; -import 'package:camelus/services/nostr/relays/relay_address_parser.dart'; -import 'package:camelus/services/nostr/relays/relay_subscription_holder.dart'; -import 'package:camelus/services/nostr/relays/relays_picker.dart'; - -class RelayCoordinator { - final _maxTmpRelayCount = 5; // move magic number to settings - final Future dbFuture; - final Future keyPairFuture; - - late AppDatabase _db; - late KeyPair _keyPair; - - final List _activeSubscriptions = []; - final List _relays = []; - final List _currentlyConnectingRelays = []; - List _gossipRelayAssignments = []; - - final Completer _ready = Completer(); - - final List _ownContacts = []; - int _fetchLatestContactListAt = 0; - - List get relays => _relays; - Stream> get relaysStream => _relaysStreamController.stream; - final StreamController> _relaysStreamController = - StreamController>.broadcast(); - - RelayCoordinator({ - required this.dbFuture, - required this.keyPairFuture, - }) { - _init(); - } - - _init() async { - _db = await dbFuture; - _keyPair = (await keyPairFuture).keyPair!; - - await _initStreamOwnContacts(_keyPair.publicKey); - - await _initConnectSequence(); - - _ready.complete(); - - _setupOwnPermanentSubscription(pubkey: _keyPair.publicKey); - } - - // following provider cant be used because circular dependency - Future _initStreamOwnContacts(String myPubkey) async { - _db.noteDao.findPubkeyNotesByKindStream([myPubkey], 3).listen((dbList) { - if (dbList.isEmpty) { - //_contactsController.add([]); - return; - } - - var kind3 = dbList.first.toNostrNote(); - // got something older than latest event - if (_fetchLatestContactListAt != 0 && - kind3.created_at <= _fetchLatestContactListAt) { - return; - } - - _fetchLatestContactListAt = kind3.created_at; - - var newContacts = kind3.getTagPubkeys; - - _ownContacts.clear(); - _ownContacts.addAll(newContacts); - }); - } - - Future _initConnectSequence() async { - var selectedManualRelays = await _findInitalManualRelays(); - - List connectFutures = []; - for (var relay in selectedManualRelays.entries) { - connectFutures.add( - _connectToRelay( - relayUrl: relay.key, - read: relay.value['read'] ?? true, - write: relay.value['write'] ?? true, - persistance: RelayPersistance.manual, - ), - ); - } - - List followingPubkeys = _ownContacts.map((e) => e.value).toList(); - - var selectedGossipRelays = await _findInitalGossipRelays(followingPubkeys); - //remove potential duplicates - selectedGossipRelays.removeWhere((key, value) => - selectedManualRelays.containsKey(key) || - _relays.any((element) => element.relayUrl == key)); - - //! disabled gossip for now - // for (var relay in selectedGossipRelays.entries) { - // connectFutures.add( - // _connectToRelay( - // relayUrl: relay.key, - // read: relay.value['read'] ?? true, - // write: relay.value['write'] ?? true, - // persistance: RelayPersistance.gossip, - // ), - // ); - // } - - await Future.any(connectFutures); - // wait additional 2 seconds for other relays to connect - await Future.delayed(const Duration(seconds: 2)); - return; - } - - Future>> _findInitalManualRelays() async { - Map> selectedRelays; - - //replace from api call for best relays in region - final Map> initRelays = { - "wss://nostr.bitcoiner.social": { - "write": true, - "read": true, - }, - "wss://nostr.zebedee.cloud": { - "write": true, - "read": true, - }, - "wss://nos.lol": {"write": true, "read": true}, - "wss://relay.damus.io": {"write": true, "read": true}, - }; - - // manual relays - try { - selectedRelays = await _getUserManualRelays(_keyPair.publicKey); - } catch (e) { - selectedRelays = initRelays; - } - - return selectedRelays; - } - - Future>> _findInitalGossipRelays( - List pubkeys) async { - _gossipRelayAssignments = await _getOptimalRelays(pubkeys); - - var converted = Map.fromEntries( - _gossipRelayAssignments.map((e) => MapEntry(e.relayUrl, { - "write": false, - "read": true, - }))); - return converted; - } - - Future request({ - required NostrRequestQuery request, - Duration timeout = const Duration(seconds: 10), - }) async { - await _ready.future; // wait to be connected to at least one relay - - var subscription = _checkForAlreadyActiveSubscription(request); - - if (subscription == null) { - // create new subscription - subscription = RelaySubscriptionHolder(request: request); - _activeSubscriptions.add(subscription); - } else { - log("WARN: already have subscription for this request"); - //return; - } - - var possiblePubkeys = request.getAllPossiblePubkeys; - if (possiblePubkeys.isNotEmpty) { - return _optimizedPubkeyRequest( - request: request, - subscription: subscription, - timeout: timeout, - ); - } - - var allReadRelays = _relays.where((element) => element.read).toList(); - - if (allReadRelays.isEmpty) { - throw Exception("no read relays"); - } - - List> allRelayRequests = []; - for (var relay in allReadRelays) { - var future = relay.request(request); - allRelayRequests.add(future); - subscription.addRelay(relay); - log("sending unoptimized request to ${relay.relayUrl} -- ${request.subscriptionId}"); - } - var combinedFuture = Future.wait(allRelayRequests).timeout( - timeout, - onTimeout: () { - return []; - }, - ); - return combinedFuture; - } - - Future _optimizedPubkeyRequest({ - required NostrRequestQuery request, - required RelaySubscriptionHolder subscription, - required Duration timeout, - }) async { - var connectedRelays = - _relays.where((element) => element.connected).toList(); - var connectedRelayUrls = connectedRelays.map((e) => e.relayUrl).toList(); - - var nip65Helper = Nip65(_db); - - // find minimal relay set - var minimalRelaySet = await nip65Helper.calcMinimalRelaySet( - pubkeys: request.getAllPossiblePubkeys, - preferConnectedRelays: connectedRelayUrls, - ); - - // split among found pubkey relay assignments - var foundSplitAssignmentRequest = Nip65.splitUpRequests( - request: request, - assignments: minimalRelaySet.relayAssignments, - ); - - // craft relay assignment for missing pubkeys - List combindedRelayUrls = [ - ...connectedRelayUrls, - ...minimalRelaySet.relayAssignments.map((e) => e.relayUrl) - ]; - // remove duplicates - combindedRelayUrls = combindedRelayUrls.toSet().toList(); - - List missingAssignments = []; - if (minimalRelaySet.missingWithNoRelay.isNotEmpty) { - for (var relayUrl in combindedRelayUrls) { - missingAssignments.add( - RelayAssignment( - relayUrl: relayUrl, - pubkeys: minimalRelaySet.missingWithNoRelay, - ), - ); - } - } - - var missingSplitAssignmentRequest = Nip65.splitUpRequests( - request: request, - assignments: missingAssignments, - ); - - // connect to relays that are not connected yet - List> autoRelayFuture = []; - for (var splitRequest in foundSplitAssignmentRequest.entries) { - if (connectedRelayUrls.contains(splitRequest.key)) { - // already connected to this relay - continue; - } - if (_currentlyConnectingRelays.contains(splitRequest.key)) { - continue; - } - autoRelayFuture.add(_connectToRelay( - relayUrl: splitRequest.key, - read: true, - write: false, - persistance: RelayPersistance.auto, - )); - } - var myNewAutoRelaysResult = - await Future.wait(autoRelayFuture, eagerError: false); - // remove nulls - myNewAutoRelaysResult.removeWhere((element) => element == null); - List myNewAutoRelays = - myNewAutoRelaysResult.map((e) => e as MyRelay).toList(); - - var combindedRelays = [...myNewAutoRelays, ...connectedRelays]; - - // send out requests - List> allRelayRequests = []; - - for (var relay in combindedRelays) { - if (foundSplitAssignmentRequest.containsKey(relay.relayUrl) && - missingSplitAssignmentRequest.containsKey(relay.relayUrl)) { - var myFirstRequest = foundSplitAssignmentRequest[relay.relayUrl]!; - var mySecondRequest = missingSplitAssignmentRequest[relay.relayUrl]!; - var myCombinedRequest = myFirstRequest.mergeQuery(mySecondRequest); - - log("sending combined request to ${relay.relayUrl} for ${myCombinedRequest.body.authors} ${myCombinedRequest.subscriptionId}"); - - var future = relay.request(myCombinedRequest); - allRelayRequests.add(future); - // to listen to EOSE response - subscription.addRelay(relay); - - continue; - } - - if (foundSplitAssignmentRequest.containsKey(relay.relayUrl)) { - var myRequest = foundSplitAssignmentRequest[relay.relayUrl]!; - log("sending targeted request to ${relay.relayUrl} for ${myRequest.body.authors} ${myRequest.subscriptionId}"); - - var future = relay.request(myRequest); - allRelayRequests.add(future); - // to listen to EOSE response - subscription.addRelay(relay); - //continue; // todo combine with missing - } - - if (missingSplitAssignmentRequest.containsKey(relay.relayUrl)) { - var myRequest = missingSplitAssignmentRequest[relay.relayUrl]!; - log("sending missing request to ${relay.relayUrl} for ${myRequest.body.authors} ${myRequest.subscriptionId}"); - - var future = relay.request(myRequest); - allRelayRequests.add(future); - // to listen to EOSE response - subscription.addRelay(relay); - } - } - - var combinedFuture = Future.wait(allRelayRequests).timeout( - timeout, - onTimeout: () { - return []; - }, - ); - return combinedFuture; - } - - Future requestFromRelays({ - required NostrRequestQuery request, - required List relayCandidates, - Duration timeout = const Duration(seconds: 2), - }) async { - var subscription = _checkForAlreadyActiveSubscription(request); - - if (subscription == null) { - // create new subscription - subscription = RelaySubscriptionHolder(request: request); - _activeSubscriptions.add(subscription); - } else { - log("already have subscription for this request"); - return; - } - - var allReadRelays = _relays.where((element) => element.read).toList(); - - // create list of alrady connected relays that match with the relayCandidates - var connectedRelays = _relays - .where((element) => relayCandidates.contains(element.relayUrl)) - .toList(); - - var connectedTmpRelays = _relays - .where((element) => element.persistance == RelayPersistance.tmp) - .toList(); - int countOfTmpRelays = connectedTmpRelays.length; - - if (countOfTmpRelays >= _maxTmpRelayCount) { - // disconnect oldest two tmp relays - connectedTmpRelays.sort((a, b) => a.createdAt.compareTo(b.createdAt)); - for (int i = 0; i < 2 - _maxTmpRelayCount; i++) { - await connectedTmpRelays[i].close(); - } - } - - // select two relays from relayCandidates that are not already connected - var relaysToConnect = relayCandidates - .where((element) => - !connectedRelays.any((connected) => connected.relayUrl == element)) - .take(2 - countOfTmpRelays) - .toList(); - - // connect to relays - List> futures = []; - for (var relay in relaysToConnect) { - futures.add(_connectToRelay( - relayUrl: relay, - read: true, - write: false, - persistance: RelayPersistance.tmp, - )); - } - var myTmpRelaysResult = await Future.wait(futures); - myTmpRelaysResult.removeWhere((element) => element == null); - List myTmpRelays = - myTmpRelaysResult.map((e) => e as MyRelay).toList(); - - var combinedRelays = [...connectedRelays, ...myTmpRelays]; - - List> relayRequests = []; - // send request to combined relays - for (var relay in combinedRelays) { - var future = relay.request(request); - relayRequests.add(future); - // to listen to EOSE response - subscription.addRelay(relay); - } - - var combinedFuture = Future.wait(relayRequests).timeout( - timeout, - onTimeout: () { - return []; - }, - ); - return combinedFuture; - } - - Future> write({ - required NostrRequestEvent request, - Duration timeout = const Duration(seconds: 10), - List exactRelays = const [], - }) async { - List allWriteRelays = - _relays.where((element) => element.write).toList(); - - List connectedRelaysThatMatch = []; - - if (exactRelays.isNotEmpty) { - connectedRelaysThatMatch = _relays - .where((element) => exactRelays.contains(element.relayUrl)) - .toList(); - } - - List toConnectRelays = exactRelays - .where((element) => - !connectedRelaysThatMatch - .any((connected) => connected.relayUrl == element) && - allWriteRelays.any((all) => all.relayUrl == element)) - .toList(); - - List> futures = []; - for (var relay in toConnectRelays) { - futures.add(_connectToRelay( - relayUrl: relay, - read: false, - write: true, - persistance: RelayPersistance.tmp, - )); - } - // remove nulls - var myTmpRelaysResult = await Future.wait(futures); - myTmpRelaysResult.removeWhere((element) => element == null); - List myTmpRelays = - myTmpRelaysResult.map((e) => e as MyRelay).toList(); - - List combinedRelays = []; - if (exactRelays.isEmpty) { - combinedRelays = [...allWriteRelays]; - } else { - combinedRelays = [...connectedRelaysThatMatch, ...myTmpRelays]; - } - - List> relayRequests = []; - // send request to combined relays - for (var relay in combinedRelays) { - var future = relay.request(request); - relayRequests.add(future); - } - - var combinedFuture = Future.wait(relayRequests).timeout( - timeout, - onTimeout: () { - return []; - }, - ); - - // disconnect write tmp relays - for (var relay in myTmpRelays) { - await relay.close(); - } - - return combinedFuture; - } - - RelaySubscriptionHolder? _checkForAlreadyActiveSubscription( - NostrRequestQuery request) { - // check if we have a subscription for this request - RelaySubscriptionHolder? subscription; - for (var element in _activeSubscriptions) { - if (element.subscriptionId == request.subscriptionId) { - subscription = element; - break; - } - } - return subscription; - } - - addRelayToRequest(String requestId, String relayId) { - return throw UnimplementedError(); - } - - // close the subscription but keeps the websocket open - closeSubscription(String subscriptionId) { - for (var subscription in _activeSubscriptions) { - if (subscription.subscriptionId == subscriptionId) { - subscription.close(); - _activeSubscriptions.remove(subscription); - break; - } - } - } - - Future>> _getUserManualRelays( - String pubkey) async { - var kind3 = await _db.noteDao.findPubkeyNotesByKind([pubkey], 3); - if (kind3.isEmpty) { - throw Exception("No relays found for this user"); - } - NostrNote latest = kind3.first.toNostrNote(); - var rawJson = jsonDecode(latest.content); - - // cast to Map> - var toCast = Map>.from(rawJson); - - toCast.map((key, value) { - var casted = Map.from(value); - toCast[key] = casted; - return MapEntry(key, casted); - }); - var relays = Map>.from(toCast); - - // parse relayUrls - for (var relayUrl in relays.keys) { - try { - relayUrl = RelayAddressParser.parseAddress(relayUrl); - } catch (e) { - // remove - relays.remove(relayUrl); - log("Invalid relay address: $relayUrl, removing from list"); - } - } - - return relays; - } - - Future _connectToRelay({ - required String relayUrl, - required bool read, - required bool write, - required RelayPersistance persistance, - }) async { - _currentlyConnectingRelays.add(relayUrl); - - var relay = MyRelay( - database: _db, - relayUrl: relayUrl, - read: read, - write: write, - persistance: persistance); - try { - await relay.connect(); - } catch (e) { - _currentlyConnectingRelays.remove(relayUrl); - log("Failed to connect to relay: $relayUrl error: $e"); - return null; - } - - _relays.add(relay); - _relaysStreamController.add(_relays); - _currentlyConnectingRelays.remove(relayUrl); - return relay; - } - - Future> _getOptimalRelays(List pubkeys) async { - var relaysPicker = RelaysPicker(); - await relaysPicker.init( - pubkeys: pubkeys, - coverageCount: 2); //todo: move coverageCount to settings - - List foundRelays = []; - Map excludedRelays = {}; - - int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - while (true) { - try { - var result = relaysPicker.pick(pubkeys); - var assignment = relaysPicker.getRelayAssignment(result); - if (assignment == null) { - continue; - } - if (assignment.relayUrl.isEmpty) { - continue; - } - foundRelays.add(assignment); - - // exclude already found relays - excludedRelays[assignment.relayUrl] = now; - - relaysPicker.setExcludedRelays = excludedRelays; - } catch (e) { - log("catch: $e"); - break; - } - } - for (var relay in foundRelays) { - log("relay-assignment: ${relay.relayUrl}, pubkey: ${relay.pubkeys.length}"); - } - return foundRelays; - } - - // used to keep in sync - void _setupOwnPermanentSubscription({required String pubkey}) { - var myBody = NostrRequestQueryBody( - authors: [pubkey], - kinds: [ - 0, - 1, - 3, - 10002, - ], - limit: 5, - ); - var myRequest = NostrRequestQuery(subscriptionId: "self", body: myBody); - request(request: myRequest); - } -} diff --git a/lib/services/nostr/relays/relay_subscription_holder.dart b/lib/services/nostr/relays/relay_subscription_holder.dart deleted file mode 100644 index 294bb5cd..00000000 --- a/lib/services/nostr/relays/relay_subscription_holder.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'dart:async'; - -import 'package:camelus/models/nostr_request_query.dart'; -import 'package:camelus/models/nostr_request_close.dart'; -import 'package:camelus/services/nostr/relays/my_relay.dart'; - -class RelaySubscriptionHolder { - final NostrRequestQuery _request; - final List _relays = []; - - final List _EOSEvents = []; - final List _subscriptions = []; - - RelaySubscriptionHolder({required NostrRequestQuery request}) - : _request = request; - - String get subscriptionId => _request.subscriptionId; - - void addRelay(MyRelay relay) { - _relays.add(relay); - //todo listen to relay eos stream - _subscriptions.add( - relay.eoseStream.listen((event) { - var now = DateTime.now().millisecondsSinceEpoch / 1000; - _EOSEvents.add({ - 'subscriptionId': subscriptionId, - 'relayUrl': relay.relayUrl, - 'receivedAt': now, - 'event': event, - }); - }), - ); - } - - void addRelays(List relays) { - for (var element in relays) { - addRelay(element); - } - } - - void removeRelay(MyRelay relay) { - _relays.remove(relay); - } - - /// closes the subscription in the sockets - void close() { - _closeAllSubscriptions(); - NostrRequestClose closeRequest = NostrRequestClose( - subscriptionId: subscriptionId, - ); - for (var element in _relays) { - element.request(closeRequest); - } - _EOSEvents.clear(); - } - - void _closeAllSubscriptions() { - for (var element in _subscriptions) { - element.cancel(); - } - } -} diff --git a/lib/services/nostr/relays/relay_tracker.dart b/lib/services/nostr/relays/relay_tracker.dart deleted file mode 100644 index 3305308b..00000000 --- a/lib/services/nostr/relays/relay_tracker.dart +++ /dev/null @@ -1,223 +0,0 @@ -import 'dart:convert'; - -import 'package:camelus/models/nostr_note.dart'; -import 'package:camelus/models/nostr_tag.dart'; -import 'package:camelus/services/nostr/metadata/metadata_injector.dart'; -import 'package:camelus/services/nostr/metadata/nip_05.dart'; -import 'package:camelus/services/nostr/relays/relay_address_parser.dart'; -import 'package:cross_local_storage/cross_local_storage.dart'; -import 'package:json_cache/json_cache.dart'; - -enum RelayTrackerAdvType { - kind03, - nip05, - tag, - lastFetched, -} - -class RelayTracker { - /// pubkey,relayUrl :{, lastSuggestedKind3, lastSuggestedNip05, lastSuggestedBytag} - Map> tracker = {}; - - late Nip05 nip05service; - late JsonCache jsonCache; - - RelayTracker() { - nip05service = MetadataInjector().nip05; - - _initJsonCache(); - } - - void _initJsonCache() async { - LocalStorageInterface prefs = await LocalStorage.getInstance(); - jsonCache = JsonCacheCrossLocalStorage(prefs); - _restoreFromCache(); - } - - void _restoreFromCache() async { - var cache = await jsonCache.value('tracker'); - // cast to Map> - - if (cache != null) { - // cast to Map> - var cacheMap = Map.fromEntries(cache.entries.map( - (entry) => MapEntry(entry.key, entry.value as Map))); - - tracker = cacheMap; - } - } - - void _updateCache() async { - await jsonCache.refresh('tracker', tracker); - } - - void clear() { - tracker = {}; - _updateCache(); - } - - Future analyzeNostrEvent(NostrNote event, String socketUrl) async { - // lastFetched - var personPubkey = event.pubkey; - var now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - var myRelayUrl = RelayAddressParser.parseAddress(socketUrl); - trackRelays( - personPubkey, [myRelayUrl], RelayTrackerAdvType.lastFetched, now); - - // kind 3 - if (event.kind == 3) { - /* EXAMPLE - "tags": [ - ["p", "91cf9..4e5ca", "wss://alicerelay.com/", "alice"], - ["p", "14aeb..8dad4", "wss://bobrelay.com/nostr", "bob"], - ["p", "612ae..e610f", "ws://carolrelay.com/ws", "carol"] - ], - */ - - List nostrTags = event.tags ?? []; - - for (var tag in nostrTags) { - if (tag.type == "p" && tag.recommended_relay != null) { - trackRelays( - tag.value, - [tag.recommended_relay!], - RelayTrackerAdvType.kind03, - event.created_at, - ); - } - } - - if (event.content == "") { - return; - } - - // own adv - Map content = jsonDecode(event.content); - List writeRelays = []; - String personPubkey = event.pubkey ?? ""; - - if (personPubkey.isEmpty) { - return; - } - - // extract write relays - for (var relay in content.entries) { - if (relay.value["write"] == true) { - writeRelays.add(relay.key); - } - } - trackRelays(personPubkey, writeRelays, RelayTrackerAdvType.kind03, - event.created_at); - } - - // nip05 - if (event.kind == 0) { - Map metadata = jsonDecode(event.content); - String nip05 = metadata["nip05"] ?? ""; - String pubkey = event.pubkey; - if (nip05.isEmpty || pubkey.isEmpty) { - return; - } - - Map result = - await nip05service.checkNip05(nip05, pubkey); - - if (result.isEmpty) { - return; - } - - if (!result["valid"]) { - return; - } - - if (result["relays"] == null) { - return; - } - - List resultRelays = List.from(result["relays"]); - - trackRelays( - pubkey, resultRelays, RelayTrackerAdvType.nip05, event.created_at); - } - // by tag - if (event.kind == 1) { - /* EXAMPLE - "tags": [ - ["e", , , ] - ["p", , , ] - ], - */ - - List nostrTags = event.tags ?? []; - - for (var tag in nostrTags) { - if (tag.type == "p" && tag.recommended_relay != null) { - trackRelays(tag.value, [tag.recommended_relay!], - RelayTrackerAdvType.tag, event.created_at); - } - } - } - } - - /// get called when a event advertises a relay pubkey connection - /// timestamp can be a string or int - void trackRelays(String personPubkey, List relayUrls, - RelayTrackerAdvType nip, dynamic timestamp) { - var relayUrlsCleaned = []; - - for (var relay in relayUrls) { - try { - var cleaned = RelayAddressParser.parseAddress(relay); - relayUrlsCleaned.add(cleaned); - } catch (e) { - continue; - } - } - - if (timestamp.runtimeType == String) { - timestamp = int.parse(timestamp); - } - - if (personPubkey.isEmpty || relayUrlsCleaned.isEmpty) { - return; - } - - for (var relayUrl in relayUrlsCleaned) { - _populateTracker(personPubkey, relayUrl); - } - - for (var relayUrl in relayUrlsCleaned) { - if (relayUrl.isEmpty) { - continue; - } - if (personPubkey.isEmpty) { - continue; - } - - switch (nip) { - case RelayTrackerAdvType.kind03: - tracker[personPubkey]![relayUrl]!["lastSuggestedKind3"] = timestamp; - break; - case RelayTrackerAdvType.nip05: - tracker[personPubkey]![relayUrl]!["lastSuggestedNip05"] = timestamp; - break; - case RelayTrackerAdvType.tag: - tracker[personPubkey]![relayUrl]!["lastSuggestedBytag"] = timestamp; - break; - case RelayTrackerAdvType.lastFetched: - tracker[personPubkey]![relayUrl]!["lastFetched"] = timestamp; - break; - } - } - _updateCache(); - } - - _populateTracker(String personPubkey, String relayUrl) { - if (tracker[personPubkey] == null) { - tracker[personPubkey] = {}; - } - if (tracker[personPubkey]![relayUrl] == null) { - tracker[personPubkey]![relayUrl] = {}; - } - } -} diff --git a/lib/services/nostr/relays/relays.dart b/lib/services/nostr/relays/relays.dart deleted file mode 100644 index 13e6e420..00000000 --- a/lib/services/nostr/relays/relays.dart +++ /dev/null @@ -1,528 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:developer'; -import 'dart:io'; - -import 'package:camelus/helpers/helpers.dart'; -import 'package:camelus/models/socket_control.dart'; -import 'package:camelus/services/nostr/relays/relay_tracker.dart'; -import 'package:camelus/services/nostr/relays/relays_injector.dart'; -import 'package:camelus/services/nostr/relays/relays_picker.dart'; -import 'package:cross_local_storage/cross_local_storage.dart'; - -import 'package:json_cache/json_cache.dart'; - -class Relays { - final Map> initRelays = { - "wss://nostr.bitcoiner.social": { - "write": true, - "read": true, - "default": true - }, - "wss://nostr.zebedee.cloud": {"write": true, "read": true, "default": true}, - //"wss://brb.io": {"write": true, "read": true}, - "wss://nos.lol": {"write": true, "read": true, "default": true}, - "wss://relay.damus.io": {"write": true, "read": true, "default": true}, - }; - - Map> manualRelays = {}; - - Map> failingRelays = {}; - - Map> relays = {}; - - Map> userRelayMatching = {}; - - Map connectedRelaysRead = {}; - Map connectedRelaysWrite = {}; - - List relayAssignments = []; - - static final StreamController> - _connectedRelaysReadStreamController = - StreamController>.broadcast(); - Stream> get connectedRelaysReadStream => - _connectedRelaysReadStreamController.stream; - - late JsonCache _jsonCache; - - late RelayTracker relayTracker; - - final Completer isNostrServiceConnectedCompleter = Completer(); - - // stream for receiving events from relays - static final StreamController> - _receiveEventStreamController = - StreamController>.broadcast(); - Stream> get receiveEventStream => - _receiveEventStreamController.stream; - - Relays() { - RelaysInjector injector = RelaysInjector(); - relayTracker = injector.relayTracker; - _initJsonCache(); - } - - void _initJsonCache() async { - LocalStorageInterface prefs = await LocalStorage.getInstance(); - _jsonCache = JsonCacheCrossLocalStorage(prefs); - _restoreFromCache(); - } - - void _restoreFromCache() async { - var cache = await _jsonCache.value('manual-relays'); - - if (cache == null) { - return; - } - //{'relays': relays, 'timestamp': now} - - //manualRelays = cache['relays']; - - manualRelays = Map>.from(cache['relays']); - } - - Future start(List pubkeys) async { - return; - //clean up - relayAssignments = []; - - relayAssignments = await getOptimalRelays(pubkeys); - - //"wss://nostr.bitcoiner.social": {"write": true, "read": true} - - var converted = - Map.fromEntries(relayAssignments.map((e) => MapEntry(e.relayUrl, { - "write": false, - "read": true, - "dynamic": true, - "manual": false, - "default": false, - }))); - - relays = converted; - - // add manual relays - var manualRelaysCast = Map>.from(manualRelays.map( - (key, value) => - MapEntry(key.toString(), Map.from(value)))); - - // check for duplicates && merge - for (var item in List.from(manualRelaysCast.entries)) { - //check for duplicates - if (relays.containsKey(item.key)) { - log("duplicate relay $item"); - // merge them if one of the values is true => true - var relay = relays[item.key]; - var manualRelay = manualRelaysCast[item.key]; - - if (relay?["write"] == true || manualRelay?["write"] == true) { - manualRelay?["write"] = true; - } - if (relay?["read"] == true || manualRelay!["read"] == true) { - manualRelay?["read"] = true; - } - } - } - relays.addAll(manualRelaysCast); - - bool useDefault = relays.isEmpty; - - await connectToRelays(useDefault: useDefault); - return; - } - - Future connectToRelays({bool useDefault = false}) async { - var usedRelays = useDefault ? initRelays : relays; - - for (var relay in usedRelays.entries) { - Future? socket; - - if (relay.value["read"] == true) { - socket ??= WebSocket.connect(relay.key); - - var id = "relay-r-${Helpers().getRandomString(5)}"; - - SocketControl socketControl = SocketControl(id, relay.key); - - socket - .then((value) => { - // set socket - socketControl.socket = value, - socketControl.socketIsRdy = true, - - value.listen((event) { - socketControl.socketReceivedEventsCount++; - var eventJson = json.decode(event); - _receiveEventStreamController.add({ - "event": eventJson, - "socketControl": socketControl, - }); - //relayTracker.analyzeNostrEvent(eventJson, socketControl); - }, onDone: () { - // if pick and connect don't try to reconnect - - try { - // on disconnect - connectedRelaysRead[id]!.socketIsRdy = false; - _reconnectToRelayRead(id); - _connectedRelaysReadStreamController - .add(connectedRelaysRead); - // ignore: empty_catches - } catch (e) { - log("654dD: $e"); - } - }, onError: (e) { - log("onError: $e"); - }), - connectedRelaysRead[id] = socketControl, - _connectedRelaysReadStreamController.add(connectedRelaysRead), - }) - .catchError((e) { - failingRelays[relay.key] = {...relay.value, "error": e.toString()}; - return Future(() => {log("OLDerror connecting to relay $e")}); - }); - } - - if (relay.value["write"] == true) { - socket ??= WebSocket.connect(relay.key); - var id = "relay-w-${Helpers().getRandomString(5)}"; - - SocketControl socketControl = SocketControl(id, relay.key); - - socket - .then((value) => { - socketControl.socket = value, - socketControl.socketIsRdy = true, - connectedRelaysWrite[id] = socketControl, - - // check if already listened to this socket - if (value.hashCode != - connectedRelaysWrite[id]!.socket.hashCode) - value.listen((event) {}, onDone: () { - // on disconnect - connectedRelaysWrite[id]!.socketIsRdy = false; - _reconnectToRelayWrite(id); - }), - }) - .catchError((e) { - failingRelays[relay.key] = {...relay.value, "error": e.toString()}; - }); - } - } - // wait check if relays promise is resolved //todo currently hotfix - await Future.delayed(const Duration(seconds: 2)); - - try { - isNostrServiceConnectedCompleter.complete(true); - } catch (e) { - log("e"); - } - - return; - } - - _reconnectToRelayRead(String id) async { - SocketControl socketControl = connectedRelaysRead[id]!; - socketControl.socketIsRdy = false; - var waitTime = 1 * socketControl.socketFailingAttempts; - // wait - - await Future.delayed(Duration(seconds: waitTime)); - - // try to reconnect - WebSocket? socket; - try { - socket = await WebSocket.connect(socketControl.connectionUrl); - } catch (e) {} - - if (socket?.readyState == WebSocket.open) { - socketControl.socket = socket!; - socketControl.socketIsRdy = true; - socketControl.socketFailingAttempts = 0; - socket.listen((event) { - var eventJson = json.decode(event); - _receiveEventStreamController.add({ - "event": eventJson, - "socketControl": socketControl, - }); - }, onDone: () { - // on disconnect - try { - connectedRelaysRead[id]!.socketIsRdy = false; - _reconnectToRelayRead(id); - _connectedRelaysReadStreamController.add(connectedRelaysRead); - // ignore: empty_catches - } catch (e) {} - }); - } else if (socketControl.socketFailingAttempts > 30) { - socketControl.socketIsFailing = true; - socketControl.socketIsRdy = false; - _connectedRelaysReadStreamController.add(connectedRelaysRead); - } else { - socketControl.socketFailingAttempts++; - _reconnectToRelayRead(id); - } - } - - _reconnectToRelayWrite(String id) async { - SocketControl socketControl = connectedRelaysWrite[id]!; - socketControl.socketIsRdy = false; - var waitTime = 1 * socketControl.socketFailingAttempts; - // wait - await Future.delayed(Duration(seconds: waitTime)); - // try to reconnect - WebSocket? socket; - try { - socket = await WebSocket.connect(socketControl.connectionUrl); - } catch (e) {} - - if (socket?.readyState == WebSocket.open) { - socketControl.socket = socket!; - socketControl.socketIsRdy = true; - socketControl.socketFailingAttempts = 0; - socket.listen((event) {}, onDone: () { - // on disconnect - connectedRelaysWrite[id]!.socketIsRdy = false; - _reconnectToRelayWrite(id); - }); - } else if (socketControl.socketFailingAttempts > 30) { - socketControl.socketIsFailing = true; - socketControl.socketIsRdy = false; - } else { - socketControl.socketFailingAttempts++; - _reconnectToRelayWrite(id); - } - } - - checkRelaysForConnection() async { - if (connectedRelaysRead.isEmpty) { - await connectToRelays(); - } - - for (var relay in connectedRelaysRead.entries) { - if (relay.value.socketIsRdy == false) { - _reconnectToRelayRead(relay.key); - } - } - for (var relay in connectedRelaysWrite.entries) { - if (relay.value.socketIsRdy == false) { - _reconnectToRelayWrite(relay.key); - } - } - } - - Future closeRelays() async { - for (var relay in connectedRelaysRead.entries) { - await relay.value.socket.close(); - // remove from array - connectedRelaysRead.remove(relay); - } - for (var relay in connectedRelaysWrite.entries) { - await relay.value.socket.close(); - // remove from array - connectedRelaysWrite.remove(relay); - } - - //manualRelays = {}; - - failingRelays = {}; - - relays = {}; - - userRelayMatching = {}; - - connectedRelaysRead = {}; - connectedRelaysWrite = {}; - - //relayAssignments = []; - - return; - } - - // selects the best relays based on the given pubkeys of the tracked pubkeys - Future> getOptimalRelays(List pubkeys) async { - var relaysPicker = RelaysPicker(); - await relaysPicker.init( - pubkeys: pubkeys, - coverageCount: 2); //todo: move coverageCount to settings - - List foundRelays = []; - Map excludedRelays = {}; - - int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - while (true) { - try { - var result = relaysPicker.pick(pubkeys); - var assignment = relaysPicker.getRelayAssignment(result); - if (assignment == null) { - continue; - } - if (assignment.relayUrl.isEmpty) { - continue; - } - foundRelays.add(assignment); - - // exclude already found relays - excludedRelays[assignment.relayUrl] = now; - - relaysPicker.setExcludedRelays = excludedRelays; - } catch (e) { - log("catch: $e"); - break; - } - } - for (var relay in foundRelays) {} - - return foundRelays; - } - - /// sends a request to specified relays in relay assignments or default if not in relay assignments - void requestEvents(List request, - {dynamic additionalData, - StreamController? streamController, - Completer? completer}) { - return; - Map splitRequest = _splitRequestByRelays(request); - - String reqId = request[1]; - - for (var relay in connectedRelaysRead.entries) { - List specificRequest = []; - try { - // if something disconnects and the relay is not in the list anymore - specificRequest = splitRequest[relay.value.connectionUrl]; - } catch (e) { - log("error: $e"); - return; - } - - var jsonRequest = json.encode(specificRequest); - - relay.value.socket.add(jsonRequest); - relay.value.requestInFlight[reqId] = true; - - if (additionalData != null) { - relay.value.additionalData[reqId] = additionalData; - } - - if (streamController != null) { - relay.value.streamControllers[reqId] = streamController; - } - if (completer != null) { - relay.value.completers[reqId] = completer; - } - } - } - - Map _splitRequestByRelays(List request) { - Map requestBody = Map.from(request[2]); - - // don't do anything if there is no authors - if (!requestBody.containsKey("authors")) { - for (var relay in connectedRelaysRead.entries) { - String relayUrl = relay.value.connectionUrl; - - Map requestsMap = {}; - - requestsMap[relayUrl] = request; - return requestsMap; - } - } - List pubkeys = requestBody["authors"]; - - Map countMap = {}; - for (var pubkey in pubkeys) { - countMap[pubkey] = 2; //todo: magic number move to settings - } - - Map> relayPubkeyMap = {}; - - var assignments = relayAssignments; - - // result should look like this - // relayPubkeyMap = { - // "relay1": ["pubkey1", "pubkey2"], - // "relay2": ["pubkey3", "pubkey4"], - // } - // and with each iteration the countMap is reduced - // countMap = { - // "pubkey1": 0, - // "pubkey2": 0, - // "pubkey3": 0, - // "pubkey4": 0, - // } - - for (var relay in connectedRelaysRead.entries) { - var relayUrl = relay.value.connectionUrl; - - for (var pubkey in pubkeys) { - if (countMap[pubkey] == 0) { - continue; - } - if (assignments - .where((element) => - element.relayUrl == relayUrl && - element.pubkeys.contains(pubkey)) - .isNotEmpty) { - if (relayPubkeyMap.containsKey(relayUrl)) { - relayPubkeyMap[relayUrl]!.add(pubkey); - } else { - relayPubkeyMap[relayUrl] = [pubkey]; - } - countMap[pubkey] = countMap[pubkey] - 1; - } - } - } - - List remainingPubkeys = List.from(countMap.entries - .where((element) => element.value > 0) - .map((e) => e.key) - .toList()); - - Map newRequests = {}; - // craft new request for each relay SPECIFIC - for (var relay in relayPubkeyMap.entries) { - var relayUrl = relay.key; - var pubkeys = relay.value; - - // deep copy request, if there is a better way, please let me know - List newRequest = json.decode(json.encode(request)); - newRequest[2]["authors"] = pubkeys; - - newRequests[relayUrl] = newRequest; - } - - // add remaining pubkeys to new request for each relay GENERAL - for (var relayUrl - in connectedRelaysRead.values.map((e) => e.connectionUrl)) { - //var relayUrl = relay.key; - - if (newRequests.containsKey(relayUrl)) { - var request = newRequests[relayUrl]; - request[2]["authors"].addAll(remainingPubkeys); - } else { - // deep copy request, if there is a better way, please let me know - List newRequest = json.decode(json.encode(request)); - newRequest[2]["authors"] = remainingPubkeys; - - newRequests[relayUrl] = newRequest; - } - } - - return newRequests; - } - - setManualRelays(Map> relays) { - // add manual true to relays - for (var relay in relays.entries) { - relays[relay.key]!['manual'] = true; - } - - manualRelays = relays; - - // add to cache - int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - _jsonCache.refresh('manual-relays', {'relays': relays, 'timestamp': now}); - } -} diff --git a/lib/services/nostr/relays/relays_injector.dart b/lib/services/nostr/relays/relays_injector.dart deleted file mode 100644 index bdedb319..00000000 --- a/lib/services/nostr/relays/relays_injector.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:camelus/services/nostr/relays/relay_tracker.dart'; -import 'package:camelus/services/nostr/relays/relays.dart'; -import 'package:camelus/services/nostr/relays/relays_ranking.dart'; - -class RelaysInjector { - static final _singleton = RelaysInjector._internal(); - static RelaysInjector? _injector; - - Relays? _relays; - RelayTracker? _relayTracker; - RelaysRanking? _relaysRanking; - - factory RelaysInjector() { - return _injector != null ? _injector! : _singleton; - } - - RelaysInjector._internal(); - - static void configure(RelaysInjector injector) { - _injector = injector; - } - - Relays get relays { - return _relays ??= Relays(); - } - - RelayTracker get relayTracker { - return _relayTracker ??= RelayTracker(); - } - - RelaysRanking get relaysRanking { - return _relaysRanking ??= RelaysRanking(); - } -} diff --git a/lib/services/nostr/relays/relays_picker.dart b/lib/services/nostr/relays/relays_picker.dart deleted file mode 100644 index 9fc6fdf5..00000000 --- a/lib/services/nostr/relays/relays_picker.dart +++ /dev/null @@ -1,219 +0,0 @@ -import 'package:camelus/services/nostr/relays/relay_tracker.dart'; -import 'package:camelus/services/nostr/relays/relays_injector.dart'; -import 'package:camelus/services/nostr/relays/relays_ranking.dart'; - -class RelaysPicker { - late RelayTracker relayTracker; - late RelaysRanking relaysRanking; - - Map pubkeyCounts = { - //'pubkey2': 2, - //'pubkey3': 1, - }; - - Map relayAssignments = { - //'relay1': - // RelayAssignment(relayUrl: 'relay1', pubkeys: ['pubkey1', 'pubkey2']), - //'relay2': RelayAssignment(relayUrl: 'relay2', pubkeys: ['pubkey3']), - }; - - Map> personRelayScores = { - //'pubkey2': {'relay1': 7, 'relay2': 8}, - }; - - Map _excludedRelays = { - //'relay1': DateTime.now().add(Duration(hours: 1)).millisecondsSinceEpoch, - //'relay2': DateTime.now().add(Duration(hours: 2)).millisecondsSinceEpoch, - }; - - RelaysPicker() { - RelaysInjector relaysInjector = RelaysInjector(); - relayTracker = relaysInjector.relayTracker; - relaysRanking = relaysInjector.relaysRanking; - } - - Future init( - {required List pubkeys, required int coverageCount}) async { - for (var pubkey in pubkeys) { - pubkeyCounts[pubkey] = coverageCount; - } - - // populate personRelayScores - for (var pubkey in pubkeys) { - var scoresList = - await relaysRanking.getBestRelays(pubkey, Direction.read); - if (scoresList.isEmpty) { - continue; - } - - Map scoresMap = { - //'wss://relay.damus.io': 10, - }; - - for (Map listItem in scoresList) { - scoresMap[listItem["relay"]] = listItem["score"]; - } - - personRelayScores[pubkey] = scoresMap; - } - return; - } - - set setExcludedRelays(Map value) { - _excludedRelays = value; - } - - String pick(List pubkeys, {int? limit}) { - var tracker = relayTracker.tracker; - - bool atMaxRelays = relayAssignments.length >= - 15; //todo move to settings hooks.getMaxRelays(); - -// Maybe include excluded relays - int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - _excludedRelays.removeWhere((k, v) => v > now); - - if (pubkeyCounts.isEmpty) { - throw Exception('NoPeopleLeft'); - } - - // combine list of maps into one list containing the map - List allRelays = tracker.values.expand((element) => element.keys).toList(); - //List allRelays = tracker.values.; //hooks.getAllRelays(); - - if (allRelays.isEmpty) { - throw Exception('NoRelays'); - } - -// Keep score for each relay - Map scoreboard = {for (var e in allRelays) e: 0}; - -// Assign scores to relays from each pubkey - personRelayScores.forEach((pubkeyhex, relayScores) { - // Skip if this pubkey doesn't need any more assignments - if (pubkeyCounts.containsKey(pubkeyhex)) { - if (pubkeyCounts[pubkeyhex] == 0) { - // person doesn't need anymore - return; - } - } else { - return; // person doesn't need any - } - - // Add scores of their relays - relayScores.forEach((relay, score) { - // Skip relays that are excluded - if (_excludedRelays.containsKey(relay)) { - return; - } - - // If at max, skip relays not already connected - if (atMaxRelays && false) { - // atMaxRelays && !hooks.isRelayConnected(relay) - return; - } - - // Skip if relay is already assigned this pubkey - if (relayAssignments.containsKey(relay)) { - if (relayAssignments[relay]!.pubkeys.contains(pubkeyhex)) { - return; - } - } - - // Add the score - scoreboard.update(relay, (value) => value + score); - }); - }); - -// Adjust all scores based on relay rank and relay success rate -// TBD to add this kind of feature back. - //scoreboard.forEach((url, score) { - // scoreboard[url] = hooks.adjustScore(url, score); - //}); - - var winner = scoreboard.entries.reduce((a, b) => a.value > b.value ? a : b); - - String winningUrl = winner.key; - int winningScore = winner.value; - - if (winningScore == 0) { - throw Exception('NoProgress $winningUrl'); - } - -// Now sort out which public keys go with that relay - List coveredPublicKeys = () { - List pubkeysSeekingRelays = pubkeyCounts.entries - .where((e) => e.value > 0) - .map((e) => e.key) - .toList(); - - List coveredPubkeys = []; - - for (String pubkey in pubkeysSeekingRelays) { - // Skip if relay is already assigned this pubkey - if (relayAssignments.containsKey(winningUrl)) { - if (relayAssignments[winningUrl]!.pubkeys.contains(pubkey)) { - continue; - } - } - - if (personRelayScores.containsKey(pubkey)) { - Map relayScores = personRelayScores[pubkey]!; - - if (relayScores.keys.any((e) => e == winningUrl)) { - coveredPubkeys.add(pubkey); - - if (pubkeyCounts.containsKey(pubkey)) { - if (pubkeyCounts[pubkey]! > 0) { - pubkeyCounts[pubkey] = pubkeyCounts[pubkey]! - 1; - } - } - } - } - } - - return coveredPubkeys; - }(); - - if (coveredPublicKeys.isEmpty) { - throw Exception('NoProgress'); - } - -// Only keep pubkeyCounts that are still >0 - pubkeyCounts.removeWhere((k, v) => v < 1); - - var assignment = RelayAssignment( - relayUrl: winningUrl, - pubkeys: coveredPublicKeys - .map((item) => item.toString()) - .toList(), // cast to string - ); - -// Put assignment into relayAssignments - if (relayAssignments.containsKey(winningUrl)) { - relayAssignments[winningUrl]!.mergeIn(assignment); - } else { - relayAssignments[winningUrl] = assignment; - } - - return winningUrl; - } - - RelayAssignment? getRelayAssignment(String relayUrl) { - return relayAssignments[relayUrl]; - } -} - -class RelayAssignment { - String relayUrl; - List pubkeys; - - RelayAssignment({required this.relayUrl, required this.pubkeys}); - - void mergeIn(RelayAssignment other) { - if (other.relayUrl != relayUrl) { - throw Exception('Cannot merge different relays'); - } - pubkeys.addAll(other.pubkeys); - } -} diff --git a/lib/services/nostr/relays/relays_ranking.dart b/lib/services/nostr/relays/relays_ranking.dart deleted file mode 100644 index f1a5deea..00000000 --- a/lib/services/nostr/relays/relays_ranking.dart +++ /dev/null @@ -1,295 +0,0 @@ -import 'package:camelus/services/nostr/relays/relay_tracker.dart'; -import 'package:camelus/services/nostr/relays/relays_injector.dart'; -import 'dart:math'; - -class RelaysRanking { - late RelayTracker relayTracker; - - RelaysRanking() { - relayTracker = RelaysInjector().relayTracker; - } - - Future> getBestRelays(String pubkeyHex, Direction dir) async { - var tracker = relayTracker.tracker; - - if (tracker[pubkeyHex] == null) { - return []; - } - - final rankedRelays = (() { - //final maybeDb = "GLOBALS.db.blockingLock()"; - //final db = maybeDb.value; - //final stmt = db.prepare(sql); - //stmt.rawBindParameter(1, pubkeyHex); - //final rows = stmt.rawQuery(); - - // - //tracker to dbprs - List dbprs = []; - - for (var entry in tracker[pubkeyHex]!.entries) { - var d = DbPersonRelay( - person: pubkeyHex, - relay: entry.key, - lastFetched: entry.value["lastFetched"] ?? 0, - lastSuggestedKind3: entry.value["lastSuggestedKind3"] ?? 0, - lastSuggestedNip05: entry.value["lastSuggestedNip05"] ?? 0, - lastSuggestedBytag: entry.value["lastSuggestedBytag"] ?? 0, - read: true, - write: false, - manuallyPairedRead: false, - manuallyPairedWrite: false); - dbprs.add(d); - } - - // convert dbprs to List> - List> dbprMap = []; - for (var dbpr in dbprs) { - dbprMap.add(dbpr.toMap()); - } - - switch (dir) { - case Direction.write: - return _writeRank(dbprMap); - case Direction.read: - return _readRank(dbprMap); - } - return []; - }); - - final rankedRelaysList = rankedRelays() as List>; - const numRelaysPerPerson = 2; //todo: move this to settings - - // If we can't get enough of them, extend with some of our relays - // at whatever the lowest score of their last one was - if (rankedRelaysList.length < (numRelaysPerPerson + 1)) { - final howManyMore = (numRelaysPerPerson + 1) - rankedRelaysList.length; - - final lastScore = rankedRelaysList.isEmpty - ? 20 - : rankedRelaysList.last.values.last; //rankedRelaysList.last.value2 - - List> myTuplesList = [ - Tuple2('a', 1), - Tuple2('b', 2), - Tuple2('c', 3), - ]; - //final additional = myTuplesList - // .where((r) => - // !rankedRelaysList.any((rel) => rel.values.first == r.key) && - // ((dir == Direction.write && r.value.write) || - // (dir == Direction.read && r.value.read))) - // .take(howManyMore) - // .map((r) => Tuple2(r.key.clone(), lastScore)) - // .toList(); - //rankedRelaysList.addAll(additional); - } - - //developer.log("rankedRelaysList: $rankedRelaysList", name: 'RelaysRanking'); - - return rankedRelaysList; - } - - List> _writeRank(List> dbprs) { - // This is the ranking we are using. There might be reasons - // for ranking differently. - // write (score=20) [ they claim (to us) ] - // manually_paired_write (score=20) [ we claim (to us) ] - // kind3 tag (score=5) [ we say ] - // nip05 (score=4) [ they claim, unsigned ] - // fetched (score=3) [ we found ] - // bytag (score=1) [ someone else mentions ] - - var now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - var output = >[]; - - int scorefn(int when, int fadePeriod, int base) { - var dur = now - when; // seconds since - var periods = (dur / fadePeriod).floor() + 1; // minimum one period - return base ~/ periods; - } - - for (var dbpr in List>.from(dbprs)) { - var score = 0; - - // 'write' is an author-signed explicit claim of where they write - if (dbpr['write'] ?? false || dbpr['manually_paired_write'] ?? false) { - score += 20; - } - - // kind3 is our memory of where we are following someone - if (dbpr['last_suggested_kind3'] != null) { - score += scorefn( - dbpr['last_suggested_kind3'], - 60 * 60 * 24 * 30, - 7, - ); - } - - // nip05 is an unsigned dns-based author claim of using this relay - if (dbpr['last_suggested_nip05'] != null) { - score += scorefn( - dbpr['last_suggested_nip05'], - 60 * 60 * 24 * 15, - 4, - ); - } - - // last_fetched is gossip verified happened-to-work-before - if (dbpr['last_fetched'] != null) { - score += scorefn( - dbpr['last_fetched'], - 60 * 60 * 24 * 3, - 3, - ); - } - - // last_suggested_bytag is an anybody-signed suggestion - if (dbpr['last_suggested_bytag'] != null) { - score += scorefn( - dbpr['last_suggested_bytag'], - 60 * 60 * 24 * 2, - 1, - ); - } - - // Prune score=0 associations - if (score == 0) { - continue; - } - - output.add({ - 'relay': dbpr['relay'], - 'score': score, - }); - } - - output.sort((a, b) => b['score'].compareTo(a['score'])); - - // prune everything below a score of 20, but only after the first 6 entries - while (output.length > 6 && output.last['score'] < 20) { - output.removeLast(); - } - - return output; - } - - List> _readRank(List> dbprs) { - var now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - var output = >[]; - - scorefn(int when, int fadePeriod, int base) { - var dur = max(0, now - when); // seconds since - var periods = (dur / fadePeriod).floor() + 1; // minimum one period - return base ~/ periods; - } - - for (var dbpr in dbprs) { - var score = 0; - - // 'read' is an author-signed explicit claim of where they read - if (dbpr['read'] || dbpr['manually_paired_read']) { - score += 20; - } - - // kind3 is our memory of where we are following someone - var lastSuggestedKind3 = dbpr['last_suggested_kind3']; - if (lastSuggestedKind3 != null) { - score += scorefn(lastSuggestedKind3, 60 * 60 * 24 * 30, 7); - } - - // nip05 is an unsigned dns-based author claim of using this relay - var lastSuggestedNip05 = dbpr['last_suggested_nip05']; - if (lastSuggestedNip05 != null) { - score += scorefn(lastSuggestedNip05, 60 * 60 * 24 * 15, 4); - } - - // last_fetched is gossip verified happened-to-work-before - var lastFetched = dbpr['last_fetched']; - if (lastFetched != null) { - score += scorefn(lastFetched, 60 * 60 * 24 * 3, 3); - } - - // last_suggested_bytag is an anybody-signed suggestion - var lastSuggestedBytag = dbpr['last_suggested_bytag']; - if (lastSuggestedBytag != null) { - score += scorefn(lastSuggestedBytag, 60 * 60 * 24 * 2, 1); - } - - // Prune score=0 associations - if (score == 0) { - continue; - } - - output.add({'relay': dbpr['relay'], 'score': score}); - } - - output.sort((a, b) => b['score'].compareTo(a['score'])); - - // prune everything below a score 20, but only after the first 6 entries - while (output.length > 6 && output.last['score'] < 20) { - output.removeLast(); - } - - return output; - } -} - -class DbPersonRelay { - dynamic person; - String relay; - int lastFetched; - int lastSuggestedKind3; - int lastSuggestedNip05; - int lastSuggestedBytag; - bool read; - bool write; - bool manuallyPairedRead; - bool manuallyPairedWrite; - - DbPersonRelay( - {required this.person, - required this.relay, - required this.lastFetched, - required this.lastSuggestedKind3, - required this.lastSuggestedNip05, - required this.lastSuggestedBytag, - required this.read, - required this.write, - required this.manuallyPairedRead, - required this.manuallyPairedWrite}); - - Map toMap() { - return { - 'person': person, - 'relay': relay, - 'last_fetched': lastFetched, - 'last_suggested_kind3': lastSuggestedKind3, - 'last_suggested_nip05': lastSuggestedNip05, - 'last_suggested_bytag': lastSuggestedBytag, - 'read': read, - 'write': write, - 'manuallyPairedRead': manuallyPairedRead, - 'manuallyPairedWrite': manuallyPairedWrite, - }; - } -} - -class Tuple2 { - T1 value1; - T2 value2; - - Tuple2(this.value1, this.value2); -} - -class Direction { - static const Direction write = Direction._("write"); - static const Direction read = Direction._("read"); - - final String _value; - - const Direction._(this._value); - - @override - String toString() => _value; -} diff --git a/lib/theme.dart b/lib/theme.dart index 2f3c799a..f9effb9e 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -15,10 +15,10 @@ final ThemeData darkTheme = ThemeData( onError: Colors.orange, onPrimary: Colors.blue, onSecondary: Colors.blueAccent, - onSurface: Colors.teal, + onSurface: Colors.white, primary: Colors.blue, secondary: Colors.blueAccent, - surface: Colors.teal, + surface: Colors.white12, ), ); diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 38dd0bc6..e370dc08 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,12 +7,16 @@ #include "generated_plugin_registrant.h" #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) objectbox_flutter_libs_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "ObjectboxFlutterLibsPlugin"); + objectbox_flutter_libs_plugin_register_with_registrar(objectbox_flutter_libs_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 65240e99..67aa6871 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,10 +4,12 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_linux + objectbox_flutter_libs url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + rust_lib_ndk ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 816683c2..9afe5153 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,20 +7,20 @@ import Foundation import device_info_plus import flutter_secure_storage_macos +import objectbox_flutter_libs import package_info_plus import path_provider_foundation import shared_preferences_foundation -import shared_preferences_macos -import sqflite +import sqflite_darwin import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) - FlutterSecureStorageMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageMacosPlugin")) - FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) - SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..61f3bd1f --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/pubspec.lock b/pubspec.lock index 4c65ccf3..16a9aea7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,42 +5,63 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "405666cd3cf0ee0a48d21ec67e65406aad2c726d9fa58840d3375e7bdcd32a07" + sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 url: "https://pub.dev" source: hosted - version: "60.0.0" + version: "72.0.0" + _macros: + dependency: transitive + description: dart + source: sdk + version: "0.3.2" + amberflutter: + dependency: "direct main" + description: + name: amberflutter + sha256: "5101b36419a93e0620a227ada25690286efc93ab9de59dc80e7c3a0c29ccd348" + url: "https://pub.dev" + source: hosted + version: "0.0.9" analyzer: dependency: transitive description: name: analyzer - sha256: "1952250bd005bacb895a01bf1b4dc00e3ba1c526cf47dca54dfe24979c65f5b3" + sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 url: "https://pub.dev" source: hosted - version: "5.12.0" + version: "6.7.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161" + url: "https://pub.dev" + source: hosted + version: "0.11.3" archive: dependency: transitive description: name: archive - sha256: "0c8368c9b3f0abbc193b9d6133649a614204b528982bebc7026372d61677ce3a" + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d url: "https://pub.dev" source: hosted - version: "3.3.7" + version: "3.6.1" args: dependency: transitive description: name: args - sha256: c372bb384f273f0c2a8aaaa226dad84dc27c8519a691b888725dec59518ad53a + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.6.0" asn1lib: dependency: transitive description: name: asn1lib - sha256: ab96a1cb3beeccf8145c52e449233fe68364c9641623acd3adad66f8184f1039 + sha256: "4bae5ae63e6d6dd17c4aac8086f3dec26c0236f6a0f03416c6c19d830c367cf5" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.8" async: dependency: transitive description: @@ -53,10 +74,18 @@ packages: dependency: "direct main" description: name: badges - sha256: "727580d938b7a1ff47ea42df730d581415606b4224cfa708671c10287f8d3fe6" + sha256: a7b6bbd60dce418df0db3058b53f9d083c22cdb5132a052145dc267494df0b84 url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "3.1.2" + base58check: + dependency: transitive + description: + name: base58check + sha256: "6c300dfc33e598d2fe26319e13f6243fea81eaf8204cb4c6b69ef20a625319a5" + url: "https://pub.dev" + source: hosted + version: "2.0.0" bech32: dependency: "direct main" description: @@ -65,14 +94,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + bip32: + dependency: "direct main" + description: + name: bip32 + sha256: "54787cd7a111e9d37394aabbf53d1fc5e2e0e0af2cd01c459147a97c0e3f8a97" + url: "https://pub.dev" + source: hosted + version: "2.0.0" bip340: dependency: "direct main" description: name: bip340 - sha256: "9a19f8e9bc2f2cffe973feb9701ceff1ee2af905634a0fb4ded7ef41376cf149" + sha256: b7bcd70a860e605046006adaa72bc4f7453f4d31d7ba74a4ad9d5de387a0fc0b url: "https://pub.dev" source: hosted - version: "0.0.4" + version: "0.3.0" + bip39_mnemonic: + dependency: "direct main" + description: + name: bip39_mnemonic + sha256: "24855a62fb9dd930f697063a274e084b0a574e1cc55ec270ca4756f579ab512c" + url: "https://pub.dev" + source: hosted + version: "3.0.6" boolean_selector: dependency: transitive description: @@ -81,14 +126,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + bs58check: + dependency: transitive + description: + name: bs58check + sha256: c4a164d42b25c2f6bc88a8beccb9fc7d01440f3c60ba23663a20a70faf484ea9 + url: "https://pub.dev" + source: hosted + version: "1.0.2" build: dependency: transitive description: name: build - sha256: "43865b79fbb78532e4bff7c33087aa43b1d488c4fdef014eaef568af6d8016dc" + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: b59d2769769efd6c9ff6d4c4cede0be115a566afc591705c2040b707534b1172 + url: "https://pub.dev" + source: hosted + version: "2.1.0" build_config: dependency: transitive description: @@ -101,34 +162,34 @@ packages: dependency: transitive description: name: build_daemon - sha256: "5f02d73eb2ba16483e693f80bee4f088563a820e47d1027d4cdfe62b5bb43e65" + sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.2" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: db49b8609ef8c81cca2b310618c3017c00f03a92af44c04d310b907b2d692d95 + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "220ae4553e50d7c21a17c051afc7b183d28a24a420502e842f303f8e4e6edced" + sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" url: "https://pub.dev" source: hosted - version: "2.4.4" + version: "2.4.13" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: "30859c90e9ddaccc484f56303931f477b1f1ba2bab74aa32ed5d6ce15870f8cf" + sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 url: "https://pub.dev" source: hosted - version: "7.2.8" + version: "7.3.2" built_collection: dependency: transitive description: @@ -141,34 +202,34 @@ packages: dependency: transitive description: name: built_value - sha256: "2f17434bd5d52a26762043d6b43bb53b3acd029b4d9071a329f46d67ef297e6d" + sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb url: "https://pub.dev" source: hosted - version: "8.5.0" + version: "8.9.2" cached_network_image: dependency: "direct main" description: name: cached_network_image - sha256: fd3d0dc1d451f9a252b32d95d3f0c3c487bc41a75eba2e6097cb0b9c71491b15 + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" url: "https://pub.dev" source: hosted - version: "3.2.3" + version: "3.4.1" cached_network_image_platform_interface: dependency: transitive description: name: cached_network_image_platform_interface - sha256: bb2b8403b4ccdc60ef5f25c70dead1f3d32d24b9d6117cfc087f496b178594a7 + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "4.1.1" cached_network_image_web: dependency: transitive description: name: cached_network_image_web - sha256: b8eb814ebfcb4dea049680f8c1ffb2df399e4d03bf7a352c775e26fa06e02fa0 + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.3.1" characters: dependency: transitive description: @@ -177,14 +238,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" - charcode: - dependency: transitive - description: - name: charcode - sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 - url: "https://pub.dev" - source: hosted - version: "1.3.1" checked_yaml: dependency: transitive description: @@ -193,14 +246,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + ci: + dependency: transitive + description: + name: ci + sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" + url: "https://pub.dev" + source: hosted + version: "0.1.0" cli_util: dependency: transitive description: name: cli_util - sha256: "66f86e916d285c1a93d3b79587d94bd71984a66aac4ff74e524cfa7877f1395c" + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c url: "https://pub.dev" source: hosted - version: "0.3.5" + version: "0.4.2" clock: dependency: transitive description: @@ -213,114 +274,138 @@ packages: dependency: transitive description: name: code_builder - sha256: "0d43dd1288fd145de1ecc9a3948ad4a6d5a82f0a14c4fdd0892260787d975cbe" + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" url: "https://pub.dev" source: hosted - version: "4.4.0" + version: "4.10.1" collection: dependency: transitive description: name: collection - sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c" + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a url: "https://pub.dev" source: hosted - version: "1.17.1" + version: "1.18.0" 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: "2fb815080e44a09b85e0f2ca8a820b15053982b2e714b59267719e8a9ff17097" + sha256: "88b0fddbe4c92910fefc09cc0248f5e7f0cd23e450ded4c28f16ab8ee8f83268" url: "https://pub.dev" source: hosted - version: "1.6.3" - cross_local_storage: + version: "1.10.0" + crop_your_image: dependency: "direct main" description: - name: cross_local_storage - sha256: "4ea71b66c9c10bac6ae1a287620e48eeb611c63d1fd70b15940195b3f84305bd" + name: crop_your_image + sha256: "9ae3b33042de5bda5321fc48aad41054c196bf2cc28350cd30cb8a85c1a7b1bd" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "1.1.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" crypto: dependency: "direct main" 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: name: cupertino_icons - sha256: e35129dc44c9118cee2a5603506d823bab99c68393879edb440e0090d07586be + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + custom_lint: + dependency: "direct dev" + description: + name: custom_lint + sha256: "3486c470bb93313a9417f926c7dd694a2e349220992d7b9d14534dc49c15bba9" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + custom_lint_builder: + dependency: transitive + description: + name: custom_lint_builder + sha256: "42cdc41994eeeddab0d7a722c7093ec52bd0761921eeb2cbdbf33d192a234759" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "02450c3e45e2a6e8b26c4d16687596ab3c4644dd5792e3313aa9ceba5a49b7f5" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + custom_lint_visitor: + dependency: transitive + description: + name: custom_lint_visitor + sha256: "8aeb3b6ae2bb765e7716b93d1d10e8356d04e0ff6d7592de6ee04e0dd7d6587d" url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.0.0+6.7.0" custom_refresh_indicator: dependency: "direct main" description: name: custom_refresh_indicator - sha256: "65a463f09623f6baf75e45e0c9034e9304810be3f5dfb00a54edde7252f4a524" + sha256: c34dd1dfb1f6b9ee2db9c5972586dba5e4445d79f8431f6ab098a6e963ccd39c url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "4.0.1" dart_style: dependency: transitive description: name: dart_style - sha256: f4f1f73ab3fd2afcbcca165ee601fe980d966af6a21b5970c6c9376955c528ad + sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.7" device_info_plus: dependency: transitive description: name: device_info_plus - sha256: f52ab3b76b36ede4d135aab80194df8925b553686f0fa12226b4e2d658e45903 + sha256: a7fd703482b391a87d60b6061d04dfdeab07826b96f9abd8f5ed98068acc0074 url: "https://pub.dev" source: hosted - version: "8.2.2" + version: "10.1.2" device_info_plus_platform_interface: dependency: transitive description: name: device_info_plus_platform_interface - sha256: d3b01d5868b50ae571cd1dc6e502fc94d956b665756180f7b16ead09e836fd64 - url: "https://pub.dev" - source: hosted - version: "7.0.0" - drift: - dependency: "direct main" - description: - name: drift - sha256: a8ec4e44b4359ef44eab3d2c2f8e44b41a00c15673b879984484b34d27656ad5 + sha256: "282d3cf731045a2feb66abfe61bbc40870ae50a3ed10a4d3d217556c35c8c2ba" url: "https://pub.dev" source: hosted - version: "2.9.0" + version: "7.0.1" encrypt: - dependency: transitive + dependency: "direct main" description: name: encrypt - sha256: "4fd4e4fdc21b9d7d4141823e1e6515cd94e7b8d84749504c232999fba25d9bbb" + sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" url: "https://pub.dev" source: hosted - version: "5.0.1" - encrypted_shared_preferences: - dependency: transitive - description: - name: encrypted_shared_preferences - sha256: f4109e4d176a81b203ee120b79b30c1ab93dfd9eee03cac5b8c10a888c231639 - url: "https://pub.dev" - source: hosted - version: "3.0.1" + version: "5.0.3" fake_async: dependency: transitive description: @@ -333,108 +418,92 @@ packages: dependency: transitive description: name: ffi - sha256: ed5337a5660c506388a9f012be0288fb38b49020ce2b45fe1f8b8323fe429f99 + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.3" file: dependency: transitive description: name: file - sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" url: "https://pub.dev" source: hosted - version: "6.1.4" + version: "7.0.0" file_picker: dependency: "direct main" description: name: file_picker - sha256: c7a8e25ca60e7f331b153b0cb3d405828f18d3e72a6fa1d9440c86556fffc877 + sha256: aac85f20436608e01a6ffd1fdd4e746a7f33c93a2c83752e626bdfaea139b877 url: "https://pub.dev" source: hosted - version: "5.3.0" + version: "8.1.3" fixnum: dependency: transitive description: name: fixnum - sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - floor: - dependency: "direct main" - description: - name: floor - sha256: "52a8eac2c8d274e7c0c54251226f59786bb5b749365a2d8537d8095aa5132d92" + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be url: "https://pub.dev" source: hosted - version: "1.4.2" - floor_annotation: + version: "1.1.1" + flat_buffers: dependency: transitive description: - name: floor_annotation - sha256: fa3fa4f198cdd1d922a69ceb06e54663fe59256bf1cb3c036eff206b445a6960 + name: flat_buffers + sha256: "380bdcba5664a718bfd4ea20a45d39e13684f5318fcd8883066a55e21f37f4c3" url: "https://pub.dev" source: hosted - version: "1.4.2" - floor_generator: - dependency: "direct dev" - description: - name: floor_generator - sha256: "40aaf1b619adc03367ce4b7c79161e3198d43b572b5ec9cc99a4a89de27b08d2" - url: "https://pub.dev" - source: hosted - version: "1.4.2" + version: "23.5.26" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" - flutter_blurhash: - dependency: transitive - description: - name: flutter_blurhash - sha256: "05001537bd3fac7644fa6558b09ec8c0a3f2eba78c0765f88912882b1331a5c6" - url: "https://pub.dev" - source: hosted - version: "0.7.0" flutter_cache_manager: dependency: "direct main" description: name: flutter_cache_manager - sha256: "32cd900555219333326a2d0653aaaf8671264c29befa65bbd9856d204a4c9fb3" + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" url: "https://pub.dev" source: hosted - version: "3.3.0" + version: "3.4.1" flutter_driver: dependency: transitive description: flutter source: sdk version: "0.0.0" + flutter_force_directed_graph: + dependency: "direct main" + description: + name: flutter_force_directed_graph + sha256: eb2fcd42927cd272a94f2235ec6ee833f912b1057532140bd2b9487d9aba7b59 + url: "https://pub.dev" + source: hosted + version: "1.0.7" flutter_hooks: dependency: "direct main" description: name: flutter_hooks - sha256: "6a126f703b89499818d73305e4ce1e3de33b4ae1c5512e3b8eab4b986f46774c" + sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70 url: "https://pub.dev" source: hosted - version: "0.18.6" + version: "0.20.5" flutter_launcher_icons: dependency: "direct main" description: name: flutter_launcher_icons - sha256: ce0e501cfc258907842238e4ca605e74b7fd1cdf04b3b43e86c43f3e40a1592c + sha256: "619817c4b65b322b5104b6bb6dfe6cda62d9729bd7ad4303ecc8b4e690a67a77" url: "https://pub.dev" source: hosted - version: "0.11.0" + version: "0.14.1" flutter_lints: dependency: "direct dev" description: name: flutter_lints - sha256: aeb0b80a8b3709709c9cc496cdc027c5b3216796bc0af0ce1007eaf24464fd4c + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "5.0.0" flutter_mentions: dependency: "direct main" description: @@ -447,10 +516,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "96af49aa6b57c10a312106ad6f71deed5a754029c24789bbf620ba784f0bd0b0" + sha256: "9b78450b89f059e96c9ebb355fa6b3df1d6b330436e0b885fb49594c41721398" url: "https://pub.dev" source: hosted - version: "2.0.14" + version: "2.0.23" flutter_portal: dependency: transitive description: @@ -463,66 +532,74 @@ packages: dependency: transitive description: name: flutter_riverpod - sha256: b83ac5827baadefd331ea1d85110f34645827ea234ccabf53a655f41901a9bf4 + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_rust_bridge: + dependency: transitive + description: + name: flutter_rust_bridge + sha256: fb9d3c9395eae3c71d4fe3ec343b9f30636c9988150c8bb33b60047549b34e3d url: "https://pub.dev" source: hosted - version: "2.3.6" + version: "2.6.0" flutter_secure_storage: dependency: "direct main" description: name: flutter_secure_storage - sha256: de957362e046bc68da8dcf6c1d922cb8bdad8dd4979ec69480cf1a3c481abe8e + sha256: "165164745e6afb5c0e3e3fcc72a012fb9e58496fb26ffb92cf22e16a821e85d0" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "9.2.2" flutter_secure_storage_linux: dependency: transitive description: name: flutter_secure_storage_linux - sha256: "0912ae29a572230ad52d8a4697e5518d7f0f429052fd51df7e5a7952c7efe2a3" + sha256: "4d91bfc23047422cbcd73ac684bc169859ee766482517c22172c86596bf1464b" url: "https://pub.dev" source: hosted - version: "1.1.3" + version: "1.2.1" flutter_secure_storage_macos: dependency: transitive description: name: flutter_secure_storage_macos - sha256: "388f76fd0f093e7415a39ec4c169ae7cceeee6d9f9ba529d788a13f2be4de7bd" + sha256: "1693ab11121a5f925bbea0be725abfcfbbcf36c1e29e571f84a0c0f436147a81" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "3.1.2" flutter_secure_storage_platform_interface: dependency: transitive description: name: flutter_secure_storage_platform_interface - sha256: b3773190e385a3c8a382007893d678ae95462b3c2279e987b55d140d3b0cb81b + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.1.2" flutter_secure_storage_web: dependency: transitive description: name: flutter_secure_storage_web - sha256: "42938e70d4b872e856e678c423cc0e9065d7d294f45bc41fc1981a4eb4beaffe" + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.2.1" flutter_secure_storage_windows: dependency: transitive description: name: flutter_secure_storage_windows - sha256: ca89c8059cf439985aa83c59619b3674c7ef6cc2e86943d169a7369d6a69cab5 + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 url: "https://pub.dev" source: hosted - version: "1.1.3" + version: "3.1.2" flutter_svg: dependency: "direct main" description: name: flutter_svg - sha256: "6ff9fa12892ae074092de2fa6a9938fb21dbabfdaa2ff57dc697ff912fc8d4b2" + sha256: "2ca230f2ef6e31151769f4a03ec806b94f0554ff02ea1a40bb0d531ac150f035" url: "https://pub.dev" source: hosted - version: "1.1.6" + version: "2.0.12" flutter_test: dependency: "direct dev" description: flutter @@ -533,14 +610,22 @@ packages: description: flutter source: sdk version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.dev" + source: hosted + version: "2.4.4" frontend_server_client: dependency: transitive description: name: frontend_server_client - sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "4.0.0" fuchsia_remote_debug_protocol: dependency: transitive description: flutter @@ -550,18 +635,18 @@ packages: dependency: transitive description: name: glob - sha256: "4515b5b6ddb505ebdd242a5f2cc5d22d3d6a80013789debfbda7777f47ea308c" + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" graphs: dependency: transitive description: name: graphs - sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" hex: dependency: "direct main" description: @@ -570,30 +655,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" - hive: - dependency: transitive - description: - name: hive - sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" - url: "https://pub.dev" - source: hosted - version: "2.2.3" hooks_riverpod: dependency: "direct main" description: name: hooks_riverpod - sha256: be68cf7653fcab798500f9047ac58c3f109287a1595012412f4a0d654a9bb9c5 + sha256: "70bba33cfc5670c84b796e6929c54b8bc5be7d0fe15bb28c2560500b9ad06966" url: "https://pub.dev" source: hosted - version: "2.3.6" + version: "2.6.1" + hotreloader: + dependency: transitive + description: + name: hotreloader + sha256: ed56fdc1f3a8ac924e717257621d09e9ec20e308ab6352a73a50a1d7a4d9158e + url: "https://pub.dev" + source: hosted + version: "4.2.0" http: dependency: "direct main" description: name: http - sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 url: "https://pub.dev" source: hosted - version: "0.13.6" + version: "1.2.2" http_multi_server: dependency: transitive description: @@ -614,10 +699,10 @@ packages: dependency: transitive description: name: image - sha256: "8e9d133755c3e84c73288363e6343157c383a0c6c56fc51afcc5d4d7180306d6" + sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d url: "https://pub.dev" source: hosted - version: "3.3.0" + version: "4.3.0" integration_test: dependency: "direct dev" description: flutter @@ -627,10 +712,10 @@ packages: dependency: transitive description: name: intl - sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf url: "https://pub.dev" source: hosted - version: "0.18.1" + version: "0.19.0" io: dependency: transitive description: @@ -651,114 +736,138 @@ packages: dependency: transitive description: name: json_annotation - sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" url: "https://pub.dev" source: hosted - version: "4.8.1" - json_cache: + version: "4.9.0" + kepler: dependency: "direct main" description: - name: json_cache - sha256: "3cc2818270596d31f9291481916b8060e34be502c1a6257ad99f7efd94382aac" + name: kepler + sha256: "8cf9f7df525bd4e5b192d91e52f1c75832b1fefb27fb4f4a09b1412b0f4f23d0" url: "https://pub.dev" source: hosted - version: "1.5.0" - lints: + version: "1.0.3" + leak_tracker: dependency: transitive description: - name: lints - sha256: "6b0206b0bf4f04961fc5438198ccb3a885685cd67d4d4a32cc20ad7f8adbe015" + name: leak_tracker + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "2.1.0" - lists: + version: "10.0.5" + leak_tracker_flutter_testing: dependency: transitive description: - name: lists - sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" + name: leak_tracker_flutter_testing + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "1.0.1" - localstorage: + version: "3.0.5" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: "3315600f3fb3b135be672bf4a178c55f274bebe368325ae18462c89ac1e3b413" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + logger: dependency: transitive description: - name: localstorage - sha256: "1b5304491c85250b90807e0e2b3a6217d2739caea4871b820d42782572f880f4" + name: logger + sha256: "697d067c60c20999686a0add96cf6aba723b3aa1f83ecf806a8097231529ec32" url: "https://pub.dev" source: hosted - version: "4.0.1+2" + version: "2.4.0" logging: dependency: transitive description: name: logging - sha256: "04094f2eb032cbb06c6f6e8d3607edcfcb0455e2bb6cbc010cb01171dcb64e6d" + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.3.0" lottie: dependency: "direct main" description: name: lottie - sha256: "23522951540d20a57a60202ed7022e6376bed206a4eee1c347a91f58bd57eb9f" + sha256: "7afc60865a2429d994144f7d66ced2ae4305fe35d82890b8766e3359872d872c" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "3.1.3" + macros: + dependency: transitive + description: + name: macros + sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" + url: "https://pub.dev" + source: hosted + version: "0.1.2-main.4" matcher: dependency: transitive description: name: matcher - sha256: "6501fbd55da300384b768785b83e5ce66991266cec21af89ab9ae7f5ce1c4cbb" + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb url: "https://pub.dev" source: hosted - version: "0.12.15" + version: "0.12.16+1" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724 + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.11.1" matomo_tracker: dependency: "direct main" description: name: matomo_tracker - sha256: "4500ed4ee9385f95e9195b448742e75c9115ffa59b4960e6ad3b79bdcd903c47" + sha256: "8706ca29389b836929415a52c3e6e94aaf8e37ceca23407ee215716fdb83466d" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "5.1.0" meta: dependency: transitive description: name: meta - sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.15.0" mime: dependency: "direct main" description: name: mime - sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "2.0.0" mockito: dependency: "direct dev" description: name: mockito - sha256: dd61809f04da1838a680926de50a9e87385c1de91c6579629c3d1723946e8059 + sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" url: "https://pub.dev" source: hosted - version: "5.4.0" - mutex: - dependency: transitive + version: "5.4.4" + ndk: + dependency: "direct main" description: - name: mutex - sha256: "03116a4e46282a671b46c12de649d72c0ed18188ffe12a8d0fc63e83f4ad88f4" + name: ndk + sha256: ec9f4b86b67573a2455515e0072db25209a75f5e67c6ef7b14bc573359c6855a url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "0.1.3" node_preamble: dependency: transitive description: @@ -767,14 +876,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + objectbox: + dependency: "direct main" + description: + name: objectbox + sha256: ea823f4bf1d0a636e7aa50b43daabb64dd0fbd80b85a033016ccc1bc4f76f432 + url: "https://pub.dev" + source: hosted + version: "4.0.3" + objectbox_flutter_libs: + dependency: "direct main" + description: + name: objectbox_flutter_libs + sha256: c91350bbbce5e6c2038255760b5be988faead004c814f833c2cd137445c6ae70 + url: "https://pub.dev" + source: hosted + version: "4.0.3" + objectbox_generator: + dependency: "direct dev" + description: + name: objectbox_generator + sha256: "96da521f2cef455cd524f8854e31d64495c50711ad5f1e2cf3142a8e527bc75f" + url: "https://pub.dev" + source: hosted + version: "4.0.3" octo_image: dependency: transitive description: name: octo_image - sha256: "107f3ed1330006a3bea63615e81cf637433f5135a52466c7caa0e7152bca9143" + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "2.1.0" package_config: dependency: transitive description: @@ -784,141 +917,125 @@ packages: source: hosted version: "2.1.0" package_info_plus: - dependency: transitive + dependency: "direct main" description: name: package_info_plus - sha256: "10259b111176fba5c505b102e3a5b022b51dd97e30522e906d6922c745584745" + sha256: df3eb3e0aed5c1107bb0fdb80a8e82e778114958b1c5ac5644fb1ac9cae8a998 url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "8.1.0" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: "9bc8ba46813a4cc42c66ab781470711781940780fd8beddd0c3da62506d3a6c6" + sha256: ac1f4a4847f1ade8e6a87d1f39f5d7c67490738642e2542f559ec38c37489a66 url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.1" path: dependency: transitive description: name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" - url: "https://pub.dev" - source: hosted - version: "1.8.3" - path_drawing: - dependency: transitive - description: - name: path_drawing - sha256: bbb1934c0cbb03091af082a6389ca2080345291ef07a5fa6d6e078ba8682f977 + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.9.0" 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: transitive + dependency: "direct main" description: name: path_provider - sha256: "3087813781ab814e4157b172f1a11c46be20179fcc9bea043e0fba36bc0acaa2" + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" url: "https://pub.dev" source: hosted - version: "2.0.15" + version: "2.1.5" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "2cec049d282c7f13c594b4a73976b0b4f2d7a1838a6dd5aaf7bd9719196bee86" + sha256: c464428172cb986b758c6d1724c603097febb8fb855aa265aeecc9280c294d4a url: "https://pub.dev" source: hosted - version: "2.0.27" + version: "2.2.12" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "1995d88ec2948dac43edf8fe58eb434d35d22a2940ecee1a9fefcd62beee6eb3" + sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.4.0" path_provider_linux: dependency: transitive description: name: path_provider_linux - sha256: "2ae08f2216225427e64ad224a24354221c2c7907e448e6e0e8b57b1eb9f10ad1" + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 url: "https://pub.dev" source: hosted - version: "2.1.10" + version: "2.2.1" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "57585299a729335f1298b43245842678cb9f43a6310351b18fb577d6e33165ec" + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" url: "https://pub.dev" source: hosted - version: "2.0.6" + version: "2.1.2" path_provider_windows: dependency: transitive description: name: path_provider_windows - sha256: d3f80b32e83ec208ac95253e0cd4d298e104fbc63cb29c5c69edaed43b0c69d6 + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 url: "https://pub.dev" source: hosted - version: "2.1.6" - pedantic: - dependency: transitive - description: - name: pedantic - sha256: "67fc27ed9639506c856c840ccce7594d0bdcd91bc8d53d6e52359449a1d50602" - url: "https://pub.dev" - source: hosted - version: "1.11.1" + version: "2.3.0" petitparser: dependency: transitive description: name: petitparser - sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 url: "https://pub.dev" source: hosted - version: "5.4.0" + version: "6.0.2" photo_view: dependency: "direct main" description: name: photo_view - sha256: "8036802a00bae2a78fc197af8a158e3e2f7b500561ed23b4c458107685e645bb" + sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e" url: "https://pub.dev" source: hosted - version: "0.14.0" + version: "0.15.0" platform: dependency: transitive description: name: platform - sha256: "4a451831508d7d6ca779f7ac6e212b4023dd5a7d08a27a63da33756410e32b76" + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.5" plugin_platform_interface: dependency: transitive description: name: plugin_platform_interface - sha256: "6a2128648c854906c53fa8e33986fc0247a1116122f9534dd20e3ab9e16a32bc" + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.8" pointycastle: - dependency: transitive + dependency: "direct main" description: name: pointycastle - sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c" + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" url: "https://pub.dev" source: hosted - version: "3.7.3" + version: "3.9.1" pool: dependency: transitive description: @@ -931,10 +1048,10 @@ packages: dependency: transitive description: name: process - sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" + sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" url: "https://pub.dev" source: hosted - version: "4.2.4" + version: "5.0.2" pub_semver: dependency: transitive description: @@ -947,106 +1064,130 @@ packages: dependency: transitive description: name: pubspec_parse - sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 + sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 url: "https://pub.dev" source: hosted - version: "1.2.3" + version: "1.3.0" qr: dependency: transitive description: name: qr - sha256: "5c4208b4dc0d55c3184d10d83ee0ded6212dc2b5e2ba17c5a0c0aab279128d21" + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "3.0.2" qr_flutter: dependency: "direct main" description: name: qr_flutter - sha256: c5c121c54cb6dd837b9b9d57eb7bc7ec6df4aee741032060c8833a678c80b87e + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.1.0" riverpod: dependency: "direct main" description: name: riverpod - sha256: "80e48bebc83010d5e67a11c9514af6b44bbac1ec77b4333c8ea65dbc79e2d8ef" + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" url: "https://pub.dev" source: hosted - version: "2.3.6" - rxdart: + version: "2.6.1" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: dc53a659cb543b203cdc35cd4e942ed08ea893eb6ef12029301323bdf18c5d95 + url: "https://pub.dev" + source: hosted + version: "0.5.7" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_lint: + dependency: "direct dev" + description: + name: riverpod_lint + sha256: "326efc199b87f21053b9a2afbf2aea26c41b3bf6f8ba346ce69126ee17d16ebd" + url: "https://pub.dev" + source: hosted + version: "2.6.2" + rust_lib_ndk: dependency: transitive + description: + name: rust_lib_ndk + sha256: "5bb8f455cebd72a174d0974651bacfaacb7638960ac9029d741d0dc4dd123e71" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + rxdart: + dependency: "direct main" description: name: rxdart - sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" url: "https://pub.dev" source: hosted - version: "0.27.7" + version: "0.28.0" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: "16d3fb6b3692ad244a695c0183fca18cf81fd4b821664394a781de42386bf022" + sha256: "746e5369a43170c25816cc472ee016d3a66bc13fcf430c0bc41ad7b4b2922051" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.3.2" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "6478c6bbbecfe9aced34c483171e90d7c078f5883558b30ec3163cf18402c749" + sha256: "3b9febd815c9ca29c9e3520d50ec32f49157711e143b7a4ca039eb87e8ade5ab" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.3.3" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: e014107bb79d6d3297196f4f2d0db54b5d1f85b8ea8ff63b8e8b391a02700feb + sha256: "07e050c7cd39bad516f8d64c455f04508d09df104be326d8c02551590a0d513d" url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.5.3" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux - sha256: "9d387433ca65717bbf1be88f4d5bb18f10508917a8fa2fb02e0fd0d7479a9afa" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - shared_preferences_macos: - dependency: transitive - description: - name: shared_preferences_macos - sha256: "81b6a60b2d27020eb0fc41f4cebc91353047309967901a79ee8203e40c42ed46" + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" url: "https://pub.dev" source: hosted - version: "2.0.5" + version: "2.4.1" shared_preferences_platform_interface: dependency: transitive description: name: shared_preferences_platform_interface - sha256: fb5cf25c0235df2d0640ac1b1174f6466bd311f621574997ac59018a6664548d + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.1" shared_preferences_web: dependency: transitive description: name: shared_preferences_web - sha256: "74083203a8eae241e0de4a0d597dbedab3b8fef5563f33cf3c12d7e93c655ca5" + sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.4.2" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows - sha256: "5e588e2efef56916a3b229c3bfe81e6a525665a454519ca51dbcc4236a274173" + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.1" shelf: dependency: transitive description: @@ -1067,18 +1208,26 @@ 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: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "2.0.0" + shimmer: + dependency: "direct main" + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter @@ -1088,18 +1237,18 @@ packages: dependency: transitive description: name: source_gen - sha256: "373f96cf5a8744bc9816c1ff41cf5391bbdbe3d7a96fe98c622b6738a8a7bd33" + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.5.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: @@ -1112,74 +1261,82 @@ packages: dependency: transitive description: name: source_span - sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250 + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "7.0.0" sqflite: - dependency: "direct main" + dependency: transitive description: name: sqflite - sha256: b4d6710e1200e96845747e37338ea8a819a12b51689a3bcf31eff0003b37a0b9 + sha256: "79a297dc3cc137e758c6a4baf83342b039e5a6d2436fcdf3f96a00adaaf2ad62" url: "https://pub.dev" source: hosted - version: "2.2.8+4" - sqflite_common: + version: "2.4.0" + sqflite_android: dependency: transitive description: - name: sqflite_common - sha256: e77abf6ff961d69dfef41daccbb66b51e9983cdd5cb35bf30733598057401555 + name: sqflite_android + sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" url: "https://pub.dev" source: hosted - version: "2.4.5" - sqflite_common_ffi: + version: "2.4.0" + sqflite_common: dependency: transitive description: - name: sqflite_common_ffi - sha256: f86de82d37403af491b21920a696b19f01465b596f545d1acd4d29a0a72418ad + name: sqflite_common + sha256: "4468b24876d673418a7b7147e5a08a715b4998a7ae69227acafaab762e0e5490" url: "https://pub.dev" source: hosted - version: "2.2.5" - sqlite3: + version: "2.5.4+5" + sqflite_darwin: dependency: transitive description: - name: sqlite3 - sha256: f7511ddd6a2dda8ded9d849f8a925bb6020e0faa59db2443debc18d484e59401 + name: sqflite_darwin + sha256: "769733dddf94622d5541c73e4ddc6aa7b252d865285914b6fcd54a63c4b4f027" url: "https://pub.dev" source: hosted - version: "2.0.0" - sqlparser: + version: "2.4.1-1" + sqflite_platform_interface: dependency: transitive description: - name: sqlparser - sha256: "91f47610aa54d8abf9d795a7b4e49b2a788f65d7493d5a68fbf180c3cbcc6f38" + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" url: "https://pub.dev" source: hosted - version: "0.27.0" + version: "2.4.0" stack_trace: dependency: transitive description: name: stack_trace - sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.11.1" state_notifier: dependency: transitive description: name: state_notifier - sha256: "8fe42610f179b843b12371e40db58c9444f8757f8b69d181c97e50787caed289" + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb url: "https://pub.dev" source: hosted - version: "0.7.2+1" + version: "1.0.0" stream_channel: dependency: transitive description: name: stream_channel - sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" stream_transform: dependency: transitive description: @@ -1196,14 +1353,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" - strings: - dependency: transitive - description: - name: strings - sha256: "5af86299505c299640f5564e187c1a2ee9d6308c540e8d65f6385f5c67019122" - url: "https://pub.dev" - source: hosted - version: "0.2.2" sync_http: dependency: transitive description: @@ -1216,10 +1365,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "5fcbd27688af6082f5abd611af56ee575342c30e87541d0245f7ff99faa02c60" + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.3.0+3" term_glyph: dependency: transitive description: @@ -1232,42 +1381,42 @@ packages: dependency: "direct dev" description: name: test - sha256: "3dac9aecf2c3991d09b9cdde4f98ded7b30804a88a0d7e4e7e1678e78d6b97f4" + sha256: "7ee44229615f8f642b68120165ae4c2a75fe77ae2065b1e55ae4711f6cf0899e" url: "https://pub.dev" source: hosted - version: "1.24.1" + version: "1.25.7" test_api: dependency: transitive description: name: test_api - sha256: eb6ac1540b26de412b3403a163d919ba86f6a973fe6cc50ae3541b80092fdcfb + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "0.7.2" test_core: dependency: transitive description: name: test_core - sha256: "5138dbffb77b2289ecb12b81c11ba46036590b72a64a7a90d6ffb880f1a29e93" + sha256: "55ea5a652e38a1dfb32943a7973f3681a60f872f8c3a05a14664ad54ef9c6696" url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "0.6.4" timeago: dependency: transitive description: name: timeago - sha256: a415b9a05ef64b845c859a91161fc9f689f88eaaa4c04759517d201891b99e90 + sha256: "054cedf68706bb142839ba0ae6b135f6b68039f0b8301cbe8784ae653d5ff8de" url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "3.7.0" timeago_flutter: dependency: "direct main" description: name: timeago_flutter - sha256: ebd9bf45b0e9088501e1243a26ce275c5810b877fc86c2618c60201ad14f6a78 + sha256: "0fd70e79f35f5ea6507f04b3852ba3df3de595901cc1a916e4abc452115b09ac" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "3.7.0" timing: dependency: transitive description: @@ -1280,90 +1429,114 @@ packages: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted - version: "1.3.2" - unicode: + version: "1.4.0" + unorm_dart: dependency: transitive description: - name: unicode - sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" + name: unorm_dart + sha256: "5b35bff83fce4d76467641438f9e867dc9bcfdb8c1694854f230579d68cd8f4b" url: "https://pub.dev" source: hosted - version: "0.3.1" + version: "0.2.0" url_launcher: dependency: "direct main" description: name: url_launcher - sha256: eb1e00ab44303d50dd487aab67ebc575456c146c6af44422f9c13889984c00f3 + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" url: "https://pub.dev" source: hosted - version: "6.1.11" + version: "6.3.1" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: "7aac14be5f4731b923cc697ae2d42043945076cd0dbb8806baecc92c1dc88891" + sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" url: "https://pub.dev" source: hosted - version: "6.0.33" + version: "6.3.14" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "9af7ea73259886b92199f9e42c116072f05ff9bea2dcb339ab935dfc957392c2" + sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e url: "https://pub.dev" source: hosted - version: "6.1.4" + version: "6.3.1" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: "207f4ddda99b95b4d4868320a352d374b0b7e05eefad95a4a26f57da413443f5" + sha256: e2b9622b4007f97f504cd64c0128309dfb978ae66adbe944125ed9e1750f06af url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.2.0" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "91ee3e75ea9dadf38036200c5d3743518f4a5eb77a8d13fda1ee5764373f185e" + sha256: "769549c999acdb42b8bcfa7c43d72bf79a382ca7441ab18a808e101149daf672" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.2.1" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface - sha256: "6c9ca697a5ae218ce56cece69d46128169a58aa8653c1b01d26fcd4aad8c4370" + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.3.2" url_launcher_web: dependency: transitive description: name: url_launcher_web - sha256: "81fe91b6c4f84f222d186a9d23c73157dc4c8e1c71489c4d08be1ad3b228f1aa" + sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" url: "https://pub.dev" source: hosted - version: "2.0.16" + version: "2.3.3" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "254708f17f7c20a9c8c471f67d86d76d4a3f9c1591aad1e15292008aceb82771" + sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4" url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.1.3" uuid: dependency: "direct main" description: name: uuid - sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "4.5.1" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "0b9149c6ddb013818075b072b9ddc1b89a5122fff1275d4648d297086b46c4f0" + url: "https://pub.dev" + source: hosted + version: "1.1.12" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "2430b973a4ca3c4dbc9999b62b8c719a160100dcbae5c819bae0cacce32c9cdb" + url: "https://pub.dev" + source: hosted + version: "1.1.12" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: f3b9b6e4591c11394d4be4806c63e72d3a41778547b2c1e2a8a04fadcfd7d173 + url: "https://pub.dev" + source: hosted + version: "1.1.12" vector_math: dependency: transitive description: @@ -1376,10 +1549,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f6deed8ed625c52864792459709183da231ebf66ff0cf09e69b573227c377efe + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "11.3.0" + version: "14.2.5" watcher: dependency: transitive description: @@ -1388,54 +1561,78 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + url: "https://pub.dev" + source: hosted + version: "0.1.6" web_socket_channel: dependency: "direct main" description: name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "3.0.1" webdriver: dependency: transitive description: name: webdriver - sha256: "3c923e918918feeb90c4c9fdf1fe39220fa4c0e8e2c0fffaded174498ef86c49" + sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.3" webkit_inspection_protocol: dependency: transitive description: name: webkit_inspection_protocol - sha256: "67d3a8b6c79e1987d19d848b0892e582dbb0c66c57cc1fef58a177dd2aa2823d" + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.1" win32: dependency: transitive description: name: win32 - sha256: "5a751eddf9db89b3e5f9d50c20ab8612296e4e8db69009788d6c8b060a84191c" + sha256: "84ba388638ed7a8cb3445a320c8273136ab2631cd5f2c57888335504ddab1bc2" + url: "https://pub.dev" + source: hosted + version: "5.8.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" url: "https://pub.dev" source: hosted - version: "4.1.4" + version: "1.1.5" xdg_directories: dependency: transitive description: name: xdg_directories - sha256: ee1505df1426458f7f60aac270645098d318a8b4766d85fde75f76f2e21807d1 + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.1.0" xml: dependency: transitive description: name: xml - sha256: "5bc72e1e45e941d825fd7468b9b4cc3b9327942649aeb6fc5cdbf135f0a86e84" + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.5.0" yaml: dependency: transitive description: @@ -1445,5 +1642,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.0.0 <4.0.0" - flutter: ">=3.3.0" + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" diff --git a/pubspec.yaml b/pubspec.yaml index 2d42dc3f..89ff2246 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,10 +15,10 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.0.20+20 +version: 1.1.4+33 environment: - sdk: ">=2.17.6 <3.0.0" + sdk: ">=3.0.0 <4.0.0" # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -29,65 +29,88 @@ environment: dependencies: flutter: sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 - flutter_hooks: ^0.18.0 - hooks_riverpod: ^2.0.2 - http: ^0.13.5 - flutter_svg: ^1.1.6 - timeago_flutter: ^1.2.0 - json_cache: ^1.5.0 - cross_local_storage: ^2.0.1 - badges: ^2.0.3 - uuid: ^3.0.7 - cached_network_image: ^3.2.3 - bip340: ^0.0.4 + flutter_hooks: ^0.20.5 + hooks_riverpod: ^2.5.2 + riverpod: ^2.3.6 + riverpod_annotation: ^2.3.5 + http: ^1.2.1 + flutter_svg: ^2.0.7 + timeago_flutter: ^3.5.0 + badges: ^3.1.2 + uuid: ^4.4.2 + cached_network_image: ^3.3.1 + bip340: ^0.3.0 bech32: ^0.2.1 hex: ^0.2.0 - flutter_secure_storage: ^6.1.0 + flutter_secure_storage: ^9.2.2 crypto: ^3.0.2 url_launcher: ^6.1.7 - lottie: ^2.2.0 - flutter_launcher_icons: ^0.11.0 - file_picker: ^5.2.5 - mime: ^1.0.4 + lottie: ^3.0.0 + flutter_launcher_icons: ^0.14.1 + file_picker: ^8.0.0+1 + mime: ^2.0.0 flutter_mentions: ^2.0.1 - photo_view: ^0.14.0 + photo_view: ^0.15.0 qr_flutter: ^4.0.0 - matomo_tracker: ^2.0.0 + matomo_tracker: ^5.1.0 shared_preferences: ^2.1.1 - floor: ^1.4.2 - sqflite: ^2.2.8+4 - riverpod: ^2.3.6 - custom_refresh_indicator: ^2.2.1 - flutter_cache_manager: ^3.3.0 - web_socket_channel: ^2.4.0 + + custom_refresh_indicator: ^4.0.1 + flutter_cache_manager: ^3.4.1 + web_socket_channel: ^3.0.1 http_parser: ^4.0.2 - drift: ^2.9.0 + path_provider: ^2.1.4 + pointycastle: ^3.7.3 + encrypt: ^5.0.3 + kepler: ^1.0.3 + #device_preview: ^1.1.0 + bip39_mnemonic: ^3.0.6 + bip32: ^2.0.0 + crop_your_image: ^1.0.1 + package_info_plus: ^8.0.2 + rxdart: ^0.28.0 + shimmer: ^3.0.0 + ndk: ^0.1.3 + flutter_force_directed_graph: ^1.0.7 + objectbox: ^4.0.3 + objectbox_flutter_libs: any + amberflutter: ^0.0.9 + + +#dependency_overrides: + #ndk: + #path: ../ndk + #git: + # url: https://github.com/relaystr/ndk.git + # ref: isar + + dev_dependencies: - floor_generator: ^1.4.2 - build_runner: ^2.1.2 + build_runner: ^2.4.13 + mockito: ^5.4.0 flutter_test: sdk: flutter - integration_test: + integration_test: sdk: flutter - mockito: ^5.4.0 - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^2.0.0 - test: ^1.21.4 + flutter_lints: ^5.0.0 + custom_lint: ^0.7.0 + riverpod_lint: ^2.3.13 + #riverpod_generator: ^2.4.3 + test: ^1.25.7 + objectbox_generator: any + + + + #layerlens: ^1.0.17 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec # The following section is specific to Flutter packages. + flutter: # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in @@ -112,12 +135,12 @@ flutter: # "family" key with the font family name, and a "fonts" key with a # list giving the asset and other descriptors for the font. For # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic + fonts: + - family: Poppins + fonts: + - asset: fonts/Poppins/Poppins-Regular.ttf + - asset: fonts/Poppins/Poppins-Italic.ttf + style: italic # - family: Trajan Pro # fonts: # - asset: fonts/TrajanPro.ttf @@ -126,3 +149,7 @@ flutter: # # For details regarding fonts from package dependencies, # see https://flutter.dev/custom-fonts/#from-packages + + +### run generator: +# dart run build_runner build \ No newline at end of file diff --git a/test/unit_test/event_feed/tree_test.dart b/test/unit_test/event_feed/tree_test.dart new file mode 100644 index 00000000..e8e0f6b0 --- /dev/null +++ b/test/unit_test/event_feed/tree_test.dart @@ -0,0 +1,149 @@ +import 'package:camelus/domain_layer/entities/nostr_note.dart'; +import 'package:camelus/domain_layer/entities/nostr_tag.dart'; +import 'package:camelus/domain_layer/usecases/event_feed.dart'; +import 'package:test/test.dart'; + +void main() { + group('Tree', () { + // Create a root note + final rootNote = NostrNote( + id: 'root123', + pubkey: 'pubkey1', + created_at: 1635000000, + kind: 1, + content: 'This is the root note.', + sig: 'sig1', + tags: [], + ); + + // Create replies to the root note + final reply1 = NostrNote( + id: 'reply1', + pubkey: 'pubkey2', + created_at: 1635000100, + kind: 1, + content: 'This is a reply to the root note.', + sig: 'sig2', + tags: [NostrTag(type: 'e', value: 'root123', marker: 'root')], + ); + + final reply2 = NostrNote( + id: 'reply2', + pubkey: 'pubkey3', + created_at: 1635000200, + kind: 1, + content: 'Another reply to the root note.', + sig: 'sig3', + tags: [NostrTag(type: 'e', value: 'root123', marker: 'root')], + ); + + // Create replies to the replies + final nestedReply1 = NostrNote( + id: 'nestedReply1', + pubkey: 'pubkey4', + created_at: 1635000300, + kind: 1, + content: 'A reply to the first reply.', + sig: 'sig4', + tags: [ + NostrTag(type: 'e', value: 'root123', marker: 'root'), + NostrTag(type: 'e', value: 'reply1', marker: 'reply'), + ], + ); + + final nestedReply2 = NostrNote( + id: 'nestedReply2', + pubkey: 'pubkey5', + created_at: 1635000400, + kind: 1, + content: 'A reply to the second reply.', + sig: 'sig5', + tags: [ + NostrTag(type: 'e', value: 'root123', marker: 'root'), + NostrTag(type: 'e', value: 'reply2', marker: 'reply'), + ], + ); + + // Create replies to the replies to the replies + final nestedNestedReply1 = NostrNote( + id: 'nestedNestedReply1', + pubkey: 'pubkey6', + created_at: 1635000500, + kind: 1, + content: 'A reply to the first nested reply.', + sig: 'sig6', + tags: [ + NostrTag(type: 'e', value: 'root123', marker: 'root'), + NostrTag(type: 'e', value: 'nestedReply1', marker: 'reply'), + ], + ); + + final nestedNestedReply2 = NostrNote( + id: 'nestedNestedReply2', + pubkey: 'pubkey7', + created_at: 1635000600, + kind: 1, + content: 'A reply to the second nested reply.', + sig: 'sig7', + tags: [ + NostrTag(type: 'e', value: 'root123', marker: 'root'), + NostrTag(type: 'e', value: 'nestedReply1', marker: 'reply'), + ], + ); + + final notFoundReply = NostrNote( + id: 'notFoundReply', + pubkey: 'pubkey8', + created_at: 1635000700, + kind: 1, + content: 'A reply to a note that does not exist.', + sig: 'sig8', + tags: [ + NostrTag(type: 'e', value: 'notFound', marker: 'root'), + ], + ); + + // Create a list of all notes + final List allValidReplies = [ + reply1, + reply2, + nestedReply1, + nestedReply2, + nestedNestedReply1, + nestedNestedReply2, + notFoundReply, + ]; + + test('test building tree', () { + final tree = EventFeed.buildRepliesTree( + rootNoteId: rootNote.id, + replies: allValidReplies, + ); + for (final node in tree) { + node.printTree(); + } + + // first level replies + expect(tree.length, 2); + expect(tree[0].value.id, equals(reply1.id)); + expect(tree[1].value.id, equals(reply2.id)); + + // second level replies + expect(tree[0].children.length, 1); + expect(tree[0].children[0].value.id, equals(nestedReply1.id)); + + expect(tree[1].children.length, 1); + expect(tree[1].children[0].value.id, equals(nestedReply2.id)); + + // third level replies + + expect(tree[0].children[0].children.length, 2); + expect(tree[0].children[0].children[0].value.id, + equals(nestedNestedReply1.id)); + expect(tree[0].children[0].children[1].value.id, + equals(nestedNestedReply2.id)); + + expect(tree[1].children[0].children.length, 0); + }); + }); +} diff --git a/test/unit_test/helpers/bip340_test.dart b/test/unit_test/helpers/bip340_test.dart deleted file mode 100644 index 88c3b2a7..00000000 --- a/test/unit_test/helpers/bip340_test.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:camelus/helpers/bip340.dart'; -import 'package:hex/hex.dart'; -import 'package:test/test.dart'; - -void main() { - group('Bip340', () { - test('sign and verify', () { - final bip340 = Bip340(); - final keyPair = bip340.generatePrivateKey(); - const message = 'Hello, World!'; - // message to HEX - final messageHex = HEX.encode(message.codeUnits); - final signature = bip340.sign(messageHex, keyPair.privateKey); - expect(bip340.verify(messageHex, signature, keyPair.publicKey), isTrue); - }); - - test('getPublicKey', () { - final bip340 = Bip340(); - final keyPair = bip340.generatePrivateKey(); - expect( - bip340.getPublicKey(keyPair.privateKey), equals(keyPair.publicKey)); - }); - }); -} diff --git a/test/unit_test/helpers/nip04_encryption_test.dart b/test/unit_test/helpers/nip04_encryption_test.dart new file mode 100644 index 00000000..7d736f12 --- /dev/null +++ b/test/unit_test/helpers/nip04_encryption_test.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; + +import 'package:camelus/helpers/nip04_encryption.dart'; +import 'package:test/test.dart'; + +void main() { + group('nip04Encryption', () { + test('decrypt', () { + final nip04 = Nip04Encryption(); + const priv = + "fb505c65d4df950f5d28c9e4d285ee12ffaf315deef1fc24e3c7cd1e7e35f2b1"; + const pub = + "b1a5c93edcc8d586566fde53a20bdb50049a97b15483cb763854e57016e0fa3d"; + + const ciphertext = + "VezuSvWak++ASjFMRqBPWS3mK5pZ0vRLL325iuIL4S+r8n9z+DuMau5vMElz1tGC/UqCDmbzE2kwplafaFo/FnIZMdEj4pdxgptyBV1ifZpH3TEF6OMjEtqbYRRqnxgIXsuOSXaerWgpi0pm+raHQPseoELQI/SZ1cvtFqEUCXdXpa5AYaSd+quEuthAEw7V1jP+5TDRCEC8jiLosBVhCtaPpLcrm8HydMYJ2XB6Ixs=?iv=/rtV49RFm0XyFEwG62Eo9A=="; + + final desiredResult = [ + [ + "p", + "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437" + ], + [ + "p", + "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168" + ] + ]; + + final result = nip04.decrypt(priv, pub, ciphertext); + final parsedResult = json.decode(result); + + expect(parsedResult, desiredResult); + }); + + test('encrypt only type', () { + const priv = + "fb505c65d4df950f5d28c9e4d285ee12ffaf315deef1fc24e3c7cd1e7e35f2b1"; + const pub = + "b1a5c93edcc8d586566fde53a20bdb50049a97b15483cb763854e57016e0fa3d"; + + const clearTextObj = [ + [ + "p", + "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437" + ], + [ + "p", + "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168" + ] + ]; + + const desiredResult = + "VezuSvWak++ASjFMRqBPWS3mK5pZ0vRLL325iuIL4S+r8n9z+DuMau5vMElz1tGC/UqCDmbzE2kwplafaFo/FnIZMdEj4pdxgptyBV1ifZpH3TEF6OMjEtqbYRRqnxgIXsuOSXaerWgpi0pm+raHQPseoELQI/SZ1cvtFqEUCXdXpa5AYaSd+quEuthAEw7V1jP+5TDRCEC8jiLosBVhCtaPpLcrm8HydMYJ2XB6Ixs=?iv=/rtV49RFm0XyFEwG62Eo9A=="; + + final clearTextString = json.encode(clearTextObj); + final nip04 = Nip04Encryption(); + final result = nip04.encrypt(priv, pub, clearTextString); + + expect(result.runtimeType, desiredResult.runtimeType); + }); + }); +} diff --git a/test/unit_test/services/nostr/metadata/nip_05_test.dart b/test/unit_test/services/nostr/metadata/nip_05_test.dart deleted file mode 100644 index 067d568a..00000000 --- a/test/unit_test/services/nostr/metadata/nip_05_test.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'package:camelus/services/nostr/metadata/nip_05.dart'; -import 'package:http/http.dart' as http; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:http/testing.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -//class MockClient extends Mock implements http.Client {} - -class MockSharedPreferences extends Mock implements SharedPreferences {} - -@GenerateMocks([http.Client]) -void main() { - SharedPreferences.setMockInitialValues({}); //set values here - - //TestWidgetsFlutterBinding.ensureInitialized(); - final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive; - group('Nip05', () { - Future requestHandler(http.Request request) async { - const apiResponse = - '{"names": {"username": "pubkey"}, "relays": {"pubkey": ["relay1", "relay2"]}}'; - return http.Response(apiResponse, 200); - } - - Future requestHandlerErr(http.Request request) async { - const apiResponse = ''; - return http.Response(apiResponse, 500); - } - - test('returns a Map if the http call completes successfully', () async { - final client = MockClient(requestHandler); - - final mockSharedPreferences = MockSharedPreferences(); - when(mockSharedPreferences.getString('')).thenReturn('mock'); - - // Use Mockito to return a successful response when it calls the provided http.Client. - // when(client.get( - // Uri.parse('https://lox.de/.well-known/nostr.json?name=username'))) - // .thenAnswer((_) async => http.Response( - // '{"names": {"username": "pubkey"}, "relays": {"pubkey": ["relay1", "relay2"]}}', - // 200)); - - Nip05 nip05 = Nip05(); - await Future.delayed(const Duration(milliseconds: 500)); - - // insert mock http client - nip05.client = client; - - expect(await nip05.checkNip05('username@lox.de', 'pubkey'), - isA>()); - }); - - test('throws an exception if the http call completes with an error', - () async { - final client = MockClient(requestHandlerErr); - final mockSharedPreferences = MockSharedPreferences(); - when(mockSharedPreferences.getString('')).thenReturn('mock'); - - var nip05 = Nip05(); // Initialize your class - - // Comment out the following lines if you have implemented dependency injection for http.Client in your class - // And pass the mock client to your class like this: var nip05 = Nip05(client: client); - - nip05.client = - client; // Make sure to add this line in your class for testing purposes - - expect(nip05.checkNip05('username@url', 'pubkey'), throwsException); - }); - test('result is invalid', () async { - final client = MockClient(requestHandler); - final mockSharedPreferences = MockSharedPreferences(); - when(mockSharedPreferences.getString('')).thenReturn('mock'); - - var nip05 = Nip05(); // Initialize your class - - // Comment out the following lines if you have implemented dependency injection for http.Client in your class - // And pass the mock client to your class like this: var nip05 = Nip05(client: client); - - nip05.client = - client; // Make sure to add this line in your class for testing purposes - - var result = - await nip05.checkNip05('username@url', 'non-existing-pubkey'); - - expect(result['valid'], false); - }); - test('result is valid', () async { - final client = MockClient(requestHandler); - final mockSharedPreferences = MockSharedPreferences(); - when(mockSharedPreferences.getString('')).thenReturn('mock'); - - var nip05 = Nip05(); // Initialize your class - - // Comment out the following lines if you have implemented dependency injection for http.Client in your class - // And pass the mock client to your class like this: var nip05 = Nip05(client: client); - - nip05.client = - client; // Make sure to add this line in your class for testing purposes - - var result = await nip05.checkNip05('username@url', 'pubkey'); - - expect(result['valid'], true); - }); - }); - - // mockito not working - // group('mockito test', () { - // test('returns a Map if the http call completes successfully', () async { - // final client = MockClient(); - - // when(client.get( - // Uri.parse('https://lox.de/.well-known/nostr.json?name=username'), - // )).thenAnswer( - // (_) => Future.value(http.Response( - // '{"names": {"username": "pubkey"}, "relays": {"pubkey": ["relay1", "relay2"]}}', - // 200)), - // ); - - // var response = await client.get( - // Uri.parse('https://lox.de/.well-known/nostr.json?name=username')); - - // print(response - // .body); // prints {"names": {"username": "pubkey"}, "relays": {"pubkey": ["relay1", "relay2"]}} - // }); - // }); - - group('working mock http', () { - Future requestHandler(http.Request request) async { - const apiResponse = - '{"names": {"username": "pubkey"}, "relays": {"pubkey": ["relay1", "relay2"]}}'; - return http.Response(apiResponse, 200); - } - - var mockHttpClient = MockClient(requestHandler); - - setUp(() { - var mockHttpClient = MockClient(requestHandler); - }); - - test('test of mock http client', () async { - var response = await mockHttpClient.get( - Uri.parse('https://lox.de/.well-known/nostr.json?name=username')); - print(response.body); - }); - }); -} diff --git a/test/unit_test/services/nostr/metadata/nip_05_test.mocks.dart b/test/unit_test/services/nostr/metadata/nip_05_test.mocks.dart deleted file mode 100644 index 5ef5e034..00000000 --- a/test/unit_test/services/nostr/metadata/nip_05_test.mocks.dart +++ /dev/null @@ -1,263 +0,0 @@ -// Mocks generated by Mockito 5.4.0 from annotations -// in camelus/test/unit_test/services/nostr/metadata/nip_05_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i3; -import 'dart:convert' as _i4; -import 'dart:typed_data' as _i5; - -import 'package:http/http.dart' as _i2; -import 'package:mockito/mockito.dart' as _i1; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class - -class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { - _FakeResponse_0( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeStreamedResponse_1 extends _i1.SmartFake - implements _i2.StreamedResponse { - _FakeStreamedResponse_1( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -/// A class which mocks [Client]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockClient extends _i1.Mock implements _i2.Client { - MockClient() { - _i1.throwOnMissingStub(this); - } - - @override - _i3.Future<_i2.Response> head( - Uri? url, { - Map? headers, - }) => - (super.noSuchMethod( - Invocation.method( - #head, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #head, - [url], - {#headers: headers}, - ), - )), - ) as _i3.Future<_i2.Response>); - @override - _i3.Future<_i2.Response> get( - Uri? url, { - Map? headers, - }) => - (super.noSuchMethod( - Invocation.method( - #get, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #get, - [url], - {#headers: headers}, - ), - )), - ) as _i3.Future<_i2.Response>); - @override - _i3.Future<_i2.Response> post( - Uri? url, { - Map? headers, - Object? body, - _i4.Encoding? encoding, - }) => - (super.noSuchMethod( - Invocation.method( - #post, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #post, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); - @override - _i3.Future<_i2.Response> put( - Uri? url, { - Map? headers, - Object? body, - _i4.Encoding? encoding, - }) => - (super.noSuchMethod( - Invocation.method( - #put, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #put, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); - @override - _i3.Future<_i2.Response> patch( - Uri? url, { - Map? headers, - Object? body, - _i4.Encoding? encoding, - }) => - (super.noSuchMethod( - Invocation.method( - #patch, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #patch, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); - @override - _i3.Future<_i2.Response> delete( - Uri? url, { - Map? headers, - Object? body, - _i4.Encoding? encoding, - }) => - (super.noSuchMethod( - Invocation.method( - #delete, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( - this, - Invocation.method( - #delete, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i3.Future<_i2.Response>); - @override - _i3.Future read( - Uri? url, { - Map? headers, - }) => - (super.noSuchMethod( - Invocation.method( - #read, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future.value(''), - ) as _i3.Future); - @override - _i3.Future<_i5.Uint8List> readBytes( - Uri? url, { - Map? headers, - }) => - (super.noSuchMethod( - Invocation.method( - #readBytes, - [url], - {#headers: headers}, - ), - returnValue: _i3.Future<_i5.Uint8List>.value(_i5.Uint8List(0)), - ) as _i3.Future<_i5.Uint8List>); - @override - _i3.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) => - (super.noSuchMethod( - Invocation.method( - #send, - [request], - ), - returnValue: - _i3.Future<_i2.StreamedResponse>.value(_FakeStreamedResponse_1( - this, - Invocation.method( - #send, - [request], - ), - )), - ) as _i3.Future<_i2.StreamedResponse>); - @override - void close() => super.noSuchMethod( - Invocation.method( - #close, - [], - ), - returnValueForMissingStub: null, - ); -} diff --git a/test/widget_integration_test/onboarding_test.dart b/test/widget_integration_test/onboarding_test.dart deleted file mode 100644 index 472b0bc3..00000000 --- a/test/widget_integration_test/onboarding_test.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:camelus/routes/nostr/onboarding/onboarding.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter/material.dart'; - -void main() { - testWidgets('NostrOnboarding widget test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MaterialApp(home: NostrOnboarding())); - - // Check if the "camelus" title appears - expect(find.text('camelus'), findsOneWidget); - - // Check if the "early preview" subtitle appears - expect(find.text('early preview'), findsOneWidget); - - // Check if the "This is your private key:" label appears - expect(find.text('This is your private key:'), findsOneWidget); - - // Check if the "paste" button appears - expect(find.widgetWithText(ElevatedButton, 'paste'), findsOneWidget); - - // Check if the "next" button appears - expect(find.widgetWithText(ElevatedButton, 'next'), findsOneWidget); - - // Check if the "I have read and accept the " label appears - expect(find.text('I have read and accept the '), findsOneWidget); - - // Check if the "terms and conditions" link appears - expect(find.text('terms and conditions'), findsOneWidget); - - // Check if the "privacy policy" link appears - expect(find.text('privacy policy'), findsOneWidget); - }); - - testWidgets('terms not accepted', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MaterialApp(home: NostrOnboarding())); - - // Find the 'next' button and tap it - await tester.tap(find.widgetWithText(ElevatedButton, 'next')); - await tester.pump(); // Rebuild the widget after the button tap - - // Check if the Snackbar is displayed with correct message - expect(find.byType(SnackBar), findsOneWidget); - expect(find.text('Please read and accept the terms and conditions first'), - findsOneWidget); - }); -} diff --git a/test/widget_integration_test/relays/relays_picker_test.dart b/test/widget_integration_test/relays/relays_picker_test.dart deleted file mode 100644 index a435cc72..00000000 --- a/test/widget_integration_test/relays/relays_picker_test.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'package:camelus/services/nostr/relays/relays_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:mockito/mockito.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -//import 'package:flutter_test/flutter_test.dart'; -import 'package:test/test.dart'; - -class MockSharedPreferences extends Mock implements SharedPreferences {} - -void main() { - SharedPreferences.setMockInitialValues({}); //set values here - - //TestWidgetsFlutterBinding.ensureInitialized(); - WidgetsFlutterBinding.ensureInitialized(); - group('picker EmptyTracker', () { - var picker = RelaysPicker(); - picker.relayTracker.tracker = {}; - - final mockSharedPreferences = MockSharedPreferences(); - when(mockSharedPreferences.getString('')).thenReturn('mock'); - - test('emptyTracker:NoPeopleLeft', () { - picker.relayTracker.tracker = {}; - const pubkeys = ['pubkey1', 'pubkey2', 'pubkey3']; - picker.init(pubkeys: [], coverageCount: 2); - - expect( - () => picker.pick(pubkeys), - throwsA(predicate((e) => - e is Exception && e.toString() == 'Exception: NoPeopleLeft')), - ); - }); - - test('emptyTracker:noRelays', () { - picker.relayTracker.tracker = {}; - const pubkeys = ['pubkey1', 'pubkey2', 'pubkey3']; - - picker.init(pubkeys: pubkeys, coverageCount: 2); - - String result; - try { - var relayAssignments = picker.pick(pubkeys); - - result = relayAssignments.toString(); - } catch (e) { - result = e.toString(); - } - - expect(result, "Exception: NoRelays"); - }); - }); - - group("picker Populated", () { - final mockSharedPreferences = MockSharedPreferences(); - when(mockSharedPreferences.getString('')).thenReturn('mock'); - var picker = RelaysPicker(); - - var now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - /// pubkey,relayUrl :{, lastSuggestedKind3, lastSuggestedNip05, lastSuggestedBytag} - var mockRelays = { - 'pubkey1': { - 'relay1': { - 'lastSuggestedKind3': now, - 'lastSuggestedNip05': now, - 'lastSuggestedBytag': now, - }, - 'relay2': { - 'lastSuggestedKind3': now, - 'lastSuggestedNip05': now, - 'lastSuggestedBytag': now, - }, - }, - 'pubkey2': { - 'relay1': { - 'lastSuggestedKind3': now, - 'lastSuggestedNip05': now, - 'lastSuggestedBytag': now, - }, - 'relay2': { - 'lastSuggestedKind3': now, - 'lastSuggestedNip05': now, - 'lastSuggestedBytag': now, - }, - }, - 'pubkey3': { - 'relay1': { - 'lastSuggestedKind3': now, - 'lastSuggestedNip05': now, - 'lastSuggestedBytag': now, - }, - 'relay2': { - 'lastSuggestedKind3': now, - 'lastSuggestedNip05': now, - 'lastSuggestedBytag': now, - }, - } - }; - }); -} diff --git a/test/widget_integration_test/relays/relays_test.dart b/test/widget_integration_test/relays/relays_test.dart deleted file mode 100644 index 680a36d2..00000000 --- a/test/widget_integration_test/relays/relays_test.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:camelus/services/nostr/relays/relays.dart'; -import 'package:camelus/services/nostr/relays/relays_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:mockito/mockito.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -//import 'package:flutter_test/flutter_test.dart'; -import 'package:test/test.dart'; - -class MockSharedPreferences extends Mock implements SharedPreferences {} - -void main() { - SharedPreferences.setMockInitialValues({}); //set values here - - //TestWidgetsFlutterBinding.ensureInitialized(); - WidgetsFlutterBinding.ensureInitialized(); - - group('relays', () { - final mockSharedPreferences = MockSharedPreferences(); - when(mockSharedPreferences.getString('')).thenReturn('mock'); - - var picker = RelaysPicker(); - - var now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - var mockRelays = { - 'pubkey1': { - 'relay1': { - 'lastSuggestedKind3': now, - 'lastSuggestedNip05': now, - 'lastSuggestedBytag': now, - }, - 'relay2': { - 'lastSuggestedKind3': now, - 'lastSuggestedNip05': now, - 'lastSuggestedBytag': now, - }, - }, - 'pubkey2': { - 'relay1': { - 'lastSuggestedKind3': now, - 'lastSuggestedNip05': now, - 'lastSuggestedBytag': now, - }, - 'relay2': { - 'lastSuggestedKind3': now, - 'lastSuggestedNip05': now, - 'lastSuggestedBytag': now, - }, - }, - 'pubkey3': { - 'relay1': { - 'lastSuggestedKind3': now, - 'lastSuggestedNip05': now, - 'lastSuggestedBytag': now, - }, - 'relay2': { - 'lastSuggestedKind3': now, - 'lastSuggestedNip05': now, - 'lastSuggestedBytag': now, - }, - } - }; - - test('get optimal relays', () async { - picker.relayTracker.tracker = mockRelays; - const pubkeys = ['pubkey1', 'pubkey2', 'pubkey3']; - picker.init(pubkeys: pubkeys, coverageCount: 2); - - var relays = Relays(); - var result = await relays.getOptimalRelays(pubkeys); - - var checkList = [ - RelayAssignment( - relayUrl: "relay2", pubkeys: ["pubkey1", "pubkey2", "pubkey3"]), - RelayAssignment( - relayUrl: "relay1", pubkeys: ["pubkey1", "pubkey2", "pubkey3"]), - ]; - - // check if all values in checkList are in result - for (var i = 0; i < checkList.length; i++) { - expect(result[i].relayUrl, checkList[i].relayUrl); - } - }); - }); -} diff --git a/widget_integration_test/onboarding_test.dart b/widget_integration_test/onboarding_test.dart new file mode 100644 index 00000000..4d7265ed --- /dev/null +++ b/widget_integration_test/onboarding_test.dart @@ -0,0 +1,41 @@ +import 'package:camelus/presentation_layer/routes/nostr/onboarding/onboarding.dart'; +import 'package:camelus/presentation_layer/routes/nostr/onboarding/onboarding_login.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +void main() { + testWidgets('NostrOnboarding widget test', (WidgetTester tester) async { + // Wrap the widget with ProviderScope + + await tester.pumpWidget(createWidgetUnderTest(child: NostrOnboarding())); + + // Rest of your test... + expect(find.text('camelus'), findsOneWidget); + expect(find.widgetWithText(ElevatedButton, 'join the conversation'), + findsOneWidget); + expect(find.widgetWithText(ElevatedButton, 'login'), findsOneWidget); + }); + + testWidgets('terms not accepted', (WidgetTester tester) async { + await tester + .pumpWidget(createWidgetUnderTest(child: OnboardingLoginPage())); + + // Rest of your test... + await tester.tap(find.widgetWithText(ElevatedButton, 'login')); + await tester.pumpAndSettle(); + + expect(find.byType(SnackBar), findsOneWidget); + expect(find.text('Please read and accept the terms and conditions first'), + findsOneWidget); + }); +} + +Widget createWidgetUnderTest({required Widget child}) { + return ProviderScope( + overrides: [ + // Add any necessary provider overrides here + ], + child: MaterialApp(home: child), + ); +} diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 16648275..9be8521a 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -8,7 +8,7 @@ set(BINARY_NAME "camelus") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. -cmake_policy(SET CMP0063 NEW) +cmake_policy(VERSION 3.14...3.25) # Define build configuration option. get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) @@ -52,6 +52,7 @@ add_subdirectory(${FLUTTER_MANAGED_DIR}) # Application build; see runner/CMakeLists.txt. add_subdirectory("runner") + # Generated plugin build rules, which manage building the plugins and adding # them to the application. include(flutter/generated_plugins.cmake) @@ -86,6 +87,12 @@ if(PLUGIN_BUNDLED_LIBRARIES) COMPONENT Runtime) endif() +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + # Fully re-copy the assets directory on each build to avoid having stale files # from a previous install. set(FLUTTER_ASSET_DIR_NAME "flutter_assets") diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt index 930d2071..903f4899 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 2048c455..3d811778 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,11 +7,14 @@ #include "generated_plugin_registrant.h" #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + ObjectboxFlutterLibsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ObjectboxFlutterLibsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index de626cc8..1891ca8f 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,10 +4,12 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows + objectbox_flutter_libs url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + rust_lib_ndk ) set(PLUGIN_BUNDLED_LIBRARIES)