├── .fvmrc ├── .github └── workflows │ └── build-android.yml ├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── analysis_options.yaml ├── documentation ├── app.gif └── scan.png ├── melos.yaml ├── packages ├── app │ ├── .gitignore │ ├── .metadata │ ├── android │ │ ├── .gitignore │ │ ├── app │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ ├── debug │ │ │ │ └── AndroidManifest.xml │ │ │ │ ├── main │ │ │ │ ├── AndroidManifest.xml │ │ │ │ ├── ic_launcher-playstore.png │ │ │ │ ├── kotlin │ │ │ │ │ └── com │ │ │ │ │ │ └── p2panda │ │ │ │ │ │ └── app │ │ │ │ │ │ └── MainActivity.kt │ │ │ │ └── res │ │ │ │ │ ├── drawable-v21 │ │ │ │ │ ├── background.png │ │ │ │ │ └── launch_background.xml │ │ │ │ │ ├── drawable │ │ │ │ │ ├── background.png │ │ │ │ │ └── launch_background.xml │ │ │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ │ ├── ic_launcher.xml │ │ │ │ │ └── ic_launcher_round.xml │ │ │ │ │ ├── mipmap-hdpi │ │ │ │ │ ├── ic_launcher.webp │ │ │ │ │ ├── ic_launcher_foreground.webp │ │ │ │ │ └── ic_launcher_round.webp │ │ │ │ │ ├── mipmap-mdpi │ │ │ │ │ ├── ic_launcher.webp │ │ │ │ │ ├── ic_launcher_foreground.webp │ │ │ │ │ └── ic_launcher_round.webp │ │ │ │ │ ├── mipmap-xhdpi │ │ │ │ │ ├── ic_launcher.webp │ │ │ │ │ ├── ic_launcher_foreground.webp │ │ │ │ │ └── ic_launcher_round.webp │ │ │ │ │ ├── mipmap-xxhdpi │ │ │ │ │ ├── ic_launcher.webp │ │ │ │ │ ├── ic_launcher_foreground.webp │ │ │ │ │ └── ic_launcher_round.webp │ │ │ │ │ ├── mipmap-xxxhdpi │ │ │ │ │ ├── ic_launcher.webp │ │ │ │ │ ├── ic_launcher_foreground.webp │ │ │ │ │ └── ic_launcher_round.webp │ │ │ │ │ ├── values-night-v31 │ │ │ │ │ └── styles.xml │ │ │ │ │ ├── values-night │ │ │ │ │ └── styles.xml │ │ │ │ │ ├── values-v31 │ │ │ │ │ └── styles.xml │ │ │ │ │ └── values │ │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ │ └── styles.xml │ │ │ │ └── profile │ │ │ │ └── AndroidManifest.xml │ │ ├── build.gradle │ │ ├── gradle.properties │ │ ├── gradle │ │ │ └── wrapper │ │ │ │ └── gradle-wrapper.properties │ │ └── settings.gradle │ ├── assets │ │ ├── fonts │ │ │ └── Staatliches-Regular.ttf │ │ ├── images │ │ │ ├── meliponini.png │ │ │ ├── meliponini.svg │ │ │ └── placeholder-bee.png │ │ ├── schema.lock │ │ └── seed.lock │ ├── l10n.yaml │ ├── lib │ │ ├── app.dart │ │ ├── io │ │ │ ├── assets.dart │ │ │ ├── files.dart │ │ │ ├── geolocation.dart │ │ │ ├── graphql │ │ │ │ ├── graphql.dart │ │ │ │ └── queries.dart │ │ │ ├── images.dart │ │ │ └── p2panda │ │ │ │ ├── documents.dart │ │ │ │ ├── key_pair.dart │ │ │ │ ├── node.dart │ │ │ │ ├── p2panda.dart │ │ │ │ ├── publish.dart │ │ │ │ ├── schemas.dart │ │ │ │ └── seed.dart │ │ ├── locales │ │ │ ├── app_en.arb │ │ │ └── app_pt.arb │ │ ├── main.dart │ │ ├── models │ │ │ ├── base.dart │ │ │ ├── blobs.dart │ │ │ ├── hive_location.dart │ │ │ ├── local_names.dart │ │ │ ├── schema_ids.dart │ │ │ ├── sightings.dart │ │ │ ├── species.dart │ │ │ ├── taxonomy_species.dart │ │ │ └── used_for.dart │ │ ├── router.dart │ │ ├── ui │ │ │ ├── colors.dart │ │ │ ├── screens │ │ │ │ ├── all_sightings.dart │ │ │ │ ├── all_species.dart │ │ │ │ ├── create_sighting.dart │ │ │ │ ├── settings.dart │ │ │ │ ├── sighting.dart │ │ │ │ ├── species.dart │ │ │ │ └── splash.dart │ │ │ └── widgets │ │ │ │ ├── action_buttons.dart │ │ │ │ ├── alert_dialog.dart │ │ │ │ ├── autocomplete.dart │ │ │ │ ├── button.dart │ │ │ │ ├── card.dart │ │ │ │ ├── card_action_button.dart │ │ │ │ ├── card_header.dart │ │ │ │ ├── confirm_dialog.dart │ │ │ │ ├── counter.dart │ │ │ │ ├── editable_card.dart │ │ │ │ ├── error_card.dart │ │ │ │ ├── expandable_card.dart │ │ │ │ ├── expandable_fab.dart │ │ │ │ ├── expansion_tile.dart │ │ │ │ ├── fab.dart │ │ │ │ ├── hive_location_field.dart │ │ │ │ ├── hive_locations_aggregate.dart │ │ │ │ ├── icon_message_card.dart │ │ │ │ ├── image.dart │ │ │ │ ├── image_carousel.dart │ │ │ │ ├── image_provider.dart │ │ │ │ ├── info_card.dart │ │ │ │ ├── loading_overlay.dart │ │ │ │ ├── local_name_autocomplete.dart │ │ │ │ ├── local_name_field.dart │ │ │ │ ├── local_names_dedup_tag_list.dart │ │ │ │ ├── location_tracker.dart │ │ │ │ ├── pagination_list.dart │ │ │ │ ├── read_only_value.dart │ │ │ │ ├── refresh_provider.dart │ │ │ │ ├── scaffold.dart │ │ │ │ ├── sighting_card.dart │ │ │ │ ├── sighting_popup_menu.dart │ │ │ │ ├── sightings_list.dart │ │ │ │ ├── sightings_tiles.dart │ │ │ │ ├── simple_card.dart │ │ │ │ ├── species_card.dart │ │ │ │ ├── species_field.dart │ │ │ │ ├── species_local_names_aggregate.dart │ │ │ │ ├── species_popup_menu.dart │ │ │ │ ├── species_uses_aggregate.dart │ │ │ │ ├── tag_item.dart │ │ │ │ ├── taxonomy_autocomplete.dart │ │ │ │ ├── text_field.dart │ │ │ │ ├── used_for_dedup_tag_list.dart │ │ │ │ ├── used_for_field.dart │ │ │ │ ├── used_for_list.dart │ │ │ │ ├── used_for_tag_selector.dart │ │ │ │ └── used_for_text_field.dart │ │ └── utils │ │ │ ├── debouncable.dart │ │ │ └── sleep.dart │ ├── pubspec.lock │ ├── pubspec.yaml │ └── pubspec_overrides.yaml ├── p2panda │ ├── .gitignore │ ├── analysis_options.yaml │ ├── lib │ │ ├── p2panda.dart │ │ └── src │ │ │ ├── bridge_generated.dart │ │ │ ├── bridge_generated.freezed.dart │ │ │ ├── ffi.dart │ │ │ └── ffi │ │ │ ├── io.dart │ │ │ ├── stub.dart │ │ │ └── web.dart │ ├── native │ │ ├── .gitignore │ │ ├── Cargo.toml │ │ ├── build.rs │ │ └── src │ │ │ ├── api.rs │ │ │ ├── bridge_generated.rs │ │ │ ├── lib.rs │ │ │ ├── node.rs │ │ │ └── operation.rs │ └── pubspec.yaml └── p2panda_flutter │ ├── .gitignore │ ├── .metadata │ ├── android │ ├── .gitignore │ ├── TLS_VERIFY │ ├── build.gradle │ ├── settings.gradle │ └── src │ │ └── main │ │ └── AndroidManifest.xml │ ├── lib │ ├── p2panda_flutter.dart │ └── src │ │ ├── ffi.dart │ │ └── ffi │ │ ├── io.dart │ │ ├── stub.dart │ │ └── web.dart │ ├── pubspec.yaml │ └── pubspec_overrides.yaml ├── pubspec.lock ├── pubspec.yaml ├── schemas ├── .gitignore ├── schema.lock ├── schema.toml └── seed.lock └── scripts ├── build.sh ├── clear.sh └── release.sh /.fvmrc: -------------------------------------------------------------------------------- 1 | { 2 | "flutter": "3.22.0" 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/build-android.yml: -------------------------------------------------------------------------------- 1 | name: Build Android APK 2 | 3 | on: 4 | push: 5 | tags: 6 | - v[0-9]+.[0-9]+.[0-9]+ 7 | 8 | workflow_dispatch: 9 | inputs: 10 | flavor: 11 | type: choice 12 | description: "APK build flavor" 13 | required: true 14 | options: 15 | - normal 16 | - qa 17 | default: qa 18 | 19 | jobs: 20 | build-apk: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: moonrepo/setup-rust@v1 25 | with: 26 | channel: "1.75" 27 | components: rustfmt 28 | - uses: actions/setup-java@v4 29 | with: 30 | distribution: "temurin" # AdoptOpenJDK is now Eclipse Temurin 31 | java-version: "17" 32 | - uses: kuhnroyal/flutter-fvm-config-action@v2 33 | id: fvm-config-action 34 | - name: Using Flutter ${{ steps.fvm-config-action.outputs.FLUTTER_VERSION }} 35 | uses: subosito/flutter-action@v2 36 | with: 37 | flutter-version: ${{ steps.fvm-config-action.outputs.FLUTTER_VERSION }} 38 | - uses: bluefireteam/melos-action@v3 39 | - name: Install Dart dependencies 40 | run: dart pub get 41 | - name: Generate FFI bindings and build native Android libraries 42 | run: melos build 43 | - name: Build APK 44 | working-directory: ./packages/app 45 | run: flutter build apk --flavor "${{ inputs.flavor }}" 46 | - uses: actions/upload-artifact@v4 47 | with: 48 | name: app-qa-release 49 | path: packages/app/build/app/outputs/flutter-apk/app-qa-release.apk 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | .vscode/ 12 | migrate_working_dir/ 13 | 14 | # IntelliJ 15 | *.iml 16 | *.ipr 17 | *.iws 18 | .idea/ 19 | 20 | # Flutter/Dart/Pub 21 | **/doc/api/ 22 | **/ios/Flutter/.last_build_id 23 | .dart_tool/ 24 | .flutter-plugins 25 | .flutter-plugins-dependencies 26 | .packages 27 | .pub-cache/ 28 | .pub/ 29 | /build/ 30 | .fvm/ 31 | 32 | # Rust 33 | /Cargo.lock 34 | /platform-build 35 | /target/ 36 | 37 | # Symbolication related 38 | app.*.symbols 39 | 40 | # Obfuscation related 41 | app.*.map.json 42 | 43 | # Android Studio will place build artifacts here 44 | /android/app/debug 45 | /android/app/profile 46 | /android/app/release 47 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["packages/p2panda/native"] 3 | resolver = "2" 4 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:flutter_lints/flutter.yaml 2 | 3 | analyzer: 4 | language: 5 | strict-casts: true 6 | strict-inference: true 7 | strict-raw-types: true 8 | 9 | errors: 10 | missing_required_param: error 11 | missing_return: error 12 | record_literal_one_positional_no_trailing_comma: error 13 | 14 | exclude: 15 | - '**.freezed.dart' 16 | - '**.g.dart' 17 | 18 | linter: 19 | rules: 20 | # Allow good old UPPER_CASE_CONSTANT_VARIABLE_NAMES 21 | constant_identifier_names: false 22 | non_constant_identifier_names: false 23 | 24 | # We dont want to use a logging framework (yet) 25 | avoid_print: false 26 | -------------------------------------------------------------------------------- /documentation/app.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/documentation/app.gif -------------------------------------------------------------------------------- /documentation/scan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/documentation/scan.png -------------------------------------------------------------------------------- /melos.yaml: -------------------------------------------------------------------------------- 1 | name: p2panda 2 | repository: https://github.com/p2panda/meli 3 | 4 | packages: 5 | - packages/* 6 | 7 | scripts: 8 | analyze: 9 | exec: flutter analyze . 10 | description: Analyze all Dart packages for code errors and warnings. 11 | 12 | format: 13 | exec: dart format . 14 | description: Format all Dart packages according to linter settings. 15 | 16 | release: 17 | run: bash scripts/release.sh 18 | packageFilters: 19 | flutter: true 20 | scope: "*app*" 21 | description: Compile .apk builds for all architectures 22 | 23 | build: 24 | run: bash scripts/build.sh 25 | description: Compile native p2panda library for Android. 26 | 27 | clear: 28 | run: bash scripts/clear.sh 29 | description: Remove artifacts which got created after building. 30 | -------------------------------------------------------------------------------- /packages/app/.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | migrate_working_dir/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | **/doc/api/ 26 | **/ios/Flutter/.last_build_id 27 | .dart_tool/ 28 | .flutter-plugins 29 | .flutter-plugins-dependencies 30 | .packages 31 | .pub-cache/ 32 | .pub/ 33 | /build/ 34 | 35 | # Symbolication related 36 | app.*.symbols 37 | 38 | # Obfuscation related 39 | app.*.map.json 40 | 41 | # Android Studio will place build artifacts here 42 | /android/app/debug 43 | /android/app/profile 44 | /android/app/release 45 | -------------------------------------------------------------------------------- /packages/app/.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled. 5 | 6 | version: 7 | revision: 796c8ef79279f9c774545b3771238c3098dbefab 8 | channel: stable 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: 796c8ef79279f9c774545b3771238c3098dbefab 17 | base_revision: 796c8ef79279f9c774545b3771238c3098dbefab 18 | - platform: android 19 | create_revision: 796c8ef79279f9c774545b3771238c3098dbefab 20 | base_revision: 796c8ef79279f9c774545b3771238c3098dbefab 21 | 22 | # User provided section 23 | 24 | # List of Local paths (relative to this file) that should be 25 | # ignored by the migrate tool. 26 | # 27 | # Files that are not part of the templates will be ignored by default. 28 | unmanaged_files: 29 | - 'lib/main.dart' 30 | - 'ios/Runner.xcodeproj/project.pbxproj' 31 | -------------------------------------------------------------------------------- /packages/app/android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app 11 | key.properties 12 | **/*.keystore 13 | **/*.jks 14 | -------------------------------------------------------------------------------- /packages/app/android/app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "com.android.application" 3 | id "kotlin-android" 4 | id "dev.flutter.flutter-gradle-plugin" 5 | } 6 | 7 | def localProperties = new Properties() 8 | def localPropertiesFile = rootProject.file('local.properties') 9 | if (localPropertiesFile.exists()) { 10 | localPropertiesFile.withReader('UTF-8') { reader -> 11 | localProperties.load(reader) 12 | } 13 | } 14 | 15 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 16 | if (flutterVersionCode == null) { 17 | flutterVersionCode = '1' 18 | } 19 | 20 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 21 | if (flutterVersionName == null) { 22 | flutterVersionName = '1.0' 23 | } 24 | 25 | android { 26 | namespace "com.p2panda.meli" 27 | 28 | // Set to minimum version 33 for `geolocator` package 29 | compileSdkVersion Math.max(flutter.compileSdkVersion, 33) 30 | 31 | // Pin NDK version to assure compatibility with cargo-ndk build (see 32 | // "packages/p2panda/native/build.rs" file for details) 33 | ndkVersion "25.2.9519653" 34 | 35 | compileOptions { 36 | sourceCompatibility JavaVersion.VERSION_17 37 | targetCompatibility JavaVersion.VERSION_17 38 | } 39 | 40 | kotlinOptions { 41 | jvmTarget = '1.8' 42 | } 43 | 44 | sourceSets { 45 | main.java.srcDirs += 'src/main/kotlin' 46 | } 47 | 48 | defaultConfig { 49 | applicationId "org.p2panda.meli" 50 | minSdkVersion 23 51 | targetSdkVersion flutter.targetSdkVersion 52 | versionCode flutterVersionCode.toInteger() 53 | versionName flutterVersionName 54 | manifestPlaceholders = [applicationLabel: "Meli"] 55 | } 56 | 57 | buildTypes { 58 | debug { 59 | applicationIdSuffix ".debug" 60 | debuggable true 61 | manifestPlaceholders = [applicationLabel: "Meli (Debug)"] 62 | } 63 | profile { 64 | applicationIdSuffix ".profile" 65 | debuggable true 66 | manifestPlaceholders = [applicationLabel: "Meli (Profile)"] 67 | } 68 | release { 69 | // We do not intend to upload the .apk to any "App Store" and just 70 | // sign with the debug keys 71 | signingConfig signingConfigs.debug 72 | } 73 | } 74 | 75 | flavorDimensions "appType" 76 | 77 | productFlavors { 78 | normal { 79 | dimension "appType" 80 | } 81 | qa { 82 | dimension "appType" 83 | applicationIdSuffix ".qa" 84 | manifestPlaceholders = [applicationLabel: "Meli (QA)"] 85 | } 86 | } 87 | 88 | variantFilter { variant -> 89 | // Skip qa flavor for debug and profile builds 90 | def names = variant.flavors*.name 91 | if ( 92 | names.contains('qa') && 93 | ( 94 | variant.buildType.name == "debug" || 95 | variant.buildType.name == "profile" 96 | ) 97 | ) { 98 | setIgnore(true) 99 | } 100 | } 101 | } 102 | 103 | flutter { 104 | source '../..' 105 | } 106 | 107 | dependencies {} 108 | -------------------------------------------------------------------------------- /packages/app/android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /packages/app/android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 17 | 21 | 25 | 28 | 32 | 33 | 34 | 35 | 36 | 37 | 39 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /packages/app/android/app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/packages/app/android/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /packages/app/android/app/src/main/kotlin/com/p2panda/app/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.p2panda.meli 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() { 6 | } 7 | -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/drawable-v21/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/packages/app/android/app/src/main/res/drawable-v21/background.png -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/drawable/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/packages/app/android/app/src/main/res/drawable/background.png -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/packages/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/packages/app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/packages/app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/packages/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/packages/app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/packages/app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/packages/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/packages/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/packages/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/packages/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/packages/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/packages/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/packages/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/packages/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/packages/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/values-night-v31/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 17 | 20 | 21 | -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 19 | 22 | 23 | -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/values-v31/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 17 | 20 | 21 | -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFFFFF 4 | -------------------------------------------------------------------------------- /packages/app/android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 19 | 22 | 23 | -------------------------------------------------------------------------------- /packages/app/android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /packages/app/android/build.gradle: -------------------------------------------------------------------------------- 1 | allprojects { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | } 6 | } 7 | 8 | rootProject.buildDir = '../build' 9 | subprojects { 10 | project.buildDir = "${rootProject.buildDir}/${project.name}" 11 | } 12 | subprojects { 13 | project.evaluationDependsOn(':app') 14 | } 15 | 16 | tasks.register("clean", Delete) { 17 | delete rootProject.buildDir 18 | } 19 | -------------------------------------------------------------------------------- /packages/app/android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | 3 | # We need this for the `geolocator` package 4 | android.useAndroidX=true 5 | android.enableJetifier=true 6 | -------------------------------------------------------------------------------- /packages/app/android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | zipStoreBase=GRADLE_USER_HOME 4 | zipStorePath=wrapper/dists 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip 6 | -------------------------------------------------------------------------------- /packages/app/android/settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | def flutterSdkPath = { 3 | def properties = new Properties() 4 | file("local.properties").withInputStream { properties.load(it) } 5 | def flutterSdkPath = properties.getProperty("flutter.sdk") 6 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 7 | return flutterSdkPath 8 | }() 9 | 10 | includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") 11 | 12 | repositories { 13 | google() 14 | mavenCentral() 15 | gradlePluginPortal() 16 | } 17 | } 18 | 19 | plugins { 20 | id "dev.flutter.flutter-plugin-loader" version "1.0.0" 21 | id "com.android.application" version "7.3.0" apply false 22 | id "org.jetbrains.kotlin.android" version "1.9.0" apply false 23 | } 24 | 25 | include ":app" 26 | -------------------------------------------------------------------------------- /packages/app/assets/fonts/Staatliches-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/packages/app/assets/fonts/Staatliches-Regular.ttf -------------------------------------------------------------------------------- /packages/app/assets/images/meliponini.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/packages/app/assets/images/meliponini.png -------------------------------------------------------------------------------- /packages/app/assets/images/meliponini.svg: -------------------------------------------------------------------------------- 1 | 2 | 49 | -------------------------------------------------------------------------------- /packages/app/assets/images/placeholder-bee.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/packages/app/assets/images/placeholder-bee.png -------------------------------------------------------------------------------- /packages/app/assets/schema.lock: -------------------------------------------------------------------------------- 1 | ../../../schemas/schema.lock -------------------------------------------------------------------------------- /packages/app/l10n.yaml: -------------------------------------------------------------------------------- 1 | arb-dir: lib/locales 2 | template-arb-file: app_en.arb 3 | output-localization-file: app_localizations.dart -------------------------------------------------------------------------------- /packages/app/lib/app.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 5 | import 'package:graphql_flutter/graphql_flutter.dart'; 6 | import 'package:shared_preferences/shared_preferences.dart'; 7 | 8 | import 'package:app/io/graphql/graphql.dart' as graphql; 9 | import 'package:app/router.dart'; 10 | import 'package:app/ui/widgets/image_provider.dart'; 11 | import 'package:app/ui/widgets/refresh_provider.dart'; 12 | 13 | class MeliApp extends StatefulWidget { 14 | const MeliApp({super.key}); 15 | 16 | @override 17 | State createState() => MeliAppState(); 18 | } 19 | 20 | class MeliAppState extends State { 21 | final Future _prefs = SharedPreferences.getInstance(); 22 | Locale? _locale; 23 | 24 | @override 25 | void initState() { 26 | super.initState(); 27 | 28 | // Set locale to user setting if given 29 | _prefs.then((SharedPreferences prefs) { 30 | final String? localString = prefs.getString('locale'); 31 | if (localString != null) { 32 | setState(() { 33 | _locale = Locale(localString); 34 | }); 35 | } 36 | }); 37 | } 38 | 39 | Future changeLocale(String languageCode) async { 40 | final SharedPreferences prefs = await _prefs; 41 | bool success = await prefs.setString('locale', languageCode.toString()); 42 | 43 | if (success) { 44 | setState(() { 45 | _locale = Locale(languageCode); 46 | }); 47 | } 48 | 49 | return success; 50 | } 51 | 52 | @override 53 | Widget build(BuildContext context) { 54 | ValueNotifier client = ValueNotifier(graphql.client); 55 | 56 | return GraphQLProvider( 57 | client: client, 58 | child: RefreshProvider( 59 | child: MeliCameraProvider(MaterialApp.router( 60 | // Register router for navigation 61 | routerDelegate: router.routerDelegate, 62 | routeInformationProvider: router.routeInformationProvider, 63 | routeInformationParser: router.routeInformationParser, 64 | 65 | // Setup localization 66 | locale: _locale, 67 | localeListResolutionCallback: (locales, supportedLocales) { 68 | // Check if we can fullfil the preferred locale of the device OS 69 | // ordered by priority 70 | if (locales != null) { 71 | final supportedLanguageCodes = 72 | supportedLocales.map((supportedLocale) { 73 | return supportedLocale.languageCode; 74 | }); 75 | 76 | for (var locale in locales) { 77 | if (supportedLanguageCodes.contains(locale.languageCode)) { 78 | return locale; 79 | } 80 | } 81 | } 82 | 83 | return const Locale('pt'); 84 | }, 85 | localizationsDelegates: AppLocalizations.localizationsDelegates, 86 | supportedLocales: AppLocalizations.supportedLocales, 87 | 88 | // Material theme configuration 89 | theme: ThemeData(useMaterial3: true), 90 | 91 | // Disable "debug" banner shown in top right corner during development 92 | debugShowCheckedModeBanner: false, 93 | )))); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /packages/app/lib/io/assets.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/services.dart' show rootBundle; 4 | 5 | Future loadAsset(String path) async { 6 | return await rootBundle.loadString(path); 7 | } 8 | -------------------------------------------------------------------------------- /packages/app/lib/io/files.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:path_provider/path_provider.dart' as provider; 4 | 5 | /// Blobs base path. 6 | const String BLOBS_BASE_PATH = 'http://localhost:2020/blobs'; 7 | 8 | Future get applicationSupportDirectory async { 9 | final directory = await provider.getApplicationSupportDirectory(); 10 | return directory.path; 11 | } 12 | 13 | Future get temporaryDirectory async { 14 | final directory = await provider.getTemporaryDirectory(); 15 | return directory.path; 16 | } 17 | -------------------------------------------------------------------------------- /packages/app/lib/io/geolocation.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'dart:async'; 4 | 5 | import 'package:geolocator/geolocator.dart'; 6 | 7 | /// Throw exception after waiting for x seconds. 8 | const TIMEOUT_DURATION = Duration(seconds: 30); 9 | 10 | /// Determine the current position of the device. 11 | /// 12 | /// When the location services are not enabled or permissions are denied the 13 | /// `Future` will return an error. 14 | Future determinePosition() async { 15 | bool serviceEnabled; 16 | LocationPermission permission; 17 | 18 | // Test if location services are enabled. 19 | serviceEnabled = await Geolocator.isLocationServiceEnabled(); 20 | if (!serviceEnabled) { 21 | // Location services are not enabled don't continue accessing the position 22 | // and request users of the App to enable the location services. 23 | throw const LocationServiceDisabledException(); 24 | } 25 | 26 | permission = await Geolocator.checkPermission(); 27 | if (permission == LocationPermission.denied) { 28 | permission = await Geolocator.requestPermission(); 29 | if (permission == LocationPermission.denied) { 30 | // Permissions are denied, next time you could try requesting permissions 31 | // again (this is also where Android's shouldShowRequestPermissionRationale 32 | // returned true. According to Android guidelines your App should show an 33 | // explanatory UI now. 34 | throw const PermissionDeniedException('Permission was denied'); 35 | } 36 | } 37 | 38 | if (permission == LocationPermission.deniedForever) { 39 | // Permissions are denied forever, handle appropriately. 40 | throw const PermissionDeniedException('Permission is permamently denied'); 41 | } 42 | 43 | // When we reach here, permissions are granted and we can 44 | // continue accessing the position of the device. 45 | return await Geolocator.getCurrentPosition(timeLimit: TIMEOUT_DURATION); 46 | } 47 | -------------------------------------------------------------------------------- /packages/app/lib/io/graphql/graphql.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:graphql/client.dart'; 4 | 5 | /// GraphQL endpoint URL. 6 | const String HTTP_ENDPOINT = 'http://localhost:2020/graphql'; 7 | 8 | final policies = Policies( 9 | fetch: FetchPolicy.networkOnly, 10 | ); 11 | 12 | /// GraphQL client making requests against our locally hosted node API. 13 | final GraphQLClient client = GraphQLClient( 14 | link: HttpLink(HTTP_ENDPOINT), 15 | cache: GraphQLCache(), 16 | // Disable caching by overriding default policies. See: 17 | // https://github.com/zino-hofmann/graphql-flutter/issues/692 18 | defaultPolicies: DefaultPolicies( 19 | watchQuery: policies, 20 | query: policies, 21 | mutate: policies, 22 | ), 23 | ); 24 | -------------------------------------------------------------------------------- /packages/app/lib/io/graphql/queries.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:graphql/client.dart'; 4 | 5 | import 'package:app/io/graphql/graphql.dart'; 6 | 7 | /// GraphQL mutation to publish entries and operations with. 8 | const String PUBLISH_MUTATION = r''' 9 | mutation Publish($entry: String!, $operation: String!) { 10 | publish(entry: $entry, operation: $operation) { 11 | logId 12 | seqNum 13 | backlink 14 | skiplink 15 | } 16 | } 17 | '''; 18 | 19 | /// GraphQL query to retrieve arguments which are required for the creation 20 | /// of the next entry. 21 | const String NEXT_ARGS_QUERY = r''' 22 | query NextArgs($publicKey: String!, $viewId: String!) { 23 | nextArgs(publicKey: $publicKey, viewId: $viewId) { 24 | logId 25 | seqNum 26 | backlink 27 | skiplink 28 | } 29 | } 30 | '''; 31 | 32 | /// Arguments returned from node which are required for the creation of the 33 | /// next entry. 34 | class NextArgs { 35 | final BigInt logId; 36 | final BigInt seqNum; 37 | final String? backlink; 38 | final String? skiplink; 39 | 40 | NextArgs( 41 | {required this.logId, 42 | required this.seqNum, 43 | this.backlink, 44 | this.skiplink}); 45 | } 46 | 47 | /// Helper method to convert GraphQL response to `NextArgs` instance. 48 | NextArgs _toNextArgs(dynamic data) { 49 | // Large integers like `logId` and `seqNum` are coming as strings from the 50 | // GraphQL JSON response as u64 is not supported in JavaScript 51 | return NextArgs( 52 | logId: BigInt.parse(data['logId'] as String), 53 | seqNum: BigInt.parse(data['seqNum'] as String), 54 | backlink: data['backlink'] as String?, 55 | skiplink: data['skiplink'] as String?); 56 | } 57 | 58 | Future> query( 59 | {required String query, Map variables = const {}}) async { 60 | final options = QueryOptions(document: gql(query), variables: variables); 61 | final result = await client.query(options); 62 | 63 | if (result.hasException) { 64 | throw Exception(result.exception); 65 | } 66 | 67 | return result.data as Map; 68 | } 69 | 70 | /// Sends a GraphQL `publish` mutation to the node. 71 | Future publish(String entry, String operation) async { 72 | final data = await query(query: PUBLISH_MUTATION, variables: { 73 | 'entry': entry, 74 | 'operation': operation, 75 | }); 76 | 77 | return _toNextArgs(data['publish']); 78 | } 79 | 80 | /// Sends a GraphQL `nextArgs` query to the node. 81 | Future nextArgs(String publicKey, String? viewId) async { 82 | final data = await query(query: NEXT_ARGS_QUERY, variables: { 83 | 'publicKey': publicKey, 84 | 'viewId': viewId, 85 | }); 86 | 87 | return _toNextArgs(data['nextArgs']); 88 | } 89 | -------------------------------------------------------------------------------- /packages/app/lib/io/images.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'dart:io'; 4 | 5 | import 'package:flutter_image_compress/flutter_image_compress.dart'; 6 | import 'package:http/http.dart' as http; 7 | import 'package:uuid/uuid.dart'; 8 | 9 | import 'package:app/io/files.dart'; 10 | 11 | const IMAGE_QUALITY = 80; 12 | const IMAGE_MAX_WIDTH = 1920; 13 | const IMAGE_MAX_HEIGHT = 1080; 14 | 15 | Future removeExifAndCompress(File file) async { 16 | final temporaryDirPath = await temporaryDirectory; 17 | final uuid = const Uuid().v1().toString(); 18 | 19 | final result = await FlutterImageCompress.compressAndGetFile( 20 | file.path, 21 | "$temporaryDirPath/$uuid.jpg", 22 | quality: IMAGE_QUALITY, 23 | minWidth: IMAGE_MAX_WIDTH, 24 | minHeight: IMAGE_MAX_HEIGHT, 25 | keepExif: false, 26 | format: CompressFormat.jpeg, 27 | ); 28 | 29 | if (result == null) { 30 | throw "Processing image failed ${file.path}"; 31 | } 32 | 33 | return File(result.path); 34 | } 35 | 36 | Future downloadAndExportImages( 37 | List blobIds, String targetDirectory) async { 38 | for (var id in blobIds) { 39 | final http.Response response = 40 | await http.get(Uri.parse("$BLOBS_BASE_PATH/$id")); 41 | final file = File("$targetDirectory/$id.jpg"); 42 | await file.writeAsBytes(response.bodyBytes); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/app/lib/io/p2panda/documents.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:graphql_flutter/graphql_flutter.dart'; 4 | 5 | import 'package:app/io/graphql/graphql.dart'; 6 | import 'package:app/io/p2panda/publish.dart'; 7 | import 'package:app/io/p2panda/schemas.dart'; 8 | import 'package:app/utils/sleep.dart'; 9 | 10 | /// Returns true if document is materialized with the specified view id. 11 | Future isDocumentViewAvailable( 12 | SchemaId schemaId, DocumentViewId viewId) async { 13 | String query = ''' 14 | query CheckDocumentStatus() { 15 | status: all_$schemaId(meta: { viewId: { eq: "$viewId" } }) { 16 | totalCount 17 | } 18 | } 19 | '''; 20 | 21 | final options = QueryOptions(document: gql(query)); 22 | final result = await client.query(options); 23 | 24 | if (result.hasException) { 25 | throw "Error while querying if document view was materialized on node"; 26 | } 27 | 28 | final status = result.data?['status'] as Map; 29 | return status['totalCount'] == 1; 30 | } 31 | 32 | /// Async helper method to block until node materialized document to a 33 | /// specific view id. 34 | Future untilDocumentViewAvailable( 35 | SchemaId schemaId, DocumentViewId viewId) async { 36 | while (true) { 37 | if (await isDocumentViewAvailable(schemaId, viewId)) { 38 | break; 39 | } else { 40 | sleep(250); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/app/lib/io/p2panda/key_pair.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'dart:io' as io; 4 | import 'dart:typed_data'; 5 | 6 | import 'package:convert/convert.dart'; 7 | import 'package:p2panda_flutter/p2panda_flutter.dart'; 8 | import 'package:path/path.dart' as path; 9 | 10 | import 'package:app/io/files.dart'; 11 | import 'package:app/io/p2panda/p2panda.dart'; 12 | 13 | /// File where private key is stored. 14 | const String PRIVATE_KEY_FILE_NAME = 'private.key'; 15 | 16 | /// Singleton workaround to only load / generate key pair once per runtime. 17 | KeyPair? _keyPairSingleton; 18 | 19 | /// Getter to receive an Ed25519 key pair. 20 | /// 21 | /// If no key pair exists yet, it will automatically be generated and stored to 22 | /// the Android file system. 23 | Future get keyPair async { 24 | if (_keyPairSingleton != null) { 25 | return _keyPairSingleton!; 26 | } 27 | 28 | // Determine folder to load private key file from 29 | final basePath = await applicationSupportDirectory; 30 | final filePath = path.join(basePath, PRIVATE_KEY_FILE_NAME); 31 | 32 | final io.File file = io.File(filePath); 33 | 34 | // Load private key from existing file or generate a new one 35 | if (file.existsSync()) { 36 | final bytes = await file.readAsBytes(); 37 | _keyPairSingleton = 38 | await p2panda.fromPrivateKeyStaticMethodKeyPair(bytes: bytes); 39 | } else { 40 | _keyPairSingleton = await p2panda.newStaticMethodKeyPair(); 41 | final bytes = await _keyPairSingleton!.privateKey(); 42 | await file.writeAsBytes(bytes); 43 | } 44 | 45 | return _keyPairSingleton!; 46 | } 47 | 48 | /// Returns public key as bytes. 49 | Future get publicKey async { 50 | final key = await keyPair; 51 | return await key.publicKey(); 52 | } 53 | 54 | /// Returns public key encoded as hexadecimal string. 55 | Future get publicKeyHex async { 56 | final key = await publicKey; 57 | return hex.encode(key); 58 | } 59 | -------------------------------------------------------------------------------- /packages/app/lib/io/p2panda/node.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:app/io/files.dart'; 4 | import 'package:app/io/graphql/queries.dart'; 5 | import 'package:app/io/p2panda/key_pair.dart'; 6 | import 'package:app/io/p2panda/p2panda.dart'; 7 | import 'package:app/models/schema_ids.dart'; 8 | import 'package:app/utils/sleep.dart'; 9 | 10 | const List relayAddresses = (bool.hasEnvironment("RELAY_ADDRESS") && 11 | String.fromEnvironment("RELAY_ADDRESS") != "") 12 | ? [String.fromEnvironment("RELAY_ADDRESS")] 13 | : []; 14 | 15 | const String preSharedSecret = String.fromEnvironment("PSK", defaultValue: ""); 16 | 17 | /// Start a p2panda node in the background. 18 | Future startNode() async { 19 | // Determine folder where we can persist data 20 | final basePath = await applicationSupportDirectory; 21 | 22 | // Set up SQLite database file inside of persisted phone directory 23 | final databaseUrl = 'sqlite:/$basePath/db.sqlite3'; 24 | 25 | // Re-use client's key pair also for node. Note that during networking the 26 | // peer id will be a hashed version of the public key and it will not leak 27 | final key = await keyPair; 28 | 29 | // Start node in background thread 30 | p2panda.startNode( 31 | keyPair: key, 32 | preSharedSecret: preSharedSecret, 33 | databaseUrl: databaseUrl, 34 | blobsBasePath: basePath, 35 | relayAddresses: relayAddresses, 36 | allowSchemaIds: ALL_SCHEMA_IDS, 37 | ); 38 | 39 | // .. since we can't `await` the FFI binding method from Rust we need to 40 | // poll here to find out until the node is ready 41 | await _untilReady(); 42 | } 43 | 44 | /// Shut down p2panda node. 45 | Future shutdownNode() async { 46 | await p2panda.shutdownNode(); 47 | } 48 | 49 | /// Async helper method to block until node is up and running. 50 | Future _untilReady() async { 51 | final publicKey = await publicKeyHex; 52 | 53 | while (true) { 54 | try { 55 | // Send a simple GraphQL request to find out if node responds 56 | await nextArgs(publicKey, null); 57 | 58 | // If we got a response (no exception) we can stop here 59 | break; 60 | } catch (err) { 61 | // Ignore thrown exceptions and keep on trying again after a while 62 | await sleep(250); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/app/lib/io/p2panda/p2panda.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:p2panda_flutter/p2panda_flutter.dart'; 4 | 5 | /// Create p2panda library originating from Rust API via FFI bindings. 6 | /// 7 | /// The APIs coming out of this are undogmatic for Dart and look sometimes a bit 8 | /// strange. With wrapper methods in this module we try to make them slightly 9 | /// more ergonomic. 10 | final P2Panda p2panda = createLib(); 11 | -------------------------------------------------------------------------------- /packages/app/lib/io/p2panda/publish.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:convert/convert.dart'; 4 | import 'package:p2panda_flutter/p2panda_flutter.dart'; 5 | 6 | import 'package:app/io/graphql/queries.dart' as queries; 7 | import 'package:app/io/p2panda/key_pair.dart'; 8 | import 'package:app/io/p2panda/p2panda.dart'; 9 | import 'package:app/io/p2panda/schemas.dart'; 10 | 11 | /// Name of an operation field. 12 | typedef FieldName = String; 13 | 14 | /// List of operation fields, representing actual application data. 15 | typedef OperationFields = List<(FieldName, OperationValue)>; 16 | 17 | /// Document view id represented as a string. 18 | typedef DocumentViewId = String; 19 | 20 | /// Document id represented as a string. 21 | typedef DocumentId = String; 22 | 23 | /// Generates and publishes a CREATE operation on the p2panda node. 24 | Future create(SchemaId schemaId, OperationFields fields) async { 25 | return await _publish(OperationAction.Create, schemaId, fields, null); 26 | } 27 | 28 | /// Generates and publishes an UPDATE operation on the p2panda node. 29 | Future update( 30 | SchemaId schemaId, DocumentViewId previous, OperationFields fields) async { 31 | return await _publish(OperationAction.Update, schemaId, fields, previous); 32 | } 33 | 34 | /// Generates and publishes a DELETE operation on the p2panda node. 35 | Future delete( 36 | SchemaId schemaId, DocumentViewId previous) async { 37 | return await _publish(OperationAction.Delete, schemaId, null, previous); 38 | } 39 | 40 | /// Internal method to publish a p2panda operation and entry on the node. 41 | /// 42 | /// This method automatically retreives the required entry arguments from 43 | /// the node, encodes and signs all data correctly and sends it off via 44 | /// GraphQL. 45 | Future _publish(OperationAction action, SchemaId schemaId, 46 | OperationFields? fields, DocumentViewId? previous) async { 47 | // Create and encode p2panda operation 48 | final encodedOperation = await p2panda.encodeOperation( 49 | action: action, schemaId: schemaId, fields: fields, previous: previous); 50 | 51 | // Get arguments to create p2panda entry from node 52 | final publicKey = await publicKeyHex; 53 | final nextArgs = await queries.nextArgs(publicKey, previous); 54 | 55 | // Create and sign p2panda entry with key pair and received arguments 56 | final encodedEntry = await p2panda.signAndEncodeEntry( 57 | logId: nextArgs.logId.toString(), 58 | seqNum: nextArgs.seqNum.toString(), 59 | backlinkHash: nextArgs.backlink, 60 | skiplinkHash: nextArgs.skiplink, 61 | payload: encodedOperation, 62 | keyPair: await keyPair); 63 | 64 | // ... finally publish entry and operation 65 | final result = await queries.publish( 66 | hex.encode(encodedEntry), hex.encode(encodedOperation)); 67 | 68 | // Return last document view id 69 | return result.backlink!; 70 | } 71 | -------------------------------------------------------------------------------- /packages/app/lib/io/p2panda/schemas.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'dart:typed_data'; 4 | 5 | import 'package:graphql_flutter/graphql_flutter.dart'; 6 | import 'package:toml/toml.dart'; 7 | import 'package:convert/convert.dart'; 8 | 9 | import 'package:app/io/assets.dart'; 10 | import 'package:app/io/graphql/graphql.dart'; 11 | import 'package:app/io/graphql/queries.dart' as queries; 12 | import 'package:app/io/p2panda/p2panda.dart'; 13 | import 'package:app/utils/sleep.dart'; 14 | 15 | /// Path to .toml file holding all data for schema migrations. 16 | const String MIGRATION_FILE_PATH = 'assets/schema.lock'; 17 | 18 | /// p2panda schema identifier represented as a string. 19 | typedef SchemaId = String; 20 | 21 | /// Checks for pending migrations of p2panda schemas and automatically deploys 22 | /// them on the node. 23 | /// 24 | /// This is especially useful for first-start runtimes which need to establish 25 | /// all schemas first before we can create data based on them. 26 | /// 27 | /// For convenience this method takes its migration data from a `schema.lock` 28 | /// file which was generated with the `fishy` tool. To update the schemas one 29 | /// needs to just check update this file with `fishy`, this method will do the 30 | /// rest. 31 | Future migrateSchemas() async { 32 | // Load .toml file holding the migration data which was generated with 33 | // p2panda `fishy` tool 34 | final toml = await loadAsset(MIGRATION_FILE_PATH); 35 | final migration = TomlDocument.parse(toml).toMap(); 36 | final commits = migration['commits'] as List; 37 | return await publishCommits(commits); 38 | } 39 | 40 | Future publishCommits(List commits) async { 41 | // Flag to indicate if any publishing took place 42 | bool didPublish = false; 43 | 44 | // Iterate over all commits which are required to migrate to the latest 45 | // version. This loop automatically checks if the commit already took place 46 | // and ignores them if so 47 | for (var commit in commits) { 48 | // Decode entry from commit to retrieve public key, sequence number and log 49 | // id from it 50 | final Uint8List entryBytes = 51 | hex.decode(commit['entry'] as String) as Uint8List; 52 | final entry = await p2panda.decodeEntry(entry: entryBytes); 53 | String publicKey = entry.$1; 54 | BigInt logId = BigInt.parse(entry.$2); 55 | BigInt seqNum = BigInt.parse(entry.$3); 56 | 57 | try { 58 | // Check if node already knows about this entry 59 | final nextArgs = 60 | await queries.nextArgs(publicKey, commit['entry_hash'] as String); 61 | 62 | if (logId != nextArgs.logId) { 63 | throw Exception('Critical log id mismatch during migration'); 64 | } 65 | 66 | // Entry already exists, we can ignore this commit 67 | if (seqNum < nextArgs.seqNum) { 68 | continue; 69 | } 70 | } catch (err) { 71 | // Ignore when query fails, it might happen when the entry was not 72 | // published yet 73 | } 74 | 75 | // Publish commit to node, this will materialize the (updated) schema on 76 | // the node and give us a new GraphQL API 77 | await queries.publish( 78 | commit['entry'] as String, commit['operation'] as String); 79 | didPublish = true; 80 | } 81 | 82 | return didPublish; 83 | } 84 | 85 | /// Returns true if schema is materialized and ready on node. 86 | Future isSchemaAvailable(SchemaId schemaId) async { 87 | String query = ''' 88 | query CheckSchemaStatus() { 89 | status: all_$schemaId { 90 | documents { 91 | meta { 92 | documentId 93 | } 94 | } 95 | } 96 | } 97 | '''; 98 | 99 | final options = QueryOptions(document: gql(query)); 100 | final result = await client.query(options); 101 | return !result.hasException; 102 | } 103 | 104 | /// Async helper method to block until node materialized schemas and updated 105 | /// GraphQL API. 106 | Future untilSchemasAvailable(List schemaIds) async { 107 | // Iterate over all required schema ids and check if they already exist 108 | Future areAllSchemasAvailable() async { 109 | for (var schemaId in schemaIds) { 110 | if (!(await isSchemaAvailable(schemaId))) { 111 | return false; 112 | } 113 | } 114 | 115 | return true; 116 | } 117 | 118 | // .. do this until all of them exist 119 | while (true) { 120 | if (await areAllSchemasAvailable()) { 121 | break; 122 | } else { 123 | sleep(250); 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /packages/app/lib/io/p2panda/seed.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:toml/toml.dart'; 4 | 5 | import 'package:app/io/assets.dart'; 6 | import 'package:app/io/p2panda/schemas.dart'; 7 | 8 | /// Path to .toml file holding all data for database seeds. 9 | const String SEED_FILE_PATH = 'assets/seed.lock'; 10 | 11 | Future seedDatabase() async { 12 | // Load .toml file holding the migration data which was generated with 13 | // p2panda `fishy` tool 14 | final toml = await loadAsset(SEED_FILE_PATH); 15 | final migration = TomlDocument.parse(toml).toMap(); 16 | final commits = migration['commits'] as List; 17 | return await publishCommits(commits); 18 | } 19 | -------------------------------------------------------------------------------- /packages/app/lib/locales/app_en.arb: -------------------------------------------------------------------------------- 1 | { 2 | "actionCancelButton": "Cancel", 3 | "actionDefaultButton": "Save", 4 | "allSpeciesScreenTitle": "All Species", 5 | "createSightingError": "Something went wrong: {error}", 6 | "createSightingScreenTitle": "Add Sighting", 7 | "createSightingSuccess": "Yay, you've successfully added a new sighting to the database", 8 | "createSpeciesSuccess": "Added new species \"{taxon}\" to database!", 9 | "deleteSighting": "Delete Sighting", 10 | "deleteSpecies": "Delete Species", 11 | "editSpeciesErrorCantRemove": "Species needs to be defined", 12 | "editSpeciesErrorConfirm": "Ok", 13 | "editSpeciesErrorInvalidRankCase": "Rank \"{rank}\" can not be changed!", 14 | "editSpeciesErrorMessage": "{error}", 15 | "editSpeciesErrorNullCase": "Rank \"{rank}\" needs to be specified!", 16 | "editSpeciesErrorTitle": "Could not save species", 17 | "exportImages": "Export Images", 18 | "hiveLocationBox": "Box", 19 | "hiveLocationBuilding": "Building", 20 | "hiveLocationCardTitle": "Location of Hive", 21 | "hiveLocationGround": "Ground", 22 | "hiveLocationTree": "Tree", 23 | "hiveLocationTreeDiameter": "Diameter", 24 | "hiveLocationTreeHeight": "Height", 25 | "hiveLocationTreeSpecies": "Tree Species", 26 | "hiveLocationTreeSpeciesHint": "Enter tree species here", 27 | "hiveLocationsAggregateNoData": "No data available", 28 | "hiveLocationsAggregateTreeDiameter": "Diameter: {diameter}", 29 | "hiveLocationsAggregateTreeHeight": "Height: {height}", 30 | "hiveLocationsAggregateTreeSpecies": "Tree Species: {list}", 31 | "imageDeleteAlertBody": "Are you sure you want to delete this image?", 32 | "imageDeleteAlertCancel": "No", 33 | "imageDeleteAlertConfirm": "Yes", 34 | "imageDeleteAlertTitle": "Delete Image", 35 | "imageDeleteConfirmation": "Image deleted.", 36 | "imageLoadingError": "Could not load image", 37 | "imageMissingError": "No image given", 38 | "localNameCardSaveAction": "Save", 39 | "localNameCardTitle": "Local Name", 40 | "localNamesCardTitle": "Local Names", 41 | "locationAdd": "Do you want to add a GPS location?", 42 | "locationAddAction": "Add location to image", 43 | "locationErrorPermissionDenied": "Location permission was denied", 44 | "locationErrorServiceDisabled": "Location service is disabled", 45 | "locationErrorTimeout": "Location could not be retreived", 46 | "locationErrorUnknown": "An unknown error occurred", 47 | "locationHeader": "Location", 48 | "locationLoading": "Retrieving location ..", 49 | "locationRemoveAction": "Remove", 50 | "locationSuccessful": "Location recorded\n({latitude}, {longitude})", 51 | "locationTryAgain": "Adjust your device's location permissions for this app or enable the location service and try again.", 52 | "locationTryAgainAction": "Try again", 53 | "noteCardTitle": "Note", 54 | "paginationListError": "Error occurred while requesting data: {errorMessage}", 55 | "paginationListLoadMore": "Load More", 56 | "paginationListNoResults": "No entries given yet", 57 | "settings": "Settings", 58 | "settingsEnglish": "English", 59 | "settingsLanguageChangeError": "Error: could not change language", 60 | "settingsLanguageChangeSuccess": "Language changed to \"{language}\"", 61 | "settingsLanguages": "Languages", 62 | "settingsPortuguese": "Portuguese", 63 | "settingsSystemInfoAndroid": "Android Build Version", 64 | "settingsSystemInfoDevice": "Device", 65 | "settingsSystemInfoError": "Could not retrieve system informations", 66 | "settingsSystemInfoSDK": "Android SDK Version", 67 | "settingsSystemInformation": "System Information", 68 | "settingsVersionNumber": "Version", 69 | "sightingDeleteAlertBody": "Are you sure you want to delete this sighting?", 70 | "sightingDeleteAlertCancel": "Cancel", 71 | "sightingDeleteAlertConfirm": "Delete", 72 | "sightingDeleteAlertTitle": "Delete Sighting", 73 | "sightingDeleteConfirmation": "Sighting deleted.", 74 | "sightingScreenTitle": "Sighting", 75 | "sightingUnspecified": "Unknown species", 76 | "speciesCardTitle": "Species", 77 | "speciesCreateAbortButton": "Cancel", 78 | "speciesCreateConfirmButton": "Create", 79 | "speciesCreateDialogTitle": "Create new species?", 80 | "speciesCreateMessage": "This action will create a new species \"{taxon}\" in the database", 81 | "speciesDeleteAlertBody": "Are you sure you want to delete this species?", 82 | "speciesDeleteAlertCancel": "Cancel", 83 | "speciesDeleteAlertConfirm": "Delete", 84 | "speciesDeleteAlertTitle": "Delete Species", 85 | "speciesDeleteConfirmation": "Species deleted.", 86 | "speciesDescription": "Description", 87 | "speciesScreenTitle": "Species", 88 | "taxonomyClass": "Class", 89 | "taxonomyFamily": "Family", 90 | "taxonomyGenus": "Genus", 91 | "taxonomyKingdom": "Kingdom", 92 | "taxonomyOrder": "Order", 93 | "taxonomyPhylum": "Phylum", 94 | "taxonomySpecies": "Species", 95 | "taxonomySubfamily": "Subfamily", 96 | "taxonomyTribe": "Tribe", 97 | "usedForCardAddButton": "Add", 98 | "usedForCardDialogAddExisting": "Add Existing", 99 | "usedForCardDialogCreateNew": "Create New", 100 | "usedForCardTitle": "Used For", 101 | "usedForCreateButton": "Create" 102 | } 103 | -------------------------------------------------------------------------------- /packages/app/lib/locales/app_pt.arb: -------------------------------------------------------------------------------- 1 | { 2 | "actionCancelButton": "Cancelar", 3 | "actionDefaultButton": "Salvar", 4 | "allSpeciesScreenTitle": "Todas as Espécies", 5 | "createSightingError": "Algo deu errado: {error}", 6 | "createSightingScreenTitle": "Adicionar Observação", 7 | "createSightingSuccess": "Parabéns, você adicionou uma nova observação ao banco de dados com sucesso", 8 | "createSpeciesSuccess": "Adicionada nova espécie \"{taxon}\" ao banco de dados!", 9 | "deleteSighting": "Excluir Observação", 10 | "deleteSpecies": "Excluir Espécie", 11 | "editSpeciesErrorCantRemove": "A espécie precisa ser definida", 12 | "editSpeciesErrorConfirm": "Ok", 13 | "editSpeciesErrorInvalidRankCase": "A classificação \"{rank}\" não pode ser alterada!", 14 | "editSpeciesErrorMessage": "{error}", 15 | "editSpeciesErrorNullCase": "A classificação \"{rank}\" precisa ser especificada!", 16 | "editSpeciesErrorTitle": "Não foi possível salvar a espécie", 17 | "exportImages": "Exportar imagens", 18 | "hiveLocationBox": "Caixa", 19 | "hiveLocationBuilding": "Prédio", 20 | "hiveLocationCardTitle": "Localização da Colmeia", 21 | "hiveLocationGround": "Chão", 22 | "hiveLocationTree": "Árvore", 23 | "hiveLocationTreeDiameter": "Diâmetro", 24 | "hiveLocationTreeHeight": "Altura", 25 | "hiveLocationTreeSpecies": "Espécie da Árvore", 26 | "hiveLocationTreeSpeciesHint": "Insira a espécie da árvore aqui", 27 | "hiveLocationsAggregateNoData": "Não há dados disponíveis", 28 | "hiveLocationsAggregateTreeDiameter": "Diâmetro: {diameter}", 29 | "hiveLocationsAggregateTreeHeight": "Altura: {height}", 30 | "hiveLocationsAggregateTreeSpecies": "Espécies de árvores: {list}", 31 | "imageDeleteAlertBody": "Tem certeza de que deseja excluir esta imagem?", 32 | "imageDeleteAlertCancel": "Não", 33 | "imageDeleteAlertConfirm": "Sim", 34 | "imageDeleteAlertTitle": "Excluir Imagem", 35 | "imageDeleteConfirmation": "Imagem excluída.", 36 | "imageLoadingError": "Não foi possível carregar a imagem", 37 | "imageMissingError": "Nenhuma imagem fornecida", 38 | "localNameCardSaveAction": "Salvar", 39 | "localNameCardTitle": "Nome Local", 40 | "localNamesCardTitle": "Nomes Locais", 41 | "locationAdd": "Deseja adicionar uma localização GPS?", 42 | "locationAddAction": "Adicionar localização à imagem", 43 | "locationErrorPermissionDenied": "Permissão de localização negada", 44 | "locationErrorServiceDisabled": "Serviço de localização desativado", 45 | "locationErrorTimeout": "Não foi possível obter a localização", 46 | "locationErrorUnknown": "Ocorreu um erro desconhecido", 47 | "locationHeader": "Localização", 48 | "locationLoading": "Obtendo localização...", 49 | "locationRemoveAction": "Remover", 50 | "locationSuccessful": "Localização registrada\n({latitude}, {longitude})", 51 | "locationTryAgain": "Ajuste as permissões de localização do seu dispositivo para este aplicativo ou ative o serviço de localização e tente novamente.", 52 | "locationTryAgainAction": "Tentar novamente", 53 | "noteCardTitle": "Nota", 54 | "paginationListError": "Ocorreu um erro ao solicitar dados: {errorMessage}", 55 | "paginationListLoadMore": "Carregar Mais", 56 | "paginationListNoResults": "Nenhuma entrada fornecida ainda", 57 | "settings": "Configurações", 58 | "settingsEnglish": "Inglês", 59 | "settingsLanguageChangeError": "Erro: não foi possível alterar o idioma", 60 | "settingsLanguageChangeSuccess": "Idioma alterado para \"{language}\"", 61 | "settingsLanguages": "Idiomas", 62 | "settingsPortuguese": "Português", 63 | "settingsSystemInfoAndroid": "Versão do Android", 64 | "settingsSystemInfoDevice": "Dispositivo", 65 | "settingsSystemInfoError": "Não foi possível obter informações do sistema", 66 | "settingsSystemInfoSDK": "Versão do SDK do Android", 67 | "settingsSystemInformation": "Informações do Sistema", 68 | "settingsVersionNumber": "Versão", 69 | "sightingDeleteAlertBody": "Tem certeza de que deseja excluir esta observação?", 70 | "sightingDeleteAlertCancel": "Cancelar", 71 | "sightingDeleteAlertConfirm": "Excluir", 72 | "sightingDeleteAlertTitle": "Excluir Observação", 73 | "sightingDeleteConfirmation": "Observação excluída.", 74 | "sightingScreenTitle": "Observação", 75 | "sightingUnspecified": "Espécie desconhecida", 76 | "speciesCardTitle": "Espécie", 77 | "speciesCreateAbortButton": "Cancelar", 78 | "speciesCreateConfirmButton": "Criar", 79 | "speciesCreateDialogTitle": "Criar nova espécie?", 80 | "speciesCreateMessage": "Esta ação criará uma nova espécie \"{taxon}\" no banco de dados", 81 | "speciesDeleteAlertBody": "Tem certeza de que deseja excluir esta espécie?", 82 | "speciesDeleteAlertCancel": "Cancelar", 83 | "speciesDeleteAlertConfirm": "Excluir", 84 | "speciesDeleteAlertTitle": "Excluir Espécie", 85 | "speciesDeleteConfirmation": "Espécie excluída.", 86 | "speciesDescription": "Descrição", 87 | "speciesScreenTitle": "Espécies", 88 | "taxonomyClass": "Classe", 89 | "taxonomyFamily": "Família", 90 | "taxonomyGenus": "Gênero", 91 | "taxonomyKingdom": "Reino", 92 | "taxonomyOrder": "Ordem", 93 | "taxonomyPhylum": "Filo", 94 | "taxonomySpecies": "Espécie", 95 | "taxonomySubfamily": "Subfamília", 96 | "taxonomyTribe": "Tribo", 97 | "usedForCardAddButton": "Adicionar", 98 | "usedForCardDialogAddExisting": "Adicionar Existente", 99 | "usedForCardDialogCreateNew": "Criar Novo", 100 | "usedForCardTitle": "Usado Para", 101 | "usedForCreateButton": "Criar" 102 | } 103 | -------------------------------------------------------------------------------- /packages/app/lib/main.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:app/app.dart'; 6 | import 'package:app/io/p2panda/node.dart'; 7 | import 'package:app/io/p2panda/schemas.dart'; 8 | import 'package:app/io/p2panda/seed.dart'; 9 | import 'package:app/models/schema_ids.dart'; 10 | import 'package:app/router.dart'; 11 | 12 | void main() async { 13 | // Start application 14 | runApp(const MeliApp()); 15 | 16 | // Bootstrap backend for p2p communication and data persistence 17 | await bootstrapNode(); 18 | 19 | // Go to main screen 20 | router.go(RoutePaths.allSightings.path); 21 | } 22 | 23 | Future bootstrapNode() async { 24 | // Run p2panda node in the background 25 | await startNode(); 26 | 27 | // Migrate pending p2panda schemas if necessary 28 | final bool didMigrate = await migrateSchemas(); 29 | if (didMigrate) { 30 | print("Migration succeeded"); 31 | } else { 32 | print("No migration required"); 33 | } 34 | 35 | // Wait until we're sure that all schema ids are ready on the node. This is 36 | // mostly important after a migration took place 37 | await untilSchemasAvailable(ALL_SCHEMA_IDS); 38 | 39 | // Seed database with initial data when necessary 40 | final bool didSeed = await seedDatabase(); 41 | if (didSeed) { 42 | print("Seed succeeded"); 43 | } else { 44 | print("No seed required"); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/app/lib/models/base.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'dart:ui'; 4 | 5 | import 'package:gql/ast.dart'; 6 | import 'package:graphql/client.dart'; 7 | 8 | import 'package:app/io/graphql/graphql.dart'; 9 | import 'package:app/io/p2panda/schemas.dart'; 10 | 11 | const DEFAULT_PAGE_SIZE = 10; 12 | 13 | const DEFAULT_RESULTS_KEY = 'collection'; 14 | 15 | String get paginationFields { 16 | return ''' 17 | endCursor 18 | hasNextPage 19 | '''; 20 | } 21 | 22 | String get metaFields { 23 | return ''' 24 | meta { 25 | owner 26 | documentId 27 | viewId 28 | } 29 | '''; 30 | } 31 | 32 | class PaginatedCollection { 33 | final List documents; 34 | final bool hasNextPage; 35 | final String? endCursor; 36 | 37 | PaginatedCollection( 38 | {required this.documents, required this.hasNextPage, this.endCursor}); 39 | } 40 | 41 | abstract class Paginator { 42 | /// Call this method to force refreshing a `PaginationList` widget using this 43 | /// Paginator instance. 44 | VoidCallback? refresh; 45 | 46 | VoidCallback? fetchMore; 47 | 48 | /// Should return GraphQL query for the next page. 49 | DocumentNode nextPageQuery(String? cursor); 50 | 51 | /// Method to decode an incoming JSON string into structured data. 52 | PaginatedCollection parseJSON(Map json); 53 | 54 | /// Combine results from previous queries and new data for the UI. Needs to be 55 | /// overwritten when result key is different than the default result key. 56 | Map mergeResponses( 57 | Map previous, Map next) { 58 | final List documents = [ 59 | ...previous[DEFAULT_RESULTS_KEY]['documents'] as List, 60 | ...next[DEFAULT_RESULTS_KEY]['documents'] as List 61 | ]; 62 | next[DEFAULT_RESULTS_KEY]['documents'] = documents; 63 | return next; 64 | } 65 | } 66 | 67 | Future>> paginateOverEverything( 68 | SchemaId schemaId, String fields, 69 | {String filter = '', int pageSize = DEFAULT_PAGE_SIZE}) async { 70 | String filterStr = filter.isNotEmpty ? "filter: { $filter }," : ""; 71 | 72 | bool hasNextPage = true; 73 | String? endCursor; 74 | List> documents = []; 75 | 76 | while (hasNextPage) { 77 | final afterStr = endCursor != null ? "after: \"$endCursor\"," : ""; 78 | final document = ''' 79 | query PaginateOverEverything { 80 | $DEFAULT_RESULTS_KEY: all_$schemaId( 81 | first: $pageSize, 82 | $afterStr 83 | $filterStr 84 | ) { 85 | $paginationFields 86 | documents { 87 | $fields 88 | } 89 | } 90 | } 91 | '''; 92 | 93 | final response = await client.query(QueryOptions(document: gql(document))); 94 | if (response.hasException) { 95 | throw "Error during pagination: ${response.exception}"; 96 | } 97 | 98 | final result = response.data![DEFAULT_RESULTS_KEY]; 99 | endCursor = result['endCursor'] as String?; 100 | hasNextPage = result['hasNextPage'] as bool; 101 | 102 | for (var document in result['documents'] as List) { 103 | documents.add(document as Map); 104 | } 105 | } 106 | 107 | return documents; 108 | } 109 | -------------------------------------------------------------------------------- /packages/app/lib/models/blobs.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'dart:io'; 4 | import 'dart:typed_data'; 5 | 6 | import 'package:mime/mime.dart'; 7 | import 'package:p2panda/p2panda.dart'; 8 | 9 | import 'package:app/io/p2panda/publish.dart'; 10 | import 'package:app/models/schema_ids.dart'; 11 | import 'package:app/models/base.dart'; 12 | 13 | const MAX_BLOB_PIECE_LENGTH = 256 * 1000; // 256kb as per specification 14 | 15 | class Blob { 16 | final DocumentId id; 17 | 18 | Blob({required this.id}); 19 | 20 | factory Blob.fromJson(Map result) { 21 | return Blob(id: result['meta']['documentId'] as String); 22 | } 23 | } 24 | 25 | String get blobFields { 26 | return ''' 27 | $metaFields 28 | '''; 29 | } 30 | 31 | Future publishBlob(File file) async { 32 | // Check the mimetype. 33 | final mimeType = lookupMimeType(file.path); 34 | 35 | // Open a reader onto the blob file. 36 | final reader = await file.open(mode: FileMode.read); 37 | 38 | // Get the length and calculate the total number of pieces, based on the 39 | // maximum allowed piece size. 40 | final length = await reader.length(); 41 | final expectedPieces = (length / MAX_BLOB_PIECE_LENGTH).ceil(); 42 | 43 | // This is where we will keep ids of the created blob pieces 44 | List blobPieces = []; 45 | 46 | // For each expected piece read the chunk of bytes into a buffer and publish 47 | // the piece to the node. 48 | for (var i = 0; i < expectedPieces; i++) { 49 | // Calculate the offset we want to start reading from. 50 | int offset = i * MAX_BLOB_PIECE_LENGTH; 51 | 52 | // Set the read position based on current offset. 53 | RandomAccessFile rangeReader = await reader.setPosition(offset); 54 | 55 | // Populate a fixed buffer which will contain the bytes of a single blob 56 | // piece. Account for the final blob piece being variable length. 57 | Uint8List buffer; 58 | if (blobPieces.length == expectedPieces - 1) { 59 | buffer = Uint8List(length - offset); 60 | } else { 61 | buffer = Uint8List(MAX_BLOB_PIECE_LENGTH); 62 | } 63 | 64 | // Create and publish the blob piece and store it's id for use later. 65 | await rangeReader.readInto(buffer); 66 | DocumentViewId id = await createBlobPiece(buffer); 67 | blobPieces.add(id); 68 | } 69 | 70 | // Close the reader. 71 | await reader.close(); 72 | 73 | // Now create and publish the blob. 74 | return await createBlob(mimeType.toString(), length, blobPieces); 75 | } 76 | 77 | Future createBlobPiece(Uint8List bytes) async { 78 | List<(String, OperationValue)> fields = [ 79 | ("data", OperationValue.bytes(bytes)), 80 | ]; 81 | return await create(SchemaIds.blob_piece, fields); 82 | } 83 | 84 | Future createBlob( 85 | String mimeType, int length, List pieces) async { 86 | List<(String, OperationValue)> fields = [ 87 | ("mime_type", OperationValue.string(mimeType)), 88 | ("length", OperationValue.integer(length)), 89 | ("pieces", OperationValue.pinnedRelationList(pieces)), 90 | ]; 91 | return await create(SchemaIds.blob, fields); 92 | } 93 | -------------------------------------------------------------------------------- /packages/app/lib/models/local_names.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:gql/ast.dart'; 4 | import 'package:graphql/client.dart'; 5 | import 'package:p2panda/p2panda.dart'; 6 | 7 | import 'package:app/io/graphql/graphql.dart'; 8 | import 'package:app/io/p2panda/publish.dart'; 9 | import 'package:app/models/base.dart'; 10 | import 'package:app/models/schema_ids.dart'; 11 | 12 | class LocalName { 13 | final DocumentId id; 14 | DocumentViewId viewId; 15 | 16 | String name; 17 | 18 | LocalName({required this.id, required this.viewId, required this.name}); 19 | 20 | factory LocalName.fromJson(Map json) { 21 | return LocalName( 22 | id: json['meta']['documentId'] as DocumentId, 23 | viewId: json['meta']['viewId'] as DocumentViewId, 24 | name: json['fields']['name'] as String); 25 | } 26 | 27 | static Future create({required String name}) async { 28 | DocumentViewId viewId = await createLocalName(name: name); 29 | return LocalName( 30 | id: viewId, 31 | viewId: viewId, 32 | name: name, 33 | ); 34 | } 35 | 36 | Future update({required String name}) async { 37 | viewId = await updateLocalName(viewId, name: name); 38 | this.name = name; 39 | return viewId; 40 | } 41 | 42 | Future delete() async { 43 | viewId = await deleteLocalName(viewId); 44 | return viewId; 45 | } 46 | } 47 | 48 | String get localNameFields { 49 | return ''' 50 | $metaFields 51 | fields { 52 | name 53 | } 54 | '''; 55 | } 56 | 57 | String searchLocalNamesQuery(String query, {bool strict = false}) { 58 | const schemaId = SchemaIds.bee_local_name; 59 | final op = strict ? "eq" : "contains"; 60 | 61 | return ''' 62 | query SearchLocalNames { 63 | $DEFAULT_RESULTS_KEY: all_$schemaId( 64 | first: 5, 65 | filter: { 66 | name: { $op: "$query" }, 67 | }, 68 | orderBy: "name", 69 | orderDirection: ASC, 70 | ) { 71 | documents { 72 | $localNameFields 73 | } 74 | } 75 | } 76 | '''; 77 | } 78 | 79 | /// Safely create a new local name instance if we're not aware yet of one with 80 | /// the same "name" value. 81 | Future createDeduplicatedLocalName(String name) async { 82 | final response = await client.query( 83 | QueryOptions(document: gql(searchLocalNamesQuery(name, strict: true)))); 84 | 85 | if (!response.hasException) { 86 | final documents = 87 | response.data![DEFAULT_RESULTS_KEY]['documents'] as List; 88 | 89 | if (documents.isNotEmpty) { 90 | return LocalName.fromJson(documents[0] as Map); 91 | } 92 | } 93 | 94 | return await LocalName.create(name: name); 95 | } 96 | 97 | Future createLocalName({required String name}) async { 98 | List<(String, OperationValue)> fields = [ 99 | ("name", OperationValue.string(name)), 100 | ]; 101 | return await create(SchemaIds.bee_local_name, fields); 102 | } 103 | 104 | Future updateLocalName(DocumentViewId viewId, 105 | {required String name}) async { 106 | List<(String, OperationValue)> fields = [ 107 | ("name", OperationValue.string(name)), 108 | ]; 109 | return await update(viewId, SchemaIds.bee_local_name, fields); 110 | } 111 | 112 | Future deleteLocalName(DocumentViewId viewId) async { 113 | return await delete(viewId, SchemaIds.bee_local_name); 114 | } 115 | 116 | class LocalNamesPaginator extends Paginator { 117 | final DocumentId species; 118 | 119 | LocalNamesPaginator({required this.species}); 120 | 121 | @override 122 | DocumentNode nextPageQuery(String? cursor) { 123 | return gql(allSpeciesLocalNames(cursor, species)); 124 | } 125 | 126 | @override 127 | PaginatedCollection parseJSON(Map json) { 128 | final list = json[DEFAULT_RESULTS_KEY]['documents'] as List; 129 | final documents = list 130 | .where((sighting) { 131 | List local_names = 132 | sighting['fields']['local_names']['documents'] as List; 133 | return local_names.isNotEmpty && local_names[0] != null; 134 | }) 135 | .map((sighting) => LocalName.fromJson(sighting['fields']['local_names'] 136 | ['documents'][0] as Map)) 137 | .toList(); 138 | 139 | final endCursor = json[DEFAULT_RESULTS_KEY]['endCursor'] as String?; 140 | final hasNextPage = json[DEFAULT_RESULTS_KEY]['hasNextPage'] as bool; 141 | 142 | return PaginatedCollection( 143 | documents: documents, hasNextPage: hasNextPage, endCursor: endCursor); 144 | } 145 | } 146 | 147 | String allSpeciesLocalNames(String? cursor, DocumentId? speciesId) { 148 | const schemaId = SchemaIds.bee_sighting; 149 | final after = (cursor != null) ? '''after: "$cursor",''' : ''; 150 | final filter = (speciesId != null) 151 | ? '''filter: { species: { in: ["$speciesId"] } },''' 152 | : ''; 153 | 154 | return ''' 155 | query SpeciesLocalNames { 156 | $DEFAULT_RESULTS_KEY: all_$schemaId( 157 | first: $DEFAULT_PAGE_SIZE, 158 | $after 159 | $filter 160 | orderBy: "datetime", 161 | orderDirection: DESC 162 | ) { 163 | $paginationFields 164 | documents { 165 | fields { 166 | local_names { 167 | documents { 168 | $localNameFields 169 | } 170 | } 171 | } 172 | } 173 | } 174 | } 175 | '''; 176 | } 177 | -------------------------------------------------------------------------------- /packages/app/lib/models/schema_ids.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:app/io/p2panda/schemas.dart'; 4 | 5 | /// Schema ids used by the meli app. 6 | abstract final class SchemaIds { 7 | /// System schema 8 | static const SchemaId blob_piece = 'blob_piece_v1'; 9 | static const SchemaId blob = 'blob_v1'; 10 | static const SchemaId schema_definition = 'schema_definition_v1'; 11 | static const SchemaId schema_field_definition = 'schema_field_definition_v1'; 12 | 13 | /// Sighting and species schema. 14 | static const SchemaId bee_sighting = 15 | 'bee_sighting_0020df662f01bd4eed879ebb2128edd3e0b55902f179eeaf8978e58011f96b488717'; 16 | static const SchemaId bee_local_name = 17 | 'bee_local_name_0020aeaca910c8a3f5fb0f80f7e8e6878720272d3bdcefa45c97cfc627e3b5e4252c'; 18 | static const SchemaId bee_species = 19 | 'bee_species_00207889f44a73c94bb9f7e7d087e47d87ab4854c3693bca35bf16c596c8c91c5fe7'; 20 | 21 | /// Bee attributes schema. 22 | static const SchemaId bee_attributes_location_tree = 23 | 'bee_attributes_location_tree_0020a960a5e60c7daf66f9b9056de6e7904247360017dd09c223800e65223c0adafe'; 24 | static const SchemaId bee_attributes_location_ground = 25 | 'bee_attributes_location_ground_00207f1b4d7115e518ed754024db27c86756479c9448aebc3f39a0e67c18b611bc25'; 26 | static const SchemaId bee_attributes_location_building = 27 | 'bee_attributes_location_building_002021849ffe9546354fab1c421a6e5f5aaa49ae6d1bddffbe057444f67ba6e4743f'; 28 | static const SchemaId bee_attributes_location_box = 29 | 'bee_attributes_location_box_00202a2f65b79de2f10a03fbd2adeeea491bfb36dd86998af86f8335414a654dbde0'; 30 | static const SchemaId bee_attributes_used_for = 31 | 'bee_attributes_used_for_002064feb6c43eca60974c8f5e60475b1b931cdc920e8441bd4c88f280a0b2f0cedc'; 32 | 33 | /// Taxonomy schema. 34 | static const SchemaId taxonomy_kingdom = 35 | 'taxonomy_kingdom_0020f3cd78f31cd41fb554d605b11b8facdccba5d93322376ba988a4fee4d7893f43'; 36 | static const SchemaId taxonomy_phylum = 37 | 'taxonomy_phylum_002098b9c13e1162b360a528196c4293ed1e00e71359048323e9af2929ddf1e30313'; 38 | static const SchemaId taxonomy_class = 39 | 'taxonomy_class_0020c790691e036ed090392b4c06eb3586eb130edaee6ece2d4a48607f240bc11f91'; 40 | static const SchemaId taxonomy_order = 41 | 'taxonomy_order_00208ced5b9fc23a3f3e87444c4be7f305bbfcf4989d77cf6d208c579de1fe0a3b79'; 42 | static const SchemaId taxonomy_family = 43 | 'taxonomy_family_0020bdb78e578befd97784e2ec7710f5314fc22fb484e59eef7a66f948ea953a38dc'; 44 | static const SchemaId taxonomy_subfamily = 45 | 'taxonomy_subfamily_0020abf0e567ec55d407c288f7d77bd58f1a529fdb0eb6cbb74279b83b53fb4fa9c2'; 46 | static const SchemaId taxonomy_tribe = 47 | 'taxonomy_tribe_0020eff38ee4ed5bed12b61452c6472f8cd9c692e4f39b36a6b35f16fde309d56d00'; 48 | static const SchemaId taxonomy_genus = 49 | 'taxonomy_genus_0020ebf2746448b4fdf563de1486efc082c1243522d2579aeaaf12e7937c6bc86eba'; 50 | static const SchemaId taxonomy_species = 51 | 'taxonomy_species_0020e1567cb6f7e097b449cd05174f96ac17f774d8b80ffea423c4d4b386e423cb0a'; 52 | } 53 | 54 | /// List of all schema ids which are used by the meli app. 55 | const List ALL_SCHEMA_IDS = [ 56 | SchemaIds.blob, 57 | SchemaIds.blob_piece, 58 | SchemaIds.schema_definition, 59 | SchemaIds.schema_field_definition, 60 | SchemaIds.bee_sighting, 61 | SchemaIds.bee_local_name, 62 | SchemaIds.bee_species, 63 | SchemaIds.bee_attributes_location_tree, 64 | SchemaIds.bee_attributes_location_ground, 65 | SchemaIds.bee_attributes_location_building, 66 | SchemaIds.bee_attributes_location_box, 67 | SchemaIds.bee_attributes_used_for, 68 | SchemaIds.taxonomy_kingdom, 69 | SchemaIds.taxonomy_phylum, 70 | SchemaIds.taxonomy_class, 71 | SchemaIds.taxonomy_order, 72 | SchemaIds.taxonomy_family, 73 | SchemaIds.taxonomy_subfamily, 74 | SchemaIds.taxonomy_tribe, 75 | SchemaIds.taxonomy_genus, 76 | SchemaIds.taxonomy_species, 77 | ]; 78 | -------------------------------------------------------------------------------- /packages/app/lib/models/used_for.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:gql/ast.dart'; 4 | import 'package:graphql_flutter/graphql_flutter.dart'; 5 | import 'package:p2panda/p2panda.dart'; 6 | 7 | import 'package:app/io/p2panda/publish.dart'; 8 | import 'package:app/models/base.dart'; 9 | import 'package:app/models/schema_ids.dart'; 10 | 11 | class UsedFor { 12 | final DocumentId id; 13 | DocumentViewId viewId; 14 | 15 | DocumentId? sighting; 16 | String usedFor; 17 | 18 | UsedFor( 19 | {required this.id, 20 | required this.viewId, 21 | required this.sighting, 22 | required this.usedFor}); 23 | 24 | factory UsedFor.fromJson(Map result) { 25 | return UsedFor( 26 | id: result['meta']['documentId'] as DocumentId, 27 | viewId: result['meta']['viewId'] as DocumentViewId, 28 | sighting: result['fields']['sighting'] != null 29 | ? result['fields']['sighting']['meta']['documentId'] as DocumentId 30 | : null, 31 | usedFor: result['fields']['used_for'] as String); 32 | } 33 | 34 | static Future create( 35 | {required DocumentId sighting, required String usedFor}) async { 36 | DocumentViewId viewId = 37 | await createUsedFor(sighting: sighting, usedFor: usedFor); 38 | return UsedFor( 39 | id: viewId, 40 | viewId: viewId, 41 | sighting: sighting, 42 | usedFor: usedFor, 43 | ); 44 | } 45 | 46 | Future delete() async { 47 | viewId = await deleteUsedFor(viewId); 48 | return viewId; 49 | } 50 | } 51 | 52 | String get usedForFields { 53 | return ''' 54 | $metaFields 55 | fields { 56 | used_for 57 | sighting { 58 | meta { 59 | documentId 60 | } 61 | } 62 | } 63 | '''; 64 | } 65 | 66 | String usedForQuery(DocumentId id) { 67 | const schemaId = SchemaIds.bee_attributes_used_for; 68 | 69 | return ''' 70 | query usedForQuery { 71 | document: $schemaId(id: "$id") { 72 | $usedForFields 73 | } 74 | } 75 | '''; 76 | } 77 | 78 | class UsedForPaginator extends Paginator { 79 | final List? sightings; 80 | 81 | UsedForPaginator({this.sightings}); 82 | 83 | @override 84 | DocumentNode nextPageQuery(String? cursor) { 85 | return gql(allUsesQuery(sightings, cursor)); 86 | } 87 | 88 | @override 89 | PaginatedCollection parseJSON(Map json) { 90 | final list = json[DEFAULT_RESULTS_KEY]['documents'] as List; 91 | final documents = list 92 | .map((sighting) => UsedFor.fromJson(sighting as Map)) 93 | .toList(); 94 | 95 | final endCursor = json[DEFAULT_RESULTS_KEY]['endCursor'] as String?; 96 | final hasNextPage = json[DEFAULT_RESULTS_KEY]['hasNextPage'] as bool; 97 | 98 | return PaginatedCollection( 99 | documents: documents, hasNextPage: hasNextPage, endCursor: endCursor); 100 | } 101 | } 102 | 103 | Future> getAllDeduplicatedUsedFor() async { 104 | final jsonDocuments = await paginateOverEverything( 105 | SchemaIds.bee_attributes_used_for, usedForFields); 106 | 107 | List documents = []; 108 | for (var json in jsonDocuments) { 109 | final usedFor = UsedFor.fromJson(json); 110 | documents.add(usedFor); 111 | } 112 | 113 | // De-Duplicate documents by removing duplicate "used-for" strings 114 | Set seen = {}; 115 | documents.retainWhere((usedFor) => seen.add(usedFor.usedFor)); 116 | 117 | return documents; 118 | } 119 | 120 | String allUsesQuery(List? sightings, String? cursor) { 121 | final after = (cursor != null) ? '''after: "$cursor",''' : ''; 122 | String filter = ''; 123 | if (sightings != null) { 124 | String sightingsString = 125 | sightings.map((sighting) => '''"$sighting"''').join(", "); 126 | filter = '''filter: { sighting: { in: [$sightingsString] } },'''; 127 | } 128 | const schemaId = SchemaIds.bee_attributes_used_for; 129 | 130 | return ''' 131 | query AllUses { 132 | $DEFAULT_RESULTS_KEY: all_$schemaId( 133 | $filter 134 | first: $DEFAULT_PAGE_SIZE, 135 | $after 136 | orderBy: "used_for", 137 | orderDirection: ASC 138 | ) { 139 | $paginationFields 140 | documents { 141 | $usedForFields 142 | } 143 | } 144 | } 145 | '''; 146 | } 147 | 148 | Future createUsedFor( 149 | {required DocumentId sighting, required String usedFor}) async { 150 | List<(String, OperationValue)> fields = [ 151 | ("sighting", OperationValue.relation(sighting)), 152 | ("used_for", OperationValue.string(usedFor)), 153 | ]; 154 | return await create(SchemaIds.bee_attributes_used_for, fields); 155 | } 156 | 157 | Future deleteUsedFor(DocumentViewId viewId) async { 158 | return await delete(SchemaIds.bee_attributes_used_for, viewId); 159 | } 160 | 161 | Future deleteAllUsedFor(DocumentId sightingId) async { 162 | final jsonDocuments = await paginateOverEverything( 163 | SchemaIds.bee_attributes_used_for, usedForFields, 164 | filter: 'sighting: { eq: "$sightingId" }'); 165 | 166 | for (var json in jsonDocuments) { 167 | await UsedFor.fromJson(json).delete(); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /packages/app/lib/router.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:go_router/go_router.dart'; 5 | 6 | import 'package:app/ui/screens/all_sightings.dart'; 7 | import 'package:app/ui/screens/all_species.dart'; 8 | import 'package:app/ui/screens/create_sighting.dart'; 9 | import 'package:app/ui/screens/settings.dart'; 10 | import 'package:app/ui/screens/sighting.dart'; 11 | import 'package:app/ui/screens/species.dart'; 12 | import 'package:app/ui/screens/splash.dart'; 13 | 14 | class RoutePath { 15 | final String name; 16 | final String path; 17 | 18 | RoutePath(this.name, this.path); 19 | } 20 | 21 | class RoutePaths { 22 | static RoutePath splash = RoutePath('splash', '/'); 23 | static RoutePath settings = RoutePath('settings', '/settings'); 24 | static RoutePath allSightings = RoutePath('all_sightings', '/sightings'); 25 | static RoutePath allSpecies = RoutePath('all_species', '/species'); 26 | static RoutePath sighting = RoutePath('sighting', '/sighting/:documentId'); 27 | static RoutePath species = RoutePath('species', '/species/:documentId'); 28 | static RoutePath createSighting = 29 | RoutePath('create_sighting', '/create/sighting'); 30 | } 31 | 32 | final router = GoRouter(routes: [ 33 | _Route(RoutePaths.splash, (_) => const SplashScreen()), 34 | _Route(RoutePaths.settings, (_) => const SettingsScreen()), 35 | _Route(RoutePaths.allSightings, (_) => AllSightingsScreen()), 36 | _Route(RoutePaths.allSpecies, (_) => AllSpeciesScreen()), 37 | _Route( 38 | RoutePaths.sighting, 39 | (state) => 40 | SightingScreen(documentId: state.pathParameters["documentId"]!)), 41 | _Route( 42 | RoutePaths.species, 43 | (state) => 44 | SpeciesScreen(documentId: state.pathParameters["documentId"]!)), 45 | _Route(RoutePaths.createSighting, (_) => const CreateSightingScreen()), 46 | ]); 47 | 48 | class _Route extends GoRoute { 49 | _Route(RoutePath route, Widget Function(GoRouterState state) builder) 50 | : super( 51 | name: route.name, 52 | path: route.path, 53 | routes: const [], 54 | pageBuilder: (context, state) { 55 | return CustomTransitionPage( 56 | key: state.pageKey, 57 | child: builder(state), 58 | transitionsBuilder: 59 | (context, animation, secondaryAnimation, child) { 60 | final tween = 61 | Tween(begin: const Offset(1.0, 0.0), end: Offset.zero) 62 | .chain(CurveTween(curve: Curves.ease)); 63 | 64 | return SlideTransition( 65 | position: animation.drive(tween), 66 | child: child, 67 | ); 68 | }); 69 | }); 70 | } 71 | -------------------------------------------------------------------------------- /packages/app/lib/ui/colors.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | abstract final class MeliColors { 6 | static const Color black = Color(0xff000000); 7 | static const Color electric = Color(0xff95fff2); 8 | static const Color flurry = Color(0xfff0ffc7); 9 | static const Color grass = Color(0xff95ffd9); 10 | static const Color magnolia = Color(0xffeaddff); 11 | static const Color peach = Color(0xffffd8d8); 12 | static const Color pink = Color(0xffffd8e4); 13 | static const Color plum = Color(0xff77729c); 14 | static const Color sea = Color(0xffbfdff6); 15 | static const Color sky = Color(0xff9ac4e8); 16 | static const Color white = Color(0xffffffff); 17 | } 18 | -------------------------------------------------------------------------------- /packages/app/lib/ui/screens/all_species.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 5 | 6 | import 'package:app/models/base.dart'; 7 | import 'package:app/models/species.dart'; 8 | import 'package:app/router.dart'; 9 | import 'package:app/ui/colors.dart'; 10 | import 'package:app/ui/widgets/pagination_list.dart'; 11 | import 'package:app/ui/widgets/refresh_provider.dart'; 12 | import 'package:app/ui/widgets/scaffold.dart'; 13 | import 'package:app/ui/widgets/species_card.dart'; 14 | 15 | class AllSpeciesScreen extends StatelessWidget { 16 | final Paginator paginator = SpeciesPaginator(); 17 | 18 | AllSpeciesScreen({super.key}); 19 | 20 | @override 21 | Widget build(BuildContext context) { 22 | return MeliScaffold( 23 | title: AppLocalizations.of(context)!.allSpeciesScreenTitle, 24 | body: RefreshIndicator( 25 | color: MeliColors.black, 26 | onRefresh: () { 27 | if (paginator.refresh != null) { 28 | paginator.refresh!(); 29 | } 30 | 31 | return Future.delayed(const Duration(milliseconds: 150)); 32 | }, 33 | child: LayoutBuilder(builder: (context, constraints) { 34 | return Container( 35 | padding: 36 | const EdgeInsets.symmetric(horizontal: 20.0, vertical: 0.0), 37 | constraints: BoxConstraints(minHeight: constraints.maxHeight), 38 | decoration: const PeachWavesBackground(), 39 | child: CustomScrollView( 40 | physics: const AlwaysScrollableScrollPhysics(), 41 | slivers: [ 42 | const SliverToBoxAdapter(child: SizedBox(height: 120.0)), 43 | SpeciesList(paginator: paginator), 44 | const SliverToBoxAdapter(child: SizedBox(height: 20.0)), 45 | ]), 46 | ); 47 | }))); 48 | } 49 | } 50 | 51 | class SpeciesList extends StatefulWidget { 52 | final Paginator paginator; 53 | 54 | const SpeciesList({super.key, required this.paginator}); 55 | 56 | @override 57 | State createState() => _SpeciesListState(); 58 | } 59 | 60 | class _SpeciesListState extends State { 61 | Widget _item(Species species) { 62 | return SpeciesCard( 63 | onTap: () => { 64 | router.pushNamed(RoutePaths.species.name, 65 | pathParameters: {'documentId': species.id}).then((value) { 66 | // Refresh list after returning from updating or deleting a species 67 | final refreshProvider = RefreshProvider.of(context); 68 | final updated = 69 | refreshProvider.isDirty(RefreshKeys.UpdatedSpecies); 70 | final deleted = 71 | refreshProvider.isDirty(RefreshKeys.DeletedSpecies); 72 | 73 | if ((updated || deleted) && widget.paginator.refresh != null) { 74 | widget.paginator.refresh!(); 75 | } 76 | }) 77 | }, 78 | taxonomySpecies: species.species, 79 | id: species.id); 80 | } 81 | 82 | @override 83 | Widget build(BuildContext context) { 84 | return SliverPaginationBase( 85 | builder: (List collection) { 86 | return SliverList.builder( 87 | itemCount: collection.length, 88 | itemBuilder: (BuildContext context, int index) { 89 | return Container( 90 | padding: const EdgeInsets.only(bottom: 20.0), 91 | child: _item(collection[index])); 92 | }); 93 | }, 94 | paginator: widget.paginator); 95 | } 96 | } 97 | 98 | class PeachWavesBackground extends Decoration { 99 | const PeachWavesBackground(); 100 | 101 | @override 102 | BoxPainter createBoxPainter([VoidCallback? onChanged]) { 103 | return _PeachWavesPainter(); 104 | } 105 | } 106 | 107 | class _PeachWavesPainter extends BoxPainter { 108 | @override 109 | void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { 110 | final Size? bounds = configuration.size; 111 | 112 | final paint = Paint() 113 | ..color = MeliColors.peach 114 | ..style = PaintingStyle.fill; 115 | 116 | final path = Path(); 117 | path.moveTo(0, 0); 118 | path.lineTo((bounds!.width / 4) * 1, -50.0); 119 | path.lineTo((bounds.width / 4) * 2, 0.0); 120 | path.lineTo((bounds.width / 4) * 3, -50.0); 121 | path.lineTo((bounds.width / 4) * 4, 0.0); 122 | path.lineTo(bounds.width, bounds.height); 123 | path.lineTo(0, bounds.height); 124 | path.close(); 125 | 126 | canvas.drawPath(path.shift(const Offset(0.0, 160.0)), paint); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /packages/app/lib/ui/screens/splash.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:app/ui/colors.dart'; 6 | 7 | class SplashScreen extends StatelessWidget { 8 | const SplashScreen({super.key}); 9 | 10 | @override 11 | Widget build(BuildContext context) { 12 | return Container( 13 | color: MeliColors.magnolia, 14 | padding: const EdgeInsets.all(30.0), 15 | child: const Center( 16 | child: CircularProgressIndicator( 17 | color: MeliColors.black, 18 | ), 19 | )); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/action_buttons.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 5 | 6 | import 'package:app/ui/colors.dart'; 7 | 8 | class ActionButtons extends StatelessWidget { 9 | final VoidCallback? onAction; 10 | final VoidCallback? onCancel; 11 | final String? cancelLabel; 12 | final String? actionLabel; 13 | 14 | const ActionButtons( 15 | {super.key, 16 | this.onAction, 17 | this.onCancel, 18 | this.cancelLabel, 19 | this.actionLabel}); 20 | 21 | @override 22 | Widget build(BuildContext context) { 23 | final t = AppLocalizations.of(context)!; 24 | 25 | return Row(children: [ 26 | OverflowBar( 27 | spacing: 10, 28 | overflowAlignment: OverflowBarAlignment.start, 29 | children: [ 30 | FilledButton( 31 | style: FilledButton.styleFrom( 32 | shape: RoundedRectangleBorder( 33 | borderRadius: BorderRadius.circular(12.0), 34 | ), 35 | foregroundColor: MeliColors.white, 36 | backgroundColor: MeliColors.plum), 37 | onPressed: onAction, 38 | child: Text(actionLabel ?? t.actionDefaultButton), 39 | ), 40 | OutlinedButton( 41 | style: OutlinedButton.styleFrom( 42 | shape: RoundedRectangleBorder( 43 | borderRadius: BorderRadius.circular(12.0), 44 | ), 45 | side: const BorderSide(width: 3.0, color: MeliColors.plum), 46 | foregroundColor: MeliColors.plum), 47 | onPressed: onCancel, 48 | child: Text( 49 | cancelLabel ?? t.actionCancelButton, 50 | style: const TextStyle(fontWeight: FontWeight.bold), 51 | )) 52 | ], 53 | ) 54 | ]); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/alert_dialog.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | class MeliAlertDialog extends StatelessWidget { 6 | final String title; 7 | final String message; 8 | final String labelConfirm; 9 | final VoidCallback? onConfirm; 10 | 11 | const MeliAlertDialog( 12 | {super.key, 13 | required this.title, 14 | required this.message, 15 | required this.labelConfirm, 16 | this.onConfirm}); 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | return AlertDialog( 21 | title: Text(title), 22 | content: Text(message), 23 | actions: [ 24 | TextButton( 25 | onPressed: onConfirm ?? () { 26 | Navigator.pop(context); 27 | }, 28 | child: Text(labelConfirm), 29 | ), 30 | ], 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/button.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | class MeliButton extends StatelessWidget { 6 | final Widget child; 7 | final VoidCallback? onPressed; 8 | 9 | const MeliButton({ 10 | super.key, 11 | required this.child, 12 | this.onPressed, 13 | }); 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return ElevatedButton( 18 | style: ElevatedButton.styleFrom( 19 | elevation: 2.0, 20 | foregroundColor: Colors.black87, 21 | backgroundColor: Colors.white), 22 | onPressed: onPressed, 23 | child: child); 24 | } 25 | } 26 | 27 | class MeliIconButton extends MeliButton { 28 | final Widget icon; 29 | 30 | const MeliIconButton({ 31 | super.key, 32 | required this.icon, 33 | required super.child, 34 | super.onPressed, 35 | }); 36 | 37 | @override 38 | Widget build(BuildContext context) { 39 | return ElevatedButton.icon( 40 | style: ElevatedButton.styleFrom( 41 | elevation: 2.0, 42 | foregroundColor: Colors.black87, 43 | backgroundColor: Colors.white), 44 | onPressed: onPressed, 45 | icon: icon, 46 | label: child, 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/card.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:app/ui/colors.dart'; 6 | 7 | class MeliCard extends StatelessWidget { 8 | final Widget child; 9 | final double elevation; 10 | final Color color; 11 | final double borderWidth; 12 | final Color borderColor; 13 | 14 | const MeliCard( 15 | {super.key, 16 | required this.child, 17 | this.elevation = 5.0, 18 | this.borderWidth = 6.0, 19 | this.borderColor = MeliColors.pink, 20 | this.color = MeliColors.pink}); 21 | 22 | @override 23 | Widget build(BuildContext context) { 24 | return Card( 25 | elevation: elevation, 26 | color: color, 27 | surfaceTintColor: Colors.transparent, 28 | shape: RoundedRectangleBorder( 29 | side: BorderSide( 30 | width: borderWidth, 31 | strokeAlign: BorderSide.strokeAlignCenter, 32 | color: borderColor, 33 | ), 34 | borderRadius: const BorderRadius.all(Radius.circular(12.0)), 35 | ), 36 | child: child); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/card_action_button.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:app/ui/colors.dart'; 6 | 7 | class CardActionButton extends StatelessWidget { 8 | final Icon icon; 9 | final VoidCallback? onPressed; 10 | 11 | const CardActionButton({ 12 | super.key, 13 | required this.icon, 14 | this.onPressed, 15 | }); 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return FloatingActionButton.small( 20 | elevation: 5.0, 21 | heroTag: null, 22 | onPressed: onPressed, 23 | splashColor: MeliColors.flurry, 24 | foregroundColor: MeliColors.black, 25 | backgroundColor: MeliColors.pink, 26 | child: icon); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/card_header.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'package:app/ui/colors.dart'; 4 | 5 | class MeliCardHeader extends StatelessWidget { 6 | final String title; 7 | final Widget? icon; 8 | final VoidCallback? onPress; 9 | 10 | const MeliCardHeader( 11 | {super.key, required this.title, this.icon, this.onPress}); 12 | 13 | Widget _title() { 14 | return Text( 15 | title, 16 | style: const TextStyle( 17 | fontSize: 16.0, 18 | fontWeight: FontWeight.w500, 19 | ), 20 | ); 21 | } 22 | 23 | Widget _icon() { 24 | return icon != null ? icon! : const SizedBox.shrink(); 25 | } 26 | 27 | @override 28 | Widget build(BuildContext context) { 29 | return Container( 30 | height: 72.0, 31 | decoration: const BoxDecoration( 32 | color: Colors.white, 33 | border: Border.fromBorderSide(BorderSide( 34 | width: 6.0, 35 | strokeAlign: BorderSide.strokeAlignCenter, 36 | color: MeliColors.pink, 37 | )), 38 | borderRadius: BorderRadius.all(Radius.circular(12.0))), 39 | child: Material( 40 | color: Colors.white, 41 | borderRadius: BorderRadius.circular(8.0), 42 | clipBehavior: Clip.hardEdge, 43 | child: InkWell( 44 | splashColor: MeliColors.pink.withOpacity(0.5), 45 | onTap: onPress, 46 | child: Container( 47 | alignment: AlignmentDirectional.centerStart, 48 | padding: EdgeInsets.symmetric( 49 | vertical: icon != null ? 9.0 : 14.0, horizontal: 16.0), 50 | child: Row( 51 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 52 | children: [ 53 | _title(), 54 | _icon(), 55 | ]), 56 | ), 57 | ), 58 | ), 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/confirm_dialog.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | class ConfirmDialog extends StatelessWidget { 6 | final String title; 7 | final String message; 8 | final String labelAbort; 9 | final String labelConfirm; 10 | final VoidCallback? onAbort; 11 | final VoidCallback onConfirm; 12 | 13 | const ConfirmDialog( 14 | {super.key, 15 | required this.title, 16 | required this.message, 17 | required this.labelAbort, 18 | required this.labelConfirm, 19 | this.onAbort, 20 | required this.onConfirm}); 21 | 22 | @override 23 | Widget build(BuildContext context) { 24 | return AlertDialog( 25 | title: Text(title), 26 | content: Text(message), 27 | actions: [ 28 | TextButton( 29 | onPressed: onAbort ?? () { 30 | Navigator.pop(context); 31 | }, 32 | child: Text(labelAbort), 33 | ), 34 | TextButton( 35 | onPressed: onConfirm, 36 | child: Text(labelConfirm), 37 | ), 38 | ], 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/counter.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:app/ui/colors.dart'; 6 | 7 | class Counter extends StatelessWidget { 8 | final int value; 9 | 10 | const Counter(this.value, {super.key}); 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | return Container( 15 | width: 35.0, 16 | height: 22.0, 17 | decoration: BoxDecoration( 18 | color: MeliColors.plum, 19 | shape: BoxShape.rectangle, 20 | borderRadius: BorderRadius.circular(8.0), 21 | border: Border.all( 22 | color: MeliColors.plum, 23 | width: 2.0, 24 | style: BorderStyle.solid, 25 | ), 26 | ), 27 | child: Center( 28 | child: Text( 29 | value.toString(), 30 | style: const TextStyle( 31 | color: MeliColors.white, 32 | fontSize: 13.0, 33 | fontWeight: FontWeight.w900, 34 | ), 35 | ), 36 | ), 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/editable_card.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:app/ui/widgets/card.dart'; 6 | import 'package:app/ui/widgets/card_action_button.dart'; 7 | import 'package:app/ui/widgets/card_header.dart'; 8 | 9 | class EditableCard extends StatelessWidget { 10 | final String title; 11 | final Widget child; 12 | final VoidCallback onChanged; 13 | final bool isEditMode; 14 | 15 | const EditableCard( 16 | {super.key, 17 | this.isEditMode = false, 18 | required this.title, 19 | required this.child, 20 | required this.onChanged}); 21 | 22 | Widget _icon() { 23 | final icon = isEditMode 24 | ? const Icon(Icons.close_outlined) 25 | : const Icon(Icons.mode_edit_outlined); 26 | return CardActionButton(icon: icon, onPressed: onChanged); 27 | } 28 | 29 | Widget _content() { 30 | return Column( 31 | children: [ 32 | MeliCardHeader( 33 | title: title, 34 | icon: _icon(), 35 | onPress: () { 36 | onChanged.call(); 37 | }), 38 | Container( 39 | padding: 40 | const EdgeInsets.symmetric(vertical: 10.0, horizontal: 18.0), 41 | child: child), 42 | ], 43 | ); 44 | } 45 | 46 | @override 47 | Widget build(BuildContext context) { 48 | return MeliCard(child: _content()); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/error_card.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:app/ui/widgets/icon_message_card.dart'; 6 | import 'package:app/ui/colors.dart'; 7 | 8 | class ErrorCard extends StatelessWidget { 9 | final String message; 10 | 11 | const ErrorCard({super.key, required this.message}); 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return IconMessageCard( 16 | color: MeliColors.peach, 17 | message: message, 18 | icon: Icons.warning_rounded, 19 | ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/expandable_card.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:app/ui/widgets/card.dart'; 6 | import 'package:app/ui/widgets/card_action_button.dart'; 7 | import 'package:app/ui/widgets/card_header.dart'; 8 | import 'package:app/ui/widgets/expansion_tile.dart'; 9 | 10 | class ExpandableCard extends StatefulWidget { 11 | final String title; 12 | final Widget child; 13 | 14 | const ExpandableCard({super.key, required this.title, required this.child}); 15 | 16 | @override 17 | State createState() => _ExpandableCardState(); 18 | } 19 | 20 | class _ExpandableCardState extends State { 21 | bool isExpanded = false; 22 | 23 | final GlobalKey tileKey = GlobalKey(); 24 | 25 | Widget _icon() { 26 | final icon = isExpanded ? const Icon(Icons.remove) : const Icon(Icons.add); 27 | return CardActionButton( 28 | icon: icon, 29 | onPressed: () { 30 | tileKey.currentState!.toggle(); 31 | }); 32 | } 33 | 34 | Widget _content() { 35 | return MeliExpansionTile( 36 | key: tileKey, 37 | header: MeliCardHeader( 38 | title: widget.title, 39 | icon: _icon(), 40 | onPress: () { 41 | tileKey.currentState!.toggle(); 42 | }, 43 | ), 44 | child: widget.child, 45 | onExpansionChanged: (isExpanded) { 46 | setState(() { 47 | this.isExpanded = isExpanded; 48 | }); 49 | }, 50 | ); 51 | } 52 | 53 | @override 54 | Widget build(BuildContext context) { 55 | return MeliCard(child: _content()); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/expansion_tile.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | const Duration _kExpand = Duration(milliseconds: 200); 6 | 7 | class MeliExpansionTile extends StatefulWidget { 8 | final Widget header; 9 | final Widget child; 10 | final EdgeInsetsGeometry childrenPadding; 11 | final ValueChanged? onExpansionChanged; 12 | 13 | const MeliExpansionTile({ 14 | super.key, 15 | required this.header, 16 | required this.child, 17 | this.onExpansionChanged, 18 | this.childrenPadding = 19 | const EdgeInsets.only(top: 5.0, right: 8.0, bottom: 8.0, left: 8.0), 20 | }); 21 | 22 | @override 23 | State createState() => MeliExpansionTileState(); 24 | } 25 | 26 | class MeliExpansionTileState extends State 27 | with SingleTickerProviderStateMixin { 28 | static final Animatable _easeInTween = 29 | CurveTween(curve: Curves.easeIn); 30 | 31 | late AnimationController _controller; 32 | late Animation _heightFactor; 33 | 34 | bool _isExpanded = false; 35 | 36 | @override 37 | void initState() { 38 | super.initState(); 39 | _controller = AnimationController(duration: _kExpand, vsync: this); 40 | _heightFactor = _controller.drive(_easeInTween); 41 | 42 | if (_isExpanded) { 43 | _controller.value = 1.0; 44 | } 45 | } 46 | 47 | @override 48 | void dispose() { 49 | _controller.dispose(); 50 | super.dispose(); 51 | } 52 | 53 | void expand() { 54 | _setExpanded(true); 55 | } 56 | 57 | void collapse() { 58 | _setExpanded(false); 59 | } 60 | 61 | void toggle() { 62 | _setExpanded(!_isExpanded); 63 | } 64 | 65 | void expandedChanged(bool isExpanded) { 66 | _setExpanded(isExpanded); 67 | } 68 | 69 | void _setExpanded(bool isExpanded) { 70 | if (_isExpanded != isExpanded) { 71 | setState(() { 72 | _isExpanded = isExpanded; 73 | if (_isExpanded) { 74 | _controller.forward(); 75 | } else { 76 | _controller.reverse().then((_) { 77 | setState(() { 78 | // Rebuild without widget.children. 79 | }); 80 | }); 81 | } 82 | PageStorage.of(context).writeState(context, _isExpanded); 83 | }); 84 | if (widget.onExpansionChanged != null) { 85 | widget.onExpansionChanged!(_isExpanded); 86 | } 87 | } 88 | } 89 | 90 | void _handleTap() { 91 | setState(() { 92 | _isExpanded = !_isExpanded; 93 | if (_isExpanded) { 94 | _controller.forward(); 95 | } else { 96 | _controller.reverse().then((void value) { 97 | if (!mounted) { 98 | return; 99 | } 100 | setState(() { 101 | // Rebuild without widget.children. 102 | }); 103 | }); 104 | } 105 | PageStorage.of(context).writeState(context, _isExpanded); 106 | }); 107 | widget.onExpansionChanged?.call(_isExpanded); 108 | } 109 | 110 | Widget _buildChildren(BuildContext context, Widget? child) { 111 | return Column( 112 | mainAxisSize: MainAxisSize.min, 113 | children: [ 114 | GestureDetector( 115 | onTap: _handleTap, 116 | child: widget.header, 117 | ), 118 | ClipRect( 119 | child: Align( 120 | alignment: Alignment.topLeft, 121 | heightFactor: _heightFactor.value, 122 | child: child, 123 | ), 124 | ), 125 | ], 126 | ); 127 | } 128 | 129 | @override 130 | Widget build(BuildContext context) { 131 | final bool closed = !_isExpanded && _controller.isDismissed; 132 | final bool shouldRemoveChildren = closed; 133 | 134 | final Widget result = Offstage( 135 | offstage: closed, 136 | child: TickerMode( 137 | enabled: !closed, 138 | child: Padding( 139 | padding: widget.childrenPadding, 140 | child: widget.child, 141 | ), 142 | ), 143 | ); 144 | 145 | return AnimatedBuilder( 146 | animation: _controller.view, 147 | builder: _buildChildren, 148 | child: shouldRemoveChildren ? null : result, 149 | ); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/fab.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:app/ui/colors.dart'; 6 | 7 | class MeliFloatingActionButton extends StatefulWidget { 8 | final Icon icon; 9 | final VoidCallback onPressed; 10 | final Color backgroundColor; 11 | final bool disabled; 12 | 13 | const MeliFloatingActionButton( 14 | {super.key, 15 | required this.icon, 16 | required this.onPressed, 17 | this.disabled = false, 18 | this.backgroundColor = MeliColors.magnolia}); 19 | 20 | @override 21 | State createState() => 22 | _MeliFloatingActionButtonState(); 23 | } 24 | 25 | class _MeliFloatingActionButtonState extends State { 26 | @override 27 | Widget build(BuildContext context) { 28 | return Container( 29 | height: 120, 30 | alignment: Alignment.bottomCenter, 31 | child: FloatingActionButton( 32 | foregroundColor: MeliColors.black, 33 | backgroundColor: 34 | widget.disabled ? Colors.grey[400] : widget.backgroundColor, 35 | heroTag: null, 36 | shape: const CircleBorder(), 37 | onPressed: widget.disabled ? null : widget.onPressed, 38 | child: widget.icon, 39 | ), 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/icon_message_card.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:app/ui/colors.dart'; 6 | import 'package:app/ui/widgets/card.dart'; 7 | 8 | class IconMessageCard extends StatelessWidget { 9 | final String message; 10 | final IconData icon; 11 | final Color color; 12 | 13 | const IconMessageCard( 14 | {super.key, 15 | required this.message, 16 | required this.icon, 17 | required this.color}); 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | return MeliCard( 22 | elevation: 3.0, 23 | color: color, 24 | borderWidth: 0.0, 25 | child: SizedBox( 26 | width: double.infinity, 27 | child: Container( 28 | padding: const EdgeInsets.all(20.0), 29 | child: Column(children: [ 30 | Icon( 31 | icon, 32 | size: 40.0, 33 | color: MeliColors.black, 34 | ), 35 | const SizedBox(height: 10.0), 36 | Text(message, 37 | textAlign: TextAlign.center, 38 | style: Theme.of(context).textTheme.bodyLarge), 39 | ])), 40 | )); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/image.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 5 | 6 | import 'package:app/io/files.dart'; 7 | import 'package:app/models/blobs.dart'; 8 | import 'package:app/ui/colors.dart'; 9 | 10 | class MeliImage extends StatelessWidget { 11 | final Blob? image; 12 | final String? externalError; 13 | 14 | const MeliImage({super.key, required this.image, this.externalError}); 15 | 16 | Widget _error(BuildContext context, String message, IconData icon) { 17 | return Container( 18 | color: MeliColors.peach, 19 | child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ 20 | Icon( 21 | icon, 22 | size: 40.0, 23 | color: MeliColors.black, 24 | ), 25 | const SizedBox(height: 10.0), 26 | Text(message, 27 | textAlign: TextAlign.center, 28 | style: Theme.of(context).textTheme.bodyLarge), 29 | ])); 30 | } 31 | 32 | @override 33 | Widget build(BuildContext context) { 34 | if (externalError != null) { 35 | return _error(context, externalError!, Icons.warning_rounded); 36 | } 37 | 38 | if (image == null) { 39 | return _error(context, AppLocalizations.of(context)!.imageMissingError, 40 | Icons.image_not_supported); 41 | } 42 | 43 | return Image.network( 44 | '$BLOBS_BASE_PATH/${image!.id}', 45 | fit: BoxFit.cover, 46 | filterQuality: FilterQuality.high, 47 | frameBuilder: (BuildContext context, Widget child, int? frame, 48 | bool wasSynchronouslyLoaded) { 49 | if (wasSynchronouslyLoaded) { 50 | return child; 51 | } 52 | 53 | return AnimatedOpacity( 54 | opacity: frame == null ? 0 : 1, 55 | duration: const Duration(milliseconds: 150), 56 | curve: Curves.easeOut, 57 | child: child, 58 | ); 59 | }, 60 | loadingBuilder: (BuildContext context, Widget child, 61 | ImageChunkEvent? loadingProgress) { 62 | if (loadingProgress == null) return child; 63 | return const Center( 64 | child: CircularProgressIndicator(color: MeliColors.black), 65 | ); 66 | }, 67 | errorBuilder: (context, error, stack) { 68 | return _error(context, AppLocalizations.of(context)!.imageLoadingError, 69 | Icons.warning_rounded); 70 | }, 71 | ); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/image_provider.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'dart:io'; 4 | 5 | import 'package:flutter/material.dart'; 6 | import 'package:image_picker/image_picker.dart'; 7 | 8 | class MeliCameraProvider extends StatefulWidget { 9 | const MeliCameraProvider(this.child, {super.key}); 10 | final Widget child; 11 | 12 | @override 13 | MeliCameraProviderState createState() => MeliCameraProviderState(); 14 | } 15 | 16 | class MeliCameraProviderState extends State { 17 | final _imagePicker = ImagePicker(); 18 | String? _retrieveDataError; 19 | 20 | Future retrieveLostData() async { 21 | final LostDataResponse response = await _imagePicker.retrieveLostData(); 22 | 23 | if (response.file != null) { 24 | return File(response.file!.path); 25 | } else if (response.exception != null) { 26 | _retrieveDataError = response.exception!.code; 27 | print('CameraProvider error: $_retrieveDataError'); 28 | } 29 | 30 | return null; 31 | } 32 | 33 | // Pick photos from the gallery. 34 | Future> pickFromGallery() async { 35 | List imageFiles = 36 | await _imagePicker.pickMultiImage(imageQuality: 80); 37 | 38 | return imageFiles.map((file) { 39 | return File(file.path); 40 | }).toList(); 41 | } 42 | 43 | // Capture a photo from the camera. 44 | Future capturePhoto() async { 45 | ImageSource source = ImageSource.camera; 46 | if (!_imagePicker.supportsImageSource(source)) { 47 | source = ImageSource.gallery; 48 | } 49 | 50 | XFile? imageFile = await _imagePicker.pickImage( 51 | source: ImageSource.camera, 52 | imageQuality: 80, 53 | preferredCameraDevice: CameraDevice.rear); 54 | 55 | if (imageFile != null) { 56 | return File(imageFile.path); 57 | } else { 58 | return null; 59 | } 60 | } 61 | 62 | Widget renderChildren() { 63 | return MeliCameraProviderInherited(state: this, child: widget.child); 64 | } 65 | 66 | @override 67 | Widget build(BuildContext context) { 68 | return MeliCameraProviderInherited(state: this, child: widget.child); 69 | } 70 | } 71 | 72 | class MeliCameraProviderInherited extends InheritedWidget { 73 | final MeliCameraProviderState state; 74 | 75 | const MeliCameraProviderInherited({ 76 | super.key, 77 | required this.state, 78 | required super.child, 79 | }); 80 | 81 | static MeliCameraProviderState of(BuildContext context) { 82 | return context 83 | .dependOnInheritedWidgetOfExactType()! 84 | .state; 85 | } 86 | 87 | @override 88 | bool updateShouldNotify(covariant InheritedWidget oldWidget) { 89 | return false; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/info_card.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:app/ui/colors.dart'; 6 | import 'package:app/ui/widgets/icon_message_card.dart'; 7 | 8 | class InfoCard extends StatelessWidget { 9 | final String message; 10 | 11 | const InfoCard({super.key, required this.message}); 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return IconMessageCard( 16 | color: MeliColors.white, 17 | message: message, 18 | icon: Icons.flare, 19 | ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/loading_overlay.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:app/ui/colors.dart'; 6 | 7 | class LoadingOverlay extends StatefulWidget { 8 | const LoadingOverlay({super.key, required this.child}); 9 | 10 | final Widget child; 11 | 12 | @override 13 | State createState() => LoadingOverlayState(); 14 | } 15 | 16 | class LoadingOverlayState extends State { 17 | bool _isLoading = false; 18 | 19 | void show() { 20 | setState(() { 21 | _isLoading = true; 22 | }); 23 | } 24 | 25 | void hide() { 26 | setState(() { 27 | _isLoading = false; 28 | }); 29 | } 30 | 31 | @override 32 | Widget build(BuildContext context) { 33 | return Stack( 34 | children: [ 35 | widget.child, 36 | if (_isLoading) 37 | const Opacity( 38 | opacity: 0.7, 39 | child: ModalBarrier(dismissible: false, color: MeliColors.electric), 40 | ), 41 | if (_isLoading) 42 | const Center( 43 | child: CircularProgressIndicator(color: MeliColors.black), 44 | ), 45 | ], 46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/local_name_autocomplete.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:graphql_flutter/graphql_flutter.dart'; 5 | 6 | import 'package:app/io/graphql/graphql.dart'; 7 | import 'package:app/models/base.dart'; 8 | import 'package:app/models/local_names.dart'; 9 | import 'package:app/ui/widgets/autocomplete.dart'; 10 | 11 | typedef OnChanged = void Function(AutocompleteItem); 12 | typedef OnSubmit = void Function(AutocompleteItem); 13 | 14 | class LocalNameAutocomplete extends StatefulWidget { 15 | final OnChanged onChanged; 16 | final VoidCallback? onSubmit; 17 | final AutocompleteItem? initialValue; 18 | final bool autofocus; 19 | 20 | const LocalNameAutocomplete( 21 | {super.key, 22 | required this.onChanged, 23 | this.onSubmit, 24 | this.autofocus = false, 25 | this.initialValue}); 26 | 27 | @override 28 | State createState() => _LocalNameAutocompleteState(); 29 | } 30 | 31 | class _LocalNameAutocompleteState extends State { 32 | @override 33 | Widget build(BuildContext context) { 34 | return MeliAutocomplete( 35 | onChanged: widget.onChanged, 36 | onSubmit: widget.onSubmit, 37 | initialValue: widget.initialValue, 38 | autofocus: widget.autofocus, 39 | onOptionsRequest: (String value) async { 40 | try { 41 | final QueryResult result = await client.query( 42 | QueryOptions(document: gql(searchLocalNamesQuery(value)))); 43 | 44 | if (result.hasException) { 45 | throw result.exception!; 46 | } 47 | 48 | final List documents = 49 | result.data![DEFAULT_RESULTS_KEY]['documents'] as List; 50 | 51 | final List options = []; 52 | Set seen = {}; 53 | 54 | for (var document in documents) { 55 | final localName = 56 | LocalName.fromJson(document as Map); 57 | 58 | // De-duplicate results with same "name" value 59 | if (seen.contains(localName.name)) { 60 | continue; 61 | } 62 | 63 | seen.add(localName.name); 64 | options.add(AutocompleteItem( 65 | value: localName.name, 66 | documentId: localName.id, 67 | viewId: localName.viewId)); 68 | } 69 | 70 | return options.toList(); 71 | } catch (error) { 72 | print(error.toString()); 73 | return []; 74 | } 75 | }); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/local_name_field.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 5 | 6 | import 'package:app/models/local_names.dart'; 7 | import 'package:app/ui/widgets/autocomplete.dart'; 8 | import 'package:app/ui/widgets/editable_card.dart'; 9 | import 'package:app/ui/widgets/local_name_autocomplete.dart'; 10 | import 'package:app/ui/widgets/read_only_value.dart'; 11 | import 'package:app/ui/widgets/action_buttons.dart'; 12 | 13 | typedef OnUpdate = Future Function(AutocompleteItem?); 14 | 15 | class LocalNameField extends StatefulWidget { 16 | final LocalName? current; 17 | final OnUpdate onUpdate; 18 | 19 | const LocalNameField(this.current, {super.key, required this.onUpdate}); 20 | 21 | @override 22 | State createState() => _LocalNameFieldState(); 23 | } 24 | 25 | class _LocalNameFieldState extends State { 26 | /// Flag indicating if we're currently editing the field or not. 27 | bool isEditMode = false; 28 | 29 | /// Contains changed value when user adjusted the field. 30 | AutocompleteItem? _dirty; 31 | 32 | void _toggleEditMode() { 33 | setState(() { 34 | isEditMode = !isEditMode; 35 | }); 36 | } 37 | 38 | void _cancel() { 39 | setState(() { 40 | isEditMode = !isEditMode; 41 | }); 42 | } 43 | 44 | void _onChanged(AutocompleteItem newValue) { 45 | _dirty = newValue; 46 | } 47 | 48 | void _onSubmit() { 49 | if (_dirty == null) { 50 | // Nothing has changed 51 | } else if (_dirty!.value == '') { 52 | // Value is empty, we consider the user wants to remove it 53 | widget.onUpdate.call(null); 54 | } else { 55 | // Value gets updated (either with item from database or something new) 56 | widget.onUpdate.call(_dirty!); 57 | } 58 | 59 | // Any time the submit method is triggered we toggle out of edit mode 60 | _toggleEditMode(); 61 | } 62 | 63 | Widget _editableValue() { 64 | // Convert existing LocalName for autocomplete widget. This will then also 65 | // contain the id and view id of that document 66 | AutocompleteItem? initialValue = widget.current != null 67 | ? AutocompleteItem( 68 | value: widget.current!.name, // Display value 69 | documentId: widget.current!.id, 70 | viewId: widget.current!.viewId) 71 | : null; 72 | 73 | return LocalNameAutocomplete( 74 | initialValue: initialValue, onSubmit: _onSubmit, onChanged: _onChanged); 75 | } 76 | 77 | @override 78 | Widget build(BuildContext context) { 79 | String? displayValue = widget.current?.name; 80 | 81 | return EditableCard( 82 | title: AppLocalizations.of(context)!.localNameCardTitle, 83 | isEditMode: isEditMode, 84 | onChanged: _toggleEditMode, 85 | child: Column( 86 | children: isEditMode 87 | ? [ 88 | _editableValue(), 89 | Padding( 90 | padding: const EdgeInsets.only(top: 8.0), 91 | child: ActionButtons( 92 | onAction: _onSubmit, 93 | onCancel: _cancel, 94 | )) 95 | ] 96 | : [ReadOnlyValue(displayValue)])); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/local_names_dedup_tag_list.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:app/models/local_names.dart'; 6 | import 'package:app/ui/widgets/pagination_list.dart'; 7 | import 'package:app/models/base.dart'; 8 | 9 | class DeduplicatedLocalNamesList extends StatelessWidget { 10 | final PaginationBuilder builder; 11 | final Paginator paginator; 12 | final minimumPageSize = 10; 13 | 14 | const DeduplicatedLocalNamesList( 15 | {super.key, required this.builder, required this.paginator}); 16 | 17 | bool _fetchMoreOverride(PaginatedCollection data) { 18 | // This collection queries all sightings, filtering by species id, and we 19 | // collect the first name in the `local_names` list field. This results in 20 | // duplicate local names being added to the list which are related to from 21 | // multiple sightings. 22 | // 23 | // In this "fetch override" method we: 24 | // 1) deduplicate the returned document collection items 25 | // 2) if we haven't met the desired quota, signal that the next page 26 | // should be fetched 27 | Set seen = {}; 28 | data.documents.retainWhere((localName) => seen.add(localName.id)); 29 | 30 | // We want to keep fetching more results (while there are any) until 31 | // we meet the minimum page size. 32 | return data.documents.length < minimumPageSize && data.hasNextPage; 33 | } 34 | 35 | @override 36 | Widget build(BuildContext context) { 37 | return PaginationBase( 38 | fetchMoreOverride: _fetchMoreOverride, 39 | paginator: paginator, 40 | builder: (List collection) { 41 | return Wrap( 42 | crossAxisAlignment: WrapCrossAlignment.center, 43 | children: [ 44 | ...collection.map((document) => builder(document)), 45 | ], 46 | ); 47 | }, 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/read_only_value.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | typedef ReadOnlyBuilder = Widget Function(T value); 6 | 7 | class ReadOnlyBase extends StatelessWidget { 8 | final T? value; 9 | final ReadOnlyBuilder builder; 10 | 11 | const ReadOnlyBase({super.key, required this.value, required this.builder}); 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | final noValueGivenSymbol = 16 | (['🤷', '🤷🏻', '🤷🏼', '🤷🏽', '🤷🏾', '🤷🏿']..shuffle()).first; 17 | 18 | return Container( 19 | alignment: Alignment.center, 20 | child: value == null 21 | ? Text( 22 | noValueGivenSymbol, 23 | textAlign: TextAlign.left, 24 | style: const TextStyle( 25 | fontSize: 35.0, shadows: [Shadow(blurRadius: 1.0)]), 26 | ) 27 | : builder(value as T), 28 | ); 29 | } 30 | } 31 | 32 | class ReadOnlyValue extends StatelessWidget { 33 | final String? value; 34 | 35 | const ReadOnlyValue(this.value, {super.key}); 36 | 37 | @override 38 | Widget build(BuildContext context) { 39 | return ReadOnlyBase( 40 | value: value, 41 | builder: (String str) { 42 | return Container( 43 | alignment: Alignment.center, 44 | height: 48, 45 | child: Text( 46 | str, 47 | textAlign: TextAlign.left, 48 | style: const TextStyle(fontSize: 16.0), 49 | ), 50 | ); 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/refresh_provider.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/widgets.dart'; 4 | 5 | /// Actions which might affect other views. 6 | enum RefreshKeys { 7 | CreatedSighting, 8 | UpdatedSighting, 9 | DeletedSighting, 10 | UpdatedSpecies, 11 | DeletedSpecies, 12 | } 13 | 14 | /// Global state keeping track of events where data was changed or created. This 15 | /// is a way to inform widgets somewhere else in the app that they'll need to 16 | /// reload data from the node and re-render as things have changed. 17 | class RefreshProvider extends InheritedWidget { 18 | final Map _map = {}; 19 | 20 | RefreshProvider({ 21 | super.key, 22 | required super.child, 23 | }); 24 | 25 | /// Flip dirty flag. 26 | void setDirty(RefreshKeys key) { 27 | _map[key] = true; 28 | } 29 | 30 | /// Returns status of "dirty" flag and flips it to false afterwards. 31 | bool isDirty(RefreshKeys key) { 32 | if (!_map.containsKey(key)) { 33 | return false; 34 | } 35 | 36 | bool status = _map[key]!; 37 | _map[key] = false; 38 | return status; 39 | } 40 | 41 | static RefreshProvider of(BuildContext context) { 42 | return context.dependOnInheritedWidgetOfExactType()!; 43 | } 44 | 45 | @override 46 | bool updateShouldNotify(RefreshProvider oldWidget) { 47 | return false; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/scaffold.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:app/ui/colors.dart'; 6 | 7 | class MeliScaffold extends StatefulWidget { 8 | final String? title; 9 | final Widget? body; 10 | final Widget? actionRight; 11 | final Color backgroundColor; 12 | final Color appBarColor; 13 | final List floatingActionButtons; 14 | final MainAxisAlignment fabAlignment; 15 | 16 | const MeliScaffold( 17 | {super.key, 18 | this.body, 19 | this.title, 20 | this.actionRight, 21 | this.floatingActionButtons = const [], 22 | this.fabAlignment = MainAxisAlignment.spaceBetween, 23 | this.appBarColor = MeliColors.flurry, 24 | this.backgroundColor = MeliColors.flurry}); 25 | 26 | @override 27 | State createState() => _MeliScaffoldState(); 28 | } 29 | 30 | class _MeliScaffoldState extends State { 31 | AppBar? _appBar() { 32 | if (widget.title != null) { 33 | return AppBar( 34 | automaticallyImplyLeading: false, 35 | scrolledUnderElevation: 3.0, 36 | surfaceTintColor: Colors.transparent, 37 | shadowColor: Colors.black54, 38 | forceMaterialTransparency: false, 39 | backgroundColor: widget.appBarColor, 40 | title: Stack( 41 | children: [ 42 | Row(mainAxisAlignment: MainAxisAlignment.center, children: [ 43 | IconButton( 44 | icon: const Icon(Icons.arrow_back_rounded), 45 | onPressed: () { 46 | Navigator.of(context).pop(); 47 | }), 48 | const SizedBox(width: 7.0), 49 | Text(widget.title!), 50 | const SizedBox(width: 35.0), 51 | ]), 52 | if (widget.actionRight != null) 53 | Row( 54 | mainAxisAlignment: MainAxisAlignment.end, 55 | children: [widget.actionRight!]), 56 | ], 57 | ), 58 | ); 59 | } 60 | 61 | return null; 62 | } 63 | 64 | Widget? _floatingActionButtons() { 65 | if (widget.floatingActionButtons.isNotEmpty) { 66 | return Container( 67 | padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 40.0), 68 | child: Row( 69 | mainAxisAlignment: widget.fabAlignment, 70 | children: widget.floatingActionButtons, 71 | )); 72 | } 73 | 74 | return null; 75 | } 76 | 77 | @override 78 | Widget build(BuildContext context) { 79 | return Scaffold( 80 | backgroundColor: widget.backgroundColor, 81 | appBar: _appBar(), 82 | body: widget.body, 83 | floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, 84 | floatingActionButton: _floatingActionButtons(), 85 | ); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/sighting_card.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 5 | 6 | import 'package:app/models/local_names.dart'; 7 | import 'package:app/models/species.dart'; 8 | import 'package:app/models/blobs.dart'; 9 | import 'package:app/ui/colors.dart'; 10 | import 'package:app/ui/widgets/card.dart'; 11 | import 'package:app/ui/widgets/image.dart'; 12 | 13 | class SightingCard extends StatefulWidget { 14 | final Blob? image; 15 | final DateTime date; 16 | final LocalName? localName; 17 | final Species? species; 18 | 19 | final VoidCallback onTap; 20 | 21 | const SightingCard( 22 | {super.key, 23 | this.localName, 24 | required this.onTap, 25 | required this.date, 26 | this.image, 27 | this.species}); 28 | 29 | @override 30 | State createState() => _SightingCardState(); 31 | } 32 | 33 | class _SightingCardState extends State { 34 | bool isSelected = false; 35 | 36 | Widget get _title { 37 | String title = AppLocalizations.of(context)!.sightingUnspecified; 38 | 39 | if (widget.species != null && widget.species!.species != null) { 40 | title = widget.species!.species!.name; 41 | } else if (widget.localName != null) { 42 | title = widget.localName!.name; 43 | } 44 | 45 | return Text(title, 46 | style: const TextStyle( 47 | fontSize: 20.0, 48 | fontFamily: 'Staatliches', 49 | overflow: TextOverflow.ellipsis)); 50 | } 51 | 52 | Widget get _icon { 53 | if (widget.species == null) { 54 | return const Icon(Icons.question_mark); 55 | } 56 | 57 | return const SizedBox.shrink(); 58 | } 59 | 60 | Widget get _date { 61 | return Text('${widget.date.day}.${widget.date.month}.${widget.date.year}'); 62 | } 63 | 64 | @override 65 | Widget build(BuildContext context) { 66 | return GestureDetector( 67 | onTapDown: (details) { 68 | setState(() { 69 | isSelected = true; 70 | }); 71 | }, 72 | onTapUp: (details) { 73 | setState(() { 74 | isSelected = false; 75 | }); 76 | }, 77 | onTapCancel: () { 78 | setState(() { 79 | isSelected = false; 80 | }); 81 | }, 82 | onTap: Feedback.wrapForTap(widget.onTap, context), 83 | child: MeliCard( 84 | elevation: 0, 85 | borderWidth: 3.0, 86 | color: MeliColors.white, 87 | borderColor: isSelected ? MeliColors.black : MeliColors.white, 88 | child: Column(children: [ 89 | Container( 90 | alignment: AlignmentDirectional.centerStart, 91 | padding: const EdgeInsets.all(10), 92 | child: Column( 93 | crossAxisAlignment: CrossAxisAlignment.start, 94 | children: [ 95 | Row( 96 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 97 | children: [ 98 | Flexible(child: _title), 99 | Padding( 100 | padding: const EdgeInsets.only(left: 20.0), 101 | child: _icon, 102 | ), 103 | ]), 104 | _date, 105 | ], 106 | ), 107 | ), 108 | Container( 109 | clipBehavior: Clip.hardEdge, 110 | height: 200.0, 111 | width: double.infinity, 112 | decoration: const ShapeDecoration( 113 | shape: RoundedRectangleBorder( 114 | borderRadius: BorderRadius.only( 115 | bottomLeft: Radius.circular(12.0), 116 | bottomRight: Radius.circular(12.0)), 117 | ), 118 | ), 119 | child: MeliImage(image: widget.image), 120 | ) 121 | ])), 122 | ); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/sighting_popup_menu.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:file_picker/file_picker.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 6 | 7 | import 'package:app/io/images.dart'; 8 | import 'package:app/models/sightings.dart'; 9 | import 'package:app/router.dart'; 10 | import 'package:app/ui/widgets/confirm_dialog.dart'; 11 | import 'package:app/ui/widgets/refresh_provider.dart'; 12 | 13 | class SightingPopupMenu extends StatelessWidget { 14 | final Sighting sighting; 15 | 16 | const SightingPopupMenu({super.key, required this.sighting}); 17 | 18 | void _onExportImages(BuildContext context) async { 19 | String? selectedDirectory = await FilePicker.platform.getDirectoryPath(); 20 | 21 | if (selectedDirectory != null) { 22 | await downloadAndExportImages( 23 | sighting.images.map((blob) { 24 | return blob.id; 25 | }).toList(), 26 | selectedDirectory); 27 | } 28 | } 29 | 30 | void _onDelete(BuildContext context) { 31 | final messenger = ScaffoldMessenger.of(context); 32 | final t = AppLocalizations.of(context)!; 33 | final refreshProvider = RefreshProvider.of(context); 34 | 35 | showDialog( 36 | context: context, 37 | builder: (BuildContext context) => ConfirmDialog( 38 | title: t.sightingDeleteAlertTitle, 39 | message: t.sightingDeleteAlertBody, 40 | labelAbort: t.sightingDeleteAlertCancel, 41 | labelConfirm: t.sightingDeleteAlertConfirm, 42 | onConfirm: () async { 43 | await sighting.delete(); 44 | 45 | // Set flag for other widgets to tell them that they might need to 46 | // re-render their data. This will make sure that our updates are 47 | // reflected in the UI 48 | refreshProvider.setDirty(RefreshKeys.DeletedSighting); 49 | 50 | // First pop closes this dialog, second goes back to the view we came 51 | // from 52 | router.pop(); 53 | router.pop(); 54 | 55 | messenger.showSnackBar( 56 | SnackBar(content: Text(t.sightingDeleteConfirmation))); 57 | }, 58 | ), 59 | ); 60 | } 61 | 62 | @override 63 | Widget build(BuildContext context) { 64 | final t = AppLocalizations.of(context)!; 65 | 66 | return PopupMenuButton( 67 | itemBuilder: (BuildContext context) => [ 68 | PopupMenuItem( 69 | child: Text(t.exportImages), 70 | onTap: () { 71 | _onExportImages(context); 72 | }), 73 | PopupMenuItem( 74 | child: Text(t.deleteSighting), 75 | onTap: () { 76 | _onDelete(context); 77 | }), 78 | ]); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/sightings_list.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:app/models/base.dart'; 6 | import 'package:app/models/sightings.dart'; 7 | import 'package:app/router.dart'; 8 | import 'package:app/ui/widgets/pagination_list.dart'; 9 | import 'package:app/ui/widgets/refresh_provider.dart'; 10 | import 'package:app/ui/widgets/sighting_card.dart'; 11 | 12 | class SightingsList extends StatefulWidget { 13 | final Paginator paginator; 14 | 15 | const SightingsList({super.key, required this.paginator}); 16 | 17 | @override 18 | State createState() => _SightingsListState(); 19 | } 20 | 21 | class _SightingsListState extends State { 22 | Widget _item(Sighting sighting) { 23 | return SightingCard( 24 | onTap: () => router.pushNamed(RoutePaths.sighting.name, 25 | pathParameters: {'documentId': sighting.id}).then((value) { 26 | final refreshProvider = RefreshProvider.of(context); 27 | // Refresh list when we've returned from updating or deleting a 28 | // sighting or species 29 | if ((refreshProvider.isDirty(RefreshKeys.UpdatedSighting) || 30 | refreshProvider.isDirty(RefreshKeys.DeletedSighting) || 31 | refreshProvider.isDirty(RefreshKeys.UpdatedSpecies) || 32 | refreshProvider.isDirty(RefreshKeys.DeletedSpecies)) && 33 | widget.paginator.refresh != null) { 34 | widget.paginator.refresh!(); 35 | } 36 | }), 37 | date: sighting.datetime, 38 | localName: sighting.localName, 39 | species: sighting.species, 40 | image: sighting.images.firstOrNull); 41 | } 42 | 43 | @override 44 | Widget build(BuildContext context) { 45 | return SliverPaginationBase( 46 | builder: (List collection) { 47 | return SliverList.builder( 48 | itemCount: collection.length, 49 | itemBuilder: (BuildContext context, int index) { 50 | return Container( 51 | padding: const EdgeInsets.only(bottom: 20.0), 52 | child: _item(collection[index])); 53 | }); 54 | }, 55 | paginator: widget.paginator); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/sightings_tiles.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:app/models/base.dart'; 6 | import 'package:app/models/blobs.dart'; 7 | import 'package:app/models/sightings.dart'; 8 | import 'package:app/ui/colors.dart'; 9 | import 'package:app/ui/screens/species.dart'; 10 | import 'package:app/ui/widgets/card.dart'; 11 | import 'package:app/ui/widgets/image.dart'; 12 | import 'package:app/ui/widgets/pagination_list.dart'; 13 | 14 | class SightingsTiles extends StatefulWidget { 15 | final Paginator paginator; 16 | final OnTap onTap; 17 | 18 | const SightingsTiles( 19 | {super.key, required this.paginator, required this.onTap}); 20 | 21 | @override 22 | State createState() => _SightingsTilesState(); 23 | } 24 | 25 | class _SightingsTilesState extends State { 26 | Widget _item(Sighting sighting, BuildContext context) { 27 | return SightingTile( 28 | onTap: () { 29 | Feedback.forTap(context); 30 | widget.onTap(sighting.id); 31 | }, 32 | date: sighting.datetime, 33 | image: sighting.images.firstOrNull); 34 | } 35 | 36 | @override 37 | Widget build(BuildContext context) { 38 | return PaginationGrid( 39 | builder: (Sighting sighting) { 40 | return _item(sighting, context); 41 | }, 42 | paginator: widget.paginator); 43 | } 44 | } 45 | 46 | class SightingTile extends StatefulWidget { 47 | final Blob? image; 48 | final DateTime date; 49 | final VoidCallback onTap; 50 | 51 | const SightingTile( 52 | {super.key, this.image, required this.date, required this.onTap}); 53 | 54 | @override 55 | State createState() => _SightingTileState(); 56 | } 57 | 58 | class _SightingTileState extends State { 59 | bool isSelected = false; 60 | 61 | @override 62 | Widget build(BuildContext context) { 63 | return GestureDetector( 64 | onTap: widget.onTap, 65 | onTapDown: (details) { 66 | setState(() { 67 | isSelected = true; 68 | }); 69 | }, 70 | onTapUp: (details) { 71 | setState(() { 72 | isSelected = false; 73 | }); 74 | }, 75 | onTapCancel: () { 76 | setState(() { 77 | isSelected = false; 78 | }); 79 | }, 80 | child: MeliCard( 81 | borderColor: isSelected ? MeliColors.black : MeliColors.white, 82 | borderWidth: 3.0, 83 | child: Container( 84 | clipBehavior: Clip.hardEdge, 85 | decoration: const ShapeDecoration( 86 | shape: RoundedRectangleBorder( 87 | borderRadius: BorderRadius.all(Radius.circular(10.0)))), 88 | child: MeliImage(image: widget.image))), 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/simple_card.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:app/ui/widgets/card_header.dart'; 4 | import 'package:flutter/material.dart'; 5 | 6 | import 'package:app/ui/widgets/card.dart'; 7 | 8 | class SimpleCard extends StatelessWidget { 9 | final Widget child; 10 | final String title; 11 | 12 | const SimpleCard({super.key, required this.child, required this.title}); 13 | 14 | Widget _content() { 15 | return Column( 16 | children: [ 17 | MeliCardHeader(title: title), 18 | Container( 19 | padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 18.0), 20 | child: child), 21 | ], 22 | ); 23 | } 24 | 25 | @override 26 | Widget build(BuildContext context) { 27 | return MeliCard(child: _content()); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/species_card.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 5 | import 'package:graphql_flutter/graphql_flutter.dart'; 6 | 7 | import 'package:app/io/p2panda/publish.dart'; 8 | import 'package:app/models/base.dart'; 9 | import 'package:app/models/sightings.dart'; 10 | import 'package:app/models/taxonomy_species.dart'; 11 | import 'package:app/ui/colors.dart'; 12 | import 'package:app/ui/widgets/card.dart'; 13 | import 'package:app/ui/widgets/image.dart'; 14 | 15 | class SpeciesCard extends StatefulWidget { 16 | final DocumentId id; 17 | final TaxonomySpecies? taxonomySpecies; 18 | 19 | final VoidCallback onTap; 20 | 21 | const SpeciesCard( 22 | {super.key, 23 | required this.id, 24 | required this.taxonomySpecies, 25 | required this.onTap}); 26 | 27 | @override 28 | State createState() => _SpeciesCardState(); 29 | } 30 | 31 | class _SpeciesCardState extends State { 32 | bool isSelected = false; 33 | 34 | Widget _title(BuildContext context) { 35 | final t = AppLocalizations.of(context)!; 36 | 37 | return Text( 38 | widget.taxonomySpecies != null 39 | ? widget.taxonomySpecies!.name 40 | : t.sightingUnspecified, 41 | textAlign: TextAlign.center, 42 | overflow: TextOverflow.ellipsis, 43 | style: const TextStyle( 44 | fontSize: 23.0, 45 | fontFamily: 'Staatliches', 46 | ), 47 | ); 48 | } 49 | 50 | Widget get _image { 51 | return Query( 52 | options: QueryOptions( 53 | document: gql(lastSightingQuery(widget.id)), 54 | ), 55 | builder: (QueryResult result, 56 | {VoidCallback? refetch, FetchMore? fetchMore}) { 57 | if (result.hasException) { 58 | return MeliImage( 59 | image: null, externalError: result.exception.toString()); 60 | } 61 | 62 | if (result.isLoading) { 63 | return const SizedBox.shrink(); 64 | } 65 | 66 | final list = result.data![DEFAULT_RESULTS_KEY]['documents'] as List; 67 | if (list.isNotEmpty) { 68 | Sighting sighting = 69 | Sighting.fromJson(list.first as Map); 70 | return MeliImage(image: sighting.images.first); 71 | } else { 72 | return const MeliImage(image: null); 73 | } 74 | }, 75 | ); 76 | } 77 | 78 | @override 79 | Widget build(BuildContext context) { 80 | return GestureDetector( 81 | onTapDown: (details) { 82 | setState(() { 83 | isSelected = true; 84 | }); 85 | }, 86 | onTapUp: (details) { 87 | setState(() { 88 | isSelected = false; 89 | }); 90 | }, 91 | onTapCancel: () { 92 | setState(() { 93 | isSelected = false; 94 | }); 95 | }, 96 | onTap: Feedback.wrapForTap(widget.onTap, context), 97 | child: MeliCard( 98 | elevation: 0, 99 | borderWidth: 4.0, 100 | color: MeliColors.white, 101 | borderColor: isSelected ? MeliColors.black : MeliColors.white, 102 | child: Column(children: [ 103 | Container( 104 | margin: const EdgeInsets.all(1.0), 105 | clipBehavior: Clip.antiAlias, 106 | height: 240.0, 107 | width: double.infinity, 108 | decoration: ShapeDecoration( 109 | shape: RoundedRectangleBorder( 110 | borderRadius: BorderRadius.circular(12), 111 | ), 112 | ), 113 | child: _image, 114 | ), 115 | Container( 116 | padding: const EdgeInsets.only( 117 | top: 8.0, right: 6.0, bottom: 10.0, left: 6.0), 118 | alignment: AlignmentDirectional.center, 119 | child: _title(context), 120 | ), 121 | ])), 122 | ); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/species_local_names_aggregate.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter/widgets.dart'; 5 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 6 | 7 | import 'package:app/io/p2panda/publish.dart'; 8 | import 'package:app/models/local_names.dart'; 9 | import 'package:app/ui/widgets/card.dart'; 10 | import 'package:app/ui/widgets/card_header.dart'; 11 | import 'package:app/ui/widgets/local_names_dedup_tag_list.dart'; 12 | import 'package:app/ui/widgets/tag_item.dart'; 13 | 14 | class SpeciesLocalNamesAggregate extends StatelessWidget { 15 | final DocumentId id; 16 | 17 | const SpeciesLocalNamesAggregate({super.key, required this.id}); 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | return MeliCard( 22 | child: Column( 23 | children: [ 24 | MeliCardHeader( 25 | title: AppLocalizations.of(context)!.localNamesCardTitle, 26 | ), 27 | LocalNamesList(species: id), 28 | ], 29 | ), 30 | ); 31 | } 32 | } 33 | 34 | class LocalNamesList extends StatelessWidget { 35 | const LocalNamesList({ 36 | super.key, 37 | required this.species, 38 | }); 39 | 40 | final DocumentId species; 41 | 42 | @override 43 | Widget build(BuildContext context) { 44 | LocalNamesPaginator paginator = LocalNamesPaginator(species: species); 45 | 46 | return Container( 47 | padding: 48 | const EdgeInsets.only(top: 10, right: 18, bottom: 10, left: 18), 49 | child: DeduplicatedLocalNamesList( 50 | builder: (LocalName localName) { 51 | return TagItem(label: localName.name); 52 | }, 53 | paginator: paginator)); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/species_popup_menu.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 5 | 6 | import 'package:app/models/species.dart'; 7 | import 'package:app/router.dart'; 8 | import 'package:app/ui/widgets/confirm_dialog.dart'; 9 | import 'package:app/ui/widgets/refresh_provider.dart'; 10 | 11 | class SpeciesPopupMenu extends StatelessWidget { 12 | final Species species; 13 | 14 | const SpeciesPopupMenu({super.key, required this.species}); 15 | 16 | void _onDelete(BuildContext context) { 17 | final messenger = ScaffoldMessenger.of(context); 18 | final t = AppLocalizations.of(context)!; 19 | final refreshProvider = RefreshProvider.of(context); 20 | 21 | showDialog( 22 | context: context, 23 | builder: (BuildContext context) => ConfirmDialog( 24 | title: t.speciesDeleteAlertTitle, 25 | message: t.speciesDeleteAlertBody, 26 | labelAbort: t.speciesDeleteAlertCancel, 27 | labelConfirm: t.speciesDeleteAlertConfirm, 28 | onConfirm: () async { 29 | await species.delete(); 30 | 31 | // Set flag for other widgets to tell them that they might need to 32 | // re-render their data. This will make sure that our updates are 33 | // reflected in the UI 34 | refreshProvider.setDirty(RefreshKeys.DeletedSpecies); 35 | 36 | // First pop closes this dialog, second goes back to the view we came 37 | // from 38 | router.pop(); 39 | router.pop(); 40 | 41 | messenger.showSnackBar( 42 | SnackBar(content: Text(t.speciesDeleteConfirmation))); 43 | }, 44 | ), 45 | ); 46 | } 47 | 48 | @override 49 | Widget build(BuildContext context) { 50 | final t = AppLocalizations.of(context)!; 51 | 52 | return PopupMenuButton( 53 | itemBuilder: (BuildContext context) => [ 54 | PopupMenuItem( 55 | child: Text(t.deleteSpecies), 56 | onTap: () { 57 | _onDelete(context); 58 | }), 59 | ]); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/species_uses_aggregate.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter/widgets.dart'; 5 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 6 | 7 | import 'package:app/io/p2panda/publish.dart'; 8 | import 'package:app/models/sightings.dart'; 9 | import 'package:app/models/used_for.dart'; 10 | import 'package:app/ui/widgets/card.dart'; 11 | import 'package:app/ui/widgets/card_header.dart'; 12 | import 'package:app/ui/widgets/pagination_list.dart'; 13 | import 'package:app/ui/widgets/tag_item.dart'; 14 | import 'package:app/ui/widgets/used_for_dedup_tag_list.dart'; 15 | 16 | class SpeciesUsesAggregate extends StatelessWidget { 17 | final DocumentId id; 18 | 19 | const SpeciesUsesAggregate({super.key, required this.id}); 20 | 21 | @override 22 | Widget build(BuildContext context) { 23 | return MeliCard( 24 | child: Column( 25 | children: [ 26 | MeliCardHeader( 27 | title: AppLocalizations.of(context)!.usedForCardTitle, 28 | ), 29 | SpeciesUsesAggregateList(id: id), 30 | ], 31 | ), 32 | ); 33 | } 34 | } 35 | 36 | class SpeciesUsesAggregateList extends StatelessWidget { 37 | const SpeciesUsesAggregateList({ 38 | super.key, 39 | required this.id, 40 | }); 41 | 42 | final DocumentId id; 43 | 44 | @override 45 | Widget build(BuildContext context) { 46 | return Container( 47 | padding: 48 | const EdgeInsets.only(top: 10, right: 18, bottom: 10, left: 18), 49 | child: FetchAll( 50 | paginator: SpeciesSightingsPaginator(id), 51 | builder: (collection) { 52 | return UsedForTagsList(sightings: collection); 53 | }, 54 | )); 55 | } 56 | } 57 | 58 | class UsedForTagsList extends StatelessWidget { 59 | final List sightings; 60 | 61 | const UsedForTagsList({super.key, required this.sightings}); 62 | 63 | @override 64 | Widget build(BuildContext context) { 65 | UsedForPaginator paginator = UsedForPaginator( 66 | sightings: sightings.map((sighting) => sighting.id).toList()); 67 | 68 | return DeduplicatedUsedForTagsList( 69 | builder: (UsedFor usedFor) { 70 | return TagItem(label: usedFor.usedFor); 71 | }, 72 | paginator: paginator); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/tag_item.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:app/ui/colors.dart'; 6 | import 'package:app/utils/sleep.dart'; 7 | 8 | const DELETE_ANIMATION_MS = 250; 9 | 10 | class TagItem extends StatefulWidget { 11 | final void Function(String)? onClick; 12 | final String label; 13 | final bool isDeleteMode; 14 | 15 | const TagItem( 16 | {super.key, 17 | required this.label, 18 | this.onClick, 19 | this.isDeleteMode = false}); 20 | 21 | @override 22 | State createState() => _TagItemState(); 23 | } 24 | 25 | class _TagItemState extends State { 26 | bool _visible = true; 27 | 28 | @override 29 | Widget build(BuildContext context) { 30 | return Container( 31 | padding: const EdgeInsets.only(right: 10, bottom: 10), 32 | child: AnimatedOpacity( 33 | duration: const Duration(milliseconds: DELETE_ANIMATION_MS), 34 | opacity: _visible ? 1.0 : 0.0, 35 | child: GestureDetector( 36 | child: Material( 37 | elevation: 2, 38 | color: MeliColors.white, 39 | clipBehavior: Clip.hardEdge, 40 | borderRadius: const BorderRadius.all(Radius.circular(7)), 41 | child: InkWell( 42 | onTap: (widget.onClick != null) 43 | ? () async { 44 | // Fade-out widget when deleting 45 | if (widget.isDeleteMode) { 46 | setState(() { 47 | _visible = false; 48 | }); 49 | } 50 | 51 | await sleep(DELETE_ANIMATION_MS); 52 | widget.onClick!(widget.label); 53 | } 54 | : null, 55 | child: Container( 56 | margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), 57 | child: Row( 58 | mainAxisSize: MainAxisSize.min, 59 | children: [ 60 | Flexible( 61 | child: Text(widget.label, 62 | overflow: TextOverflow.ellipsis, 63 | style: Theme.of(context).textTheme.titleMedium), 64 | ), 65 | if (widget.isDeleteMode) 66 | const Padding( 67 | padding: EdgeInsets.only(left: 5, bottom: 1), 68 | child: Icon( 69 | size: 20, color: MeliColors.plum, Icons.delete), 70 | ), 71 | ], 72 | ), 73 | ), 74 | ), 75 | ), 76 | ), 77 | ), 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/taxonomy_autocomplete.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:graphql_flutter/graphql_flutter.dart'; 5 | 6 | import 'package:app/io/graphql/graphql.dart'; 7 | import 'package:app/io/p2panda/schemas.dart'; 8 | import 'package:app/models/base.dart'; 9 | import 'package:app/models/taxonomy_species.dart'; 10 | import 'package:app/ui/widgets/autocomplete.dart'; 11 | 12 | typedef OnChanged = void Function(AutocompleteItem); 13 | typedef OnSubmit = void Function(AutocompleteItem); 14 | 15 | class TaxonomyAutocomplete extends StatefulWidget { 16 | final SchemaId schemaId; 17 | final OnChanged onChanged; 18 | final VoidCallback? onSubmit; 19 | final AutocompleteItem? initialValue; 20 | 21 | const TaxonomyAutocomplete( 22 | {super.key, 23 | required this.onChanged, 24 | required this.schemaId, 25 | this.onSubmit, 26 | this.initialValue}); 27 | 28 | @override 29 | State createState() => _TaxonomyAutocompleteState(); 30 | } 31 | 32 | class _TaxonomyAutocompleteState extends State { 33 | @override 34 | Widget build(BuildContext context) { 35 | return MeliAutocomplete( 36 | onChanged: widget.onChanged, 37 | onSubmit: widget.onSubmit, 38 | initialValue: widget.initialValue, 39 | onOptionsRequest: (String value) async { 40 | try { 41 | final QueryResult result = await client.query(QueryOptions( 42 | document: gql(searchTaxon(widget.schemaId, value)))); 43 | 44 | if (result.hasException) { 45 | throw result.exception!; 46 | } 47 | 48 | final List documents = 49 | result.data![DEFAULT_RESULTS_KEY]['documents'] as List; 50 | 51 | final List options = []; 52 | Set seen = {}; 53 | 54 | for (var document in documents) { 55 | final localName = BaseTaxonomy.fromJson( 56 | widget.schemaId, document as Map); 57 | 58 | // De-duplicate results with same "name" value 59 | if (seen.contains(localName.name)) { 60 | continue; 61 | } 62 | 63 | seen.add(localName.name); 64 | options.add(AutocompleteItem( 65 | value: localName.name, 66 | documentId: localName.id, 67 | viewId: localName.viewId)); 68 | } 69 | 70 | return options.toList(); 71 | } catch (error) { 72 | print(error.toString()); 73 | return []; 74 | } 75 | }); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/text_field.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:app/ui/colors.dart'; 6 | import 'package:app/ui/widgets/editable_card.dart'; 7 | import 'package:app/ui/widgets/read_only_value.dart'; 8 | import 'package:app/ui/widgets/action_buttons.dart'; 9 | 10 | typedef OnUpdate = Future Function(String); 11 | 12 | class EditableTextField extends StatefulWidget { 13 | final String title; 14 | final String current; 15 | final OnUpdate onUpdate; 16 | 17 | const EditableTextField(this.current, 18 | {super.key, required this.title, required this.onUpdate}); 19 | 20 | @override 21 | State createState() => _TextFieldState(); 22 | } 23 | 24 | class _TextFieldState extends State { 25 | final TextEditingController _controller = TextEditingController(); 26 | 27 | /// Flag indicating if we're currently editing the field or not. 28 | bool isEditMode = false; 29 | 30 | /// Contains changed value when user adjusted the field. 31 | String _dirty = ""; 32 | 33 | @override 34 | void initState() { 35 | _init(); 36 | super.initState(); 37 | } 38 | 39 | @override 40 | void didUpdateWidget(covariant EditableTextField oldWidget) { 41 | _init(); 42 | super.didUpdateWidget(oldWidget); 43 | } 44 | 45 | void _init() { 46 | _controller.text = widget.current; 47 | } 48 | 49 | Future _submit() async { 50 | if (_dirty == widget.current) { 51 | return; // Nothing has changed 52 | } else { 53 | await widget.onUpdate.call(_dirty); 54 | } 55 | } 56 | 57 | void _changeValue(String newValue) async { 58 | setState(() { 59 | _dirty = newValue; 60 | }); 61 | } 62 | 63 | void _toggleEditMode() { 64 | setState(() { 65 | isEditMode = !isEditMode; 66 | }); 67 | } 68 | 69 | void _handleSubmit() async { 70 | await _submit(); 71 | setState(() { 72 | isEditMode = !isEditMode; 73 | }); 74 | } 75 | 76 | Widget _editableValue() { 77 | return TextField( 78 | keyboardType: TextInputType.multiline, 79 | minLines: 1, 80 | controller: _controller, 81 | maxLines: 5, 82 | onChanged: _changeValue, 83 | scrollPadding: const EdgeInsets.only(bottom: 100.0), 84 | textCapitalization: TextCapitalization.sentences, 85 | decoration: const InputDecoration( 86 | focusedBorder: OutlineInputBorder( 87 | borderSide: BorderSide( 88 | color: MeliColors.plum, width: 3, style: BorderStyle.solid)), 89 | enabledBorder: OutlineInputBorder( 90 | borderSide: BorderSide( 91 | color: MeliColors.plum, width: 3, style: BorderStyle.solid)), 92 | ), 93 | ); 94 | } 95 | 96 | Widget _readOnlyValue() { 97 | if (widget.current == '') { 98 | return const ReadOnlyValue(null); 99 | } else { 100 | return SizedBox( 101 | width: double.infinity, 102 | child: Text(widget.current, textAlign: TextAlign.start)); 103 | } 104 | } 105 | 106 | @override 107 | Widget build(BuildContext context) { 108 | return EditableCard( 109 | title: widget.title, 110 | isEditMode: isEditMode, 111 | onChanged: _toggleEditMode, 112 | child: Column( 113 | children: [ 114 | if (isEditMode) ...[ 115 | _editableValue(), 116 | Padding( 117 | padding: const EdgeInsets.only(top: 8.0), 118 | child: ActionButtons( 119 | onAction: _handleSubmit, 120 | onCancel: _toggleEditMode, 121 | ), 122 | ) 123 | ] else 124 | _readOnlyValue(), 125 | ], 126 | )); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/used_for_dedup_tag_list.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:app/models/base.dart'; 6 | import 'package:app/models/used_for.dart'; 7 | import 'package:app/ui/widgets/pagination_list.dart'; 8 | 9 | const MINIMUM_PAGE_SIZE = 10; 10 | 11 | class DeduplicatedUsedForTagsList extends StatelessWidget { 12 | final PaginationBuilder builder; 13 | final Paginator paginator; 14 | 15 | const DeduplicatedUsedForTagsList( 16 | {super.key, required this.builder, required this.paginator}); 17 | 18 | bool _fetchMoreOverride(PaginatedCollection data) { 19 | // Collection queries over `UsedFor` documents will inevitably return 20 | // unwanted duplicate items which have the same "use". 21 | // 22 | // In this "fetch override" method we: 23 | // 1) deduplicate the returned collection 24 | // 2) if we haven't met the desired quota, signal that the next page 25 | // should be fetched 26 | Set seen = {}; 27 | data.documents.retainWhere((usedFor) => seen.add(usedFor.usedFor)); 28 | 29 | // We want to keep fetching more results (while there are any) until 30 | // we meet the minimum page size. 31 | return data.documents.length < MINIMUM_PAGE_SIZE && data.hasNextPage; 32 | } 33 | 34 | @override 35 | Widget build(BuildContext context) { 36 | return PaginationBase( 37 | fetchMoreOverride: _fetchMoreOverride, 38 | paginator: paginator, 39 | builder: (List collection) { 40 | return Wrap( 41 | crossAxisAlignment: WrapCrossAlignment.center, 42 | children: [ 43 | ...collection.map((document) => builder(document)), 44 | ], 45 | ); 46 | }, 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/used_for_list.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:app/models/base.dart'; 6 | import 'package:app/models/used_for.dart'; 7 | import 'package:app/ui/widgets/pagination_list.dart'; 8 | import 'package:app/ui/widgets/tag_item.dart'; 9 | 10 | class UsedForList extends StatefulWidget { 11 | final Paginator paginator; 12 | final void Function(UsedFor usedFor) onDeleteClick; 13 | final bool isEditMode; 14 | final bool isLoading; 15 | 16 | const UsedForList( 17 | {super.key, 18 | required this.paginator, 19 | required this.onDeleteClick, 20 | this.isLoading = false, 21 | this.isEditMode = false}); 22 | 23 | @override 24 | State createState() => _UsedForListState(); 25 | } 26 | 27 | class _UsedForListState extends State { 28 | Widget _item(UsedFor document) { 29 | return TagItem( 30 | key: ValueKey(document.id), 31 | label: document.usedFor, 32 | isDeleteMode: widget.isEditMode, 33 | onClick: widget.isEditMode && !widget.isLoading 34 | ? (String label) { 35 | widget.onDeleteClick(document); 36 | } 37 | : null); 38 | } 39 | 40 | @override 41 | Widget build(BuildContext context) { 42 | return PaginationBaseWithShruggie( 43 | isEditMode: widget.isEditMode, 44 | builder: (List collection) { 45 | return Wrap( 46 | crossAxisAlignment: WrapCrossAlignment.center, 47 | children: [ 48 | ...collection.map((document) => _item(document)), 49 | ], 50 | ); 51 | }, 52 | paginator: widget.paginator); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/used_for_tag_selector.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 5 | 6 | import 'package:app/models/used_for.dart'; 7 | import 'package:app/ui/widgets/error_card.dart'; 8 | import 'package:app/ui/widgets/tag_item.dart'; 9 | 10 | class UsedForTagSelector extends StatefulWidget { 11 | final void Function(String) onTagClick; 12 | 13 | const UsedForTagSelector({super.key, required this.onTagClick}); 14 | 15 | @override 16 | State createState() => _UsedForTagSelectorState(); 17 | } 18 | 19 | class _UsedForTagSelectorState extends State { 20 | Future>? _aggregate; 21 | 22 | @override 23 | void initState() { 24 | super.initState(); 25 | _aggregate = getAllDeduplicatedUsedFor(); 26 | } 27 | 28 | @override 29 | Widget build(BuildContext context) { 30 | return FutureBuilder>( 31 | future: _aggregate, 32 | initialData: const [], 33 | builder: (BuildContext context, AsyncSnapshot> snapshot) { 34 | if (snapshot.hasError) { 35 | return ErrorCard( 36 | message: AppLocalizations.of(context)! 37 | .paginationListError(snapshot.error.toString())); 38 | } 39 | 40 | if (snapshot.data!.isEmpty) { 41 | return Text(AppLocalizations.of(context)!.paginationListNoResults, 42 | textAlign: TextAlign.center); 43 | } 44 | 45 | return Wrap( 46 | crossAxisAlignment: WrapCrossAlignment.center, 47 | children: [ 48 | ...snapshot.data!.map((document) => 49 | TagItem(label: document.usedFor, onClick: widget.onTagClick)), 50 | ], 51 | ); 52 | }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/app/lib/ui/widgets/used_for_text_field.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 5 | 6 | import 'package:app/ui/colors.dart'; 7 | import 'package:app/ui/widgets/action_buttons.dart'; 8 | 9 | class UsedForTextField extends StatefulWidget { 10 | final void Function(String) onSubmit; 11 | final void Function() onCancel; 12 | 13 | const UsedForTextField( 14 | {super.key, required this.onSubmit, required this.onCancel}); 15 | 16 | @override 17 | State createState() => _UsedForTextFieldState(); 18 | } 19 | 20 | class _UsedForTextFieldState extends State { 21 | final TextEditingController _controller = TextEditingController(); 22 | bool disabled = true; 23 | 24 | void _handleSubmit() { 25 | if (_controller.text == '') { 26 | return; 27 | } 28 | 29 | widget.onSubmit(_controller.text); 30 | _controller.text = ''; 31 | 32 | setState(() { 33 | disabled = true; 34 | }); 35 | } 36 | 37 | void _handleCancel() { 38 | widget.onCancel(); 39 | } 40 | 41 | void _onChange(String newText) { 42 | if (newText != '') { 43 | setState(() { 44 | disabled = false; 45 | }); 46 | } else { 47 | setState(() { 48 | disabled = true; 49 | }); 50 | } 51 | } 52 | 53 | @override 54 | void dispose() { 55 | _controller.dispose(); 56 | super.dispose(); 57 | } 58 | 59 | @override 60 | Widget build(BuildContext context) { 61 | final t = AppLocalizations.of(context)!; 62 | 63 | return Column( 64 | crossAxisAlignment: CrossAxisAlignment.start, 65 | children: [ 66 | TextField( 67 | controller: _controller, 68 | decoration: const InputDecoration( 69 | focusedBorder: OutlineInputBorder( 70 | borderSide: BorderSide( 71 | color: MeliColors.plum, 72 | width: 3, 73 | style: BorderStyle.solid)), 74 | enabledBorder: OutlineInputBorder( 75 | borderSide: BorderSide( 76 | color: MeliColors.plum, 77 | width: 3, 78 | style: BorderStyle.solid)), 79 | ), 80 | keyboardType: TextInputType.text, 81 | scrollPadding: const EdgeInsets.only(bottom: 100.0), 82 | onChanged: _onChange, 83 | textCapitalization: TextCapitalization.sentences), 84 | const SizedBox(height: 10), 85 | ActionButtons( 86 | actionLabel: t.usedForCreateButton, 87 | onAction: disabled ? null : _handleSubmit, 88 | onCancel: _handleCancel, 89 | ) 90 | ], 91 | ); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /packages/app/lib/utils/debouncable.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | import 'dart:async'; 4 | 5 | const Duration debounceDuration = Duration(milliseconds: 500); 6 | 7 | typedef Debounceable = Future Function(T parameter); 8 | 9 | /// Returns a new function that is a debounced version of the given function. 10 | /// 11 | /// This means that the original function will be called only after no calls 12 | /// have been made for the given Duration. 13 | Debounceable debounce(Debounceable function) { 14 | _DebounceTimer? debounceTimer; 15 | 16 | return (T parameter) async { 17 | if (debounceTimer != null && !debounceTimer!.isCompleted) { 18 | debounceTimer!.cancel(); 19 | } 20 | debounceTimer = _DebounceTimer(); 21 | try { 22 | await debounceTimer!.future; 23 | } catch (error) { 24 | if (error is _CancelException) { 25 | return null; 26 | } 27 | rethrow; 28 | } 29 | return function(parameter); 30 | }; 31 | } 32 | 33 | // A wrapper around Timer used for debouncing. 34 | class _DebounceTimer { 35 | _DebounceTimer() { 36 | _timer = Timer(debounceDuration, _onComplete); 37 | } 38 | 39 | late final Timer _timer; 40 | final Completer _completer = Completer(); 41 | 42 | void _onComplete() { 43 | _completer.complete(); 44 | } 45 | 46 | Future get future => _completer.future; 47 | 48 | bool get isCompleted => _completer.isCompleted; 49 | 50 | void cancel() { 51 | _timer.cancel(); 52 | _completer.completeError(const _CancelException()); 53 | } 54 | } 55 | 56 | // An exception indicating that the timer was canceled. 57 | class _CancelException implements Exception { 58 | const _CancelException(); 59 | } 60 | -------------------------------------------------------------------------------- /packages/app/lib/utils/sleep.dart: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | /// Runs a dummy async task for a defined amount of time. 4 | Future sleep(int milliseconds) async { 5 | return Future.delayed(Duration(milliseconds: milliseconds)); 6 | } 7 | -------------------------------------------------------------------------------- /packages/app/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: app 2 | description: Meli Bees App for Android 3 | publish_to: none 4 | version: 1.0.0+1 5 | 6 | environment: 7 | sdk: ">=3.3.0 <4.0.0" 8 | 9 | dependencies: 10 | camera: 0.11.0+1 11 | carousel_slider: 4.2.1 12 | convert: 3.1.1 13 | device_info_plus: 10.1.0 14 | easy_image_viewer: 1.5.0 15 | file_picker: 8.0.3 16 | flutter: 17 | sdk: flutter 18 | flutter_image_compress: 2.3.0 19 | flutter_localizations: 20 | sdk: flutter 21 | flutter_scroll_shadow: 1.2.4 22 | geolocator: 12.0.0 23 | go_router: 14.1.4 24 | gql: 1.0.1-alpha+1709845491443 25 | graphql: 5.2.0-beta.7 26 | graphql_flutter: 5.2.0-beta.6 27 | http: 1.2.1 28 | image_picker: 1.1.2 29 | intl: 0.19.0 30 | mime: 1.0.5 31 | p2panda: 0.1.1 32 | p2panda_flutter: 0.1.1 33 | package_info_plus: 8.0.0 34 | path: 1.9.0 35 | path_provider: 2.1.3 36 | shared_preferences: 2.2.3 37 | sliver_tools: 0.2.12 38 | toml: 0.15.0 39 | uuid: 4.4.0 40 | 41 | dev_dependencies: 42 | flutter_test: 43 | sdk: flutter 44 | flutter_lints: 4.0.0 45 | flutter_native_splash: 2.4.0 46 | 47 | flutter: 48 | assets: 49 | - assets/ 50 | - assets/images/ 51 | fonts: 52 | - family: Staatliches 53 | fonts: 54 | - asset: assets/fonts/Staatliches-Regular.ttf 55 | uses-material-design: true 56 | 57 | # Enable code generation for localization 58 | generate: true 59 | 60 | # Run "dart run flutter_native_splash:create" in "app" folder after applying changes here 61 | flutter_native_splash: 62 | color: "#eaddff" 63 | android_12: 64 | color: "#eaddff" 65 | -------------------------------------------------------------------------------- /packages/app/pubspec_overrides.yaml: -------------------------------------------------------------------------------- 1 | # melos_managed_dependency_overrides: p2panda,p2panda_flutter 2 | dependency_overrides: 3 | p2panda: 4 | path: ../p2panda 5 | p2panda_flutter: 6 | path: ../p2panda_flutter 7 | -------------------------------------------------------------------------------- /packages/p2panda/.gitignore: -------------------------------------------------------------------------------- 1 | # https://dart.dev/guides/libraries/private-files 2 | # Created by `dart pub` 3 | .dart_tool/ 4 | 5 | # Avoid committing pubspec.lock for library packages; see 6 | # https://dart.dev/guides/libraries/private-files#pubspeclock. 7 | pubspec.lock 8 | -------------------------------------------------------------------------------- /packages/p2panda/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | analyzer: 2 | exclude: 3 | # Generated file by flutter_rust_bridge can be ignored 4 | - lib/src/bridge_generated.dart 5 | - lib/src/bridge_generated.freezed.dart 6 | -------------------------------------------------------------------------------- /packages/p2panda/lib/p2panda.dart: -------------------------------------------------------------------------------- 1 | export 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show WasmModule; 2 | export 'src/bridge_generated.dart' 3 | show P2Panda, KeyPair, OperationValue, OperationAction; 4 | export 'src/ffi.dart'; 5 | -------------------------------------------------------------------------------- /packages/p2panda/lib/src/ffi.dart: -------------------------------------------------------------------------------- 1 | import 'package:p2panda/src/bridge_generated.dart'; 2 | import 'package:p2panda/src/ffi/stub.dart' 3 | if (dart.library.io) 'ffi/io.dart' 4 | if (dart.library.html) 'ffi/web.dart'; 5 | 6 | P2Panda? _wrapper; 7 | 8 | P2Panda createWrapper(ExternalLibrary lib) { 9 | _wrapper ??= createWrapperImpl(lib); 10 | return _wrapper!; 11 | } 12 | -------------------------------------------------------------------------------- /packages/p2panda/lib/src/ffi/io.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ffi'; 2 | 3 | import 'package:p2panda/src/bridge_generated.dart'; 4 | 5 | typedef ExternalLibrary = DynamicLibrary; 6 | 7 | P2Panda createWrapperImpl(ExternalLibrary dylib) => P2PandaImpl(dylib); 8 | -------------------------------------------------------------------------------- /packages/p2panda/lib/src/ffi/stub.dart: -------------------------------------------------------------------------------- 1 | import 'package:p2panda/src/bridge_generated.dart'; 2 | 3 | /// Represents the external library for p2panda 4 | /// 5 | /// Will be a DynamicLibrary for dart:io or WasmModule for dart:html 6 | typedef ExternalLibrary = Object; 7 | 8 | P2Panda createWrapperImpl(ExternalLibrary lib) => throw UnimplementedError(); 9 | -------------------------------------------------------------------------------- /packages/p2panda/lib/src/ffi/web.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_rust_bridge/flutter_rust_bridge.dart'; 2 | import 'package:p2panda/src/bridge_generated.dart'; 3 | 4 | typedef ExternalLibrary = WasmModule; 5 | 6 | P2Panda createWrapperImpl(ExternalLibrary module) => P2PandaImpl.wasm(module); 7 | -------------------------------------------------------------------------------- /packages/p2panda/native/.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | -------------------------------------------------------------------------------- /packages/p2panda/native/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "p2panda" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["staticlib", "cdylib"] 8 | 9 | [build-dependencies] 10 | flutter_rust_bridge_codegen = "1.82.6" 11 | 12 | [dependencies] 13 | android_logger = "0.13.1" 14 | anyhow = "1.0.75" 15 | aquadoggo = "0.8.0" 16 | ed25519-dalek = "1.0.1" 17 | flutter_rust_bridge = "1.82.6" 18 | log = "0.4.19" 19 | p2panda-rs = "0.8.1" 20 | tokio = { version = "1.28.2", features = ["rt"] } 21 | -------------------------------------------------------------------------------- /packages/p2panda/native/build.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | use std::env; 4 | 5 | /// Pinned NDK version, needs to be installed on machine. 6 | const ANDROID_NDK_VERSION: &'static str = "25.2.9519653"; 7 | 8 | fn main() { 9 | let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap(); 10 | let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap(); 11 | 12 | let host_os_prefix = if env::consts::OS == "macos" { 13 | "darwin" 14 | } else { 15 | "linux" 16 | }; 17 | 18 | // x86-64 linux standard library path inside NDK directory. 19 | let linux_x86_64_lib_dir = std::format!( 20 | "/toolchains/llvm/prebuilt/{host_os_prefix}-x86_64/lib64/clang/14.0.7/lib/linux/" 21 | ); 22 | 23 | // The new NDK doesn't link to `libgcc` anymore, which breaks our our libraries since they 24 | // depended on the symbols from `libclang_rt.builtins-x86_64-android` like `__extenddftf2`. See 25 | // https://github.com/bbqsrc/cargo-ndk/issues/94 for details. 26 | // 27 | // The change works around this by manually linking to the 28 | // `libclang_rt.builtins-x86_64-android` library in this case. 29 | if target_arch == "x86_64" && target_os == "android" { 30 | let android_home = env::var("ANDROID_HOME").expect("ANDROID_HOME not set"); 31 | println!("cargo:rustc-link-search={android_home}/ndk/{ANDROID_NDK_VERSION}/{linux_x86_64_lib_dir}"); 32 | println!("cargo:rustc-link-lib=static=clang_rt.builtins-x86_64-android"); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/p2panda/native/src/lib.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | mod api; 4 | mod bridge_generated; 5 | mod node; 6 | mod operation; 7 | -------------------------------------------------------------------------------- /packages/p2panda/native/src/node.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | use std::thread; 4 | 5 | use anyhow::Result; 6 | use aquadoggo::{Configuration, Node}; 7 | use p2panda_rs::identity::KeyPair; 8 | use tokio::runtime; 9 | use tokio::sync::mpsc::{channel, Sender}; 10 | 11 | pub struct Manager { 12 | shutdown_signal: Sender, 13 | } 14 | 15 | impl Manager { 16 | pub fn new(key_pair: KeyPair, config: Configuration) -> Result { 17 | let (shutdown_signal, mut on_shutdown) = channel(4); 18 | 19 | thread::spawn(move || { 20 | let rt = runtime::Builder::new_current_thread() 21 | .enable_all() 22 | .build() 23 | .expect("Could not create async tokio runtime"); 24 | 25 | rt.block_on(async move { 26 | let node = Node::start(key_pair, config).await; 27 | 28 | tokio::select! { 29 | _ = on_shutdown.recv() => (), 30 | _ = node.on_exit() => (), 31 | } 32 | 33 | node.shutdown().await; 34 | }); 35 | }); 36 | 37 | Ok(Manager { shutdown_signal }) 38 | } 39 | 40 | pub fn shutdown(&self) { 41 | if self.shutdown_signal.try_send(true).is_err() { 42 | // Ignore if signal was not received 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/p2panda/native/src/operation.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | 3 | use anyhow::{Error, Result}; 4 | use p2panda_rs::document::{DocumentId, DocumentViewId}; 5 | use p2panda_rs::operation::{self, PinnedRelation, PinnedRelationList, Relation, RelationList}; 6 | 7 | use crate::api::{OperationAction, OperationValue}; 8 | 9 | /// Convert operation actions from external FFI Dart type to internal Rust type. 10 | impl From for operation::OperationAction { 11 | fn from(value: OperationAction) -> operation::OperationAction { 12 | match value { 13 | OperationAction::Create => operation::OperationAction::Create, 14 | OperationAction::Update => operation::OperationAction::Update, 15 | OperationAction::Delete => operation::OperationAction::Delete, 16 | } 17 | } 18 | } 19 | 20 | /// Convert operation action from internal Rust type to external FFI Dart type. 21 | impl From for OperationAction { 22 | fn from(value: operation::OperationAction) -> OperationAction { 23 | match value { 24 | operation::OperationAction::Create => OperationAction::Create, 25 | operation::OperationAction::Update => OperationAction::Update, 26 | operation::OperationAction::Delete => OperationAction::Delete, 27 | } 28 | } 29 | } 30 | 31 | /// Convert operation value from external FFI Dart type to internal Rust type. 32 | impl TryFrom for operation::OperationValue { 33 | type Error = Error; 34 | 35 | fn try_from(value: OperationValue) -> Result { 36 | match value { 37 | OperationValue::Boolean(value) => Ok(operation::OperationValue::Boolean(value)), 38 | OperationValue::Float(value) => Ok(operation::OperationValue::Float(value)), 39 | OperationValue::Integer(value) => Ok(operation::OperationValue::Integer(value)), 40 | OperationValue::String(value) => Ok(operation::OperationValue::String(value)), 41 | OperationValue::Bytes(value) => Ok(operation::OperationValue::Bytes(value)), 42 | OperationValue::Relation(document_id_str) => { 43 | let document_id: DocumentId = document_id_str.parse()?; 44 | Ok(operation::OperationValue::Relation(Relation::new( 45 | document_id, 46 | ))) 47 | } 48 | OperationValue::RelationList(document_id_str_vec) => { 49 | let document_id_vec: Result> = document_id_str_vec 50 | .into_iter() 51 | .map(|id_str| { 52 | let document_id = id_str.parse()?; 53 | Ok(document_id) 54 | }) 55 | .collect(); 56 | 57 | Ok(operation::OperationValue::RelationList(RelationList::new( 58 | document_id_vec?, 59 | ))) 60 | } 61 | OperationValue::PinnedRelation(view_id_str) => { 62 | let view_id: DocumentViewId = view_id_str.parse()?; 63 | Ok(operation::OperationValue::PinnedRelation( 64 | PinnedRelation::new(view_id), 65 | )) 66 | } 67 | OperationValue::PinnedRelationList(view_id_str_vec) => { 68 | let view_id_vec: Result> = view_id_str_vec 69 | .into_iter() 70 | .map(|view_id_str| { 71 | let view_id = view_id_str.parse()?; 72 | Ok(view_id) 73 | }) 74 | .collect(); 75 | 76 | Ok(operation::OperationValue::PinnedRelationList( 77 | PinnedRelationList::new(view_id_vec?), 78 | )) 79 | } 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /packages/p2panda/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: p2panda 2 | description: p2panda FFI bindings for Dart 3 | version: 0.1.1 4 | repository: https://github.com/p2panda/meli 5 | publish_to: none 6 | 7 | environment: 8 | sdk: '>=3.3.0 <4.0.0' 9 | 10 | dependencies: 11 | ffi: 2.1.2 12 | flutter_rust_bridge: 1.82.6 13 | freezed_annotation: 2.4.1 14 | meta: 1.12.0 15 | uuid: 4.4.0 16 | 17 | dev_dependencies: 18 | build_runner: 2.4.6 19 | ffigen: 8.0.2 20 | freezed: 2.4.1 21 | -------------------------------------------------------------------------------- /packages/p2panda_flutter/.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | migrate_working_dir/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | # Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. 26 | /pubspec.lock 27 | **/doc/api/ 28 | .dart_tool/ 29 | .packages 30 | build/ 31 | -------------------------------------------------------------------------------- /packages/p2panda_flutter/.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled. 5 | 6 | version: 7 | revision: 796c8ef79279f9c774545b3771238c3098dbefab 8 | channel: stable 9 | 10 | project_type: plugin_ffi 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: 796c8ef79279f9c774545b3771238c3098dbefab 17 | base_revision: 796c8ef79279f9c774545b3771238c3098dbefab 18 | - platform: android 19 | create_revision: 796c8ef79279f9c774545b3771238c3098dbefab 20 | base_revision: 796c8ef79279f9c774545b3771238c3098dbefab 21 | 22 | # User provided section 23 | 24 | # List of Local paths (relative to this file) that should be 25 | # ignored by the migrate tool. 26 | # 27 | # Files that are not part of the templates will be ignored by default. 28 | unmanaged_files: 29 | - 'lib/main.dart' 30 | - 'ios/Runner.xcodeproj/project.pbxproj' 31 | -------------------------------------------------------------------------------- /packages/p2panda_flutter/android/.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .DS_Store 3 | .cxx 4 | .gradle 5 | /.idea/libraries 6 | /.idea/workspace.xml 7 | /build 8 | /captures 9 | /local.properties 10 | 11 | # Ignore Rust binaries 12 | src/main/jniLibs/ 13 | *.tar.gz 14 | -------------------------------------------------------------------------------- /packages/p2panda_flutter/android/TLS_VERIFY: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2panda/meli/bb82c69ecafdbcf72b6f563b5bc648bdafda5f97/packages/p2panda_flutter/android/TLS_VERIFY -------------------------------------------------------------------------------- /packages/p2panda_flutter/android/build.gradle: -------------------------------------------------------------------------------- 1 | // The Android Gradle Plugin builds the native code with the Android NDK. 2 | 3 | group 'com.p2panda.p2panda_flutter' 4 | version '1.0' 5 | 6 | buildscript { 7 | repositories { 8 | google() 9 | mavenCentral() 10 | } 11 | 12 | dependencies { 13 | // The Android Gradle Plugin knows how to build native code with the NDK. 14 | classpath 'com.android.tools.build:gradle:7.3.0' 15 | } 16 | } 17 | 18 | rootProject.allprojects { 19 | repositories { 20 | google() 21 | mavenCentral() 22 | } 23 | } 24 | 25 | apply plugin: 'com.android.library' 26 | 27 | android { 28 | // Bumping the plugin compileSdkVersion requires all clients of this plugin 29 | // to bump the version in their app. 30 | compileSdkVersion 31 31 | 32 | // Bumping the plugin ndkVersion requires all clients of this plugin to bump 33 | // the version in their app and to download a newer version of the NDK. 34 | ndkVersion '25.2.9519653' 35 | 36 | compileOptions { 37 | sourceCompatibility JavaVersion.VERSION_1_8 38 | targetCompatibility JavaVersion.VERSION_1_8 39 | } 40 | 41 | defaultConfig { 42 | minSdkVersion 16 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/p2panda_flutter/android/settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'p2panda_flutter' 2 | -------------------------------------------------------------------------------- /packages/p2panda_flutter/android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /packages/p2panda_flutter/lib/p2panda_flutter.dart: -------------------------------------------------------------------------------- 1 | export 'package:p2panda/p2panda.dart' 2 | show P2Panda, KeyPair, OperationValue, OperationAction; 3 | export 'src/ffi.dart'; 4 | -------------------------------------------------------------------------------- /packages/p2panda_flutter/lib/src/ffi.dart: -------------------------------------------------------------------------------- 1 | import 'package:p2panda/p2panda.dart'; 2 | import 'package:p2panda_flutter/src/ffi/stub.dart' 3 | if (dart.library.io) 'ffi/io.dart' 4 | if (dart.library.html) 'ffi/web.dart'; 5 | 6 | P2Panda createLib() => createWrapper(createLibraryImpl()); 7 | -------------------------------------------------------------------------------- /packages/p2panda_flutter/lib/src/ffi/io.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ffi'; 2 | import 'dart:io'; 3 | 4 | DynamicLibrary createLibraryImpl() { 5 | const base = 'p2panda'; 6 | 7 | if (Platform.isIOS || Platform.isMacOS) { 8 | return DynamicLibrary.executable(); 9 | } else if (Platform.isWindows) { 10 | return DynamicLibrary.open('$base.dll'); 11 | } else { 12 | return DynamicLibrary.open('lib$base.so'); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/p2panda_flutter/lib/src/ffi/stub.dart: -------------------------------------------------------------------------------- 1 | Object createLibraryImpl() => throw UnimplementedError(); 2 | -------------------------------------------------------------------------------- /packages/p2panda_flutter/lib/src/ffi/web.dart: -------------------------------------------------------------------------------- 1 | import 'package:p2panda/p2panda.dart'; 2 | 3 | WasmModule createLibraryImpl() { 4 | throw UnsupportedError('Web support is not provided yet.'); 5 | } 6 | -------------------------------------------------------------------------------- /packages/p2panda_flutter/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: p2panda_flutter 2 | description: p2panda FFI bindings for Flutter 3 | version: 0.1.1 4 | repository: https://github.com/p2panda/meli 5 | publish_to: none 6 | 7 | environment: 8 | sdk: '>=3.3.0 <4.0.0' 9 | flutter: '>=3.3.0' 10 | 11 | dependencies: 12 | flutter: 13 | sdk: flutter 14 | p2panda: ^0.1.1 15 | 16 | dev_dependencies: 17 | flutter_test: 18 | sdk: flutter 19 | 20 | flutter: 21 | plugin: 22 | platforms: 23 | android: 24 | ffiPlugin: true 25 | -------------------------------------------------------------------------------- /packages/p2panda_flutter/pubspec_overrides.yaml: -------------------------------------------------------------------------------- 1 | # melos_managed_dependency_overrides: p2panda 2 | dependency_overrides: 3 | p2panda: 4 | path: ../p2panda 5 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: meli_workspace 2 | publish_to: none 3 | 4 | environment: 5 | sdk: ">=3.3.0 <4.0.0" 6 | 7 | dev_dependencies: 8 | flutter_lints: 4.0.0 9 | melos: 6.0.0 10 | -------------------------------------------------------------------------------- /schemas/.gitignore: -------------------------------------------------------------------------------- 1 | secret.txt 2 | -------------------------------------------------------------------------------- /schemas/schema.toml: -------------------------------------------------------------------------------- 1 | # Sightings and species 2 | 3 | [bee_sighting] 4 | description = "A bee sighting" 5 | 6 | [bee_sighting.fields] 7 | datetime = { type = "str" } 8 | latitude = { type = "float" } 9 | longitude = { type = "float" } 10 | images = { type = "relation_list", schema = { id = "blob_v1" } } 11 | species = { type = "relation_list", schema = { name = "bee_species" } } 12 | local_names = { type = "relation_list", schema = { name = "bee_local_name" } } 13 | comment = { type = "str" } 14 | 15 | [bee_species] 16 | description = "The species of a bee" 17 | 18 | [bee_species.fields] 19 | description = { type = "str" } 20 | species = { type = "relation", schema = { name = "taxonomy_species" } } 21 | 22 | [bee_local_name] 23 | description = "Name a bee is known by locally" 24 | 25 | [bee_local_name.fields] 26 | name = { type = "str" } 27 | 28 | # Attributes 29 | 30 | [bee_attributes_used_for] 31 | description = "What a bees honey is used for" 32 | 33 | [bee_attributes_used_for.fields] 34 | sighting = { type = "relation", schema = { name = "bee_sighting" } } 35 | used_for = { type = "str" } 36 | 37 | [bee_attributes_location_tree] 38 | description = "Bee sighting location: tree" 39 | 40 | [bee_attributes_location_tree.fields] 41 | tree_species = { type = "str" } 42 | height = { type = "float" } 43 | diameter = { type = "float" } 44 | sighting = { type = "relation", schema = { name = "bee_sighting" } } 45 | 46 | [bee_attributes_location_building] 47 | description = "Bee sighting location: building" 48 | 49 | [bee_attributes_location_building.fields] 50 | sighting = { type = "relation", schema = { name = "bee_sighting" } } 51 | 52 | [bee_attributes_location_ground] 53 | description = "Bee sighting location: ground" 54 | 55 | [bee_attributes_location_ground.fields] 56 | sighting = { type = "relation", schema = { name = "bee_sighting" } } 57 | 58 | [bee_attributes_location_box] 59 | description = "Bee sighting location: box" 60 | 61 | [bee_attributes_location_box.fields] 62 | sighting = { type = "relation", schema = { name = "bee_sighting" } } 63 | 64 | # Taxonomy 65 | 66 | [taxonomy_kingdom] 67 | description = "Taxonomy: kingdom" 68 | 69 | [taxonomy_kingdom.fields] 70 | name = { type = "str" } 71 | 72 | [taxonomy_phylum] 73 | description = "Taxonomy: phylum" 74 | 75 | [taxonomy_phylum.fields] 76 | name = { type = "str" } 77 | kingdom = { type = "relation", schema = { name = "taxonomy_kingdom" } } 78 | 79 | [taxonomy_class] 80 | description = "Taxonomy: class" 81 | 82 | [taxonomy_class.fields] 83 | name = { type = "str" } 84 | phylum = { type = "relation", schema = { name = "taxonomy_phylum" } } 85 | 86 | [taxonomy_order] 87 | description = "Taxonomy: order" 88 | 89 | [taxonomy_order.fields] 90 | name = { type = "str" } 91 | class = { type = "relation", schema = { name = "taxonomy_class" } } 92 | 93 | [taxonomy_family] 94 | description = "Taxonomy: family" 95 | 96 | [taxonomy_family.fields] 97 | name = { type = "str" } 98 | order = { type = "relation", schema = { name = "taxonomy_order" } } 99 | 100 | [taxonomy_subfamily] 101 | description = "Taxonomy: subfamily" 102 | 103 | [taxonomy_subfamily.fields] 104 | name = { type = "str" } 105 | family = { type = "relation", schema = { name = "taxonomy_family" } } 106 | 107 | [taxonomy_tribe] 108 | description = "Taxonomy: tribe" 109 | 110 | [taxonomy_tribe.fields] 111 | name = { type = "str" } 112 | subfamily = { type = "relation", schema = { name = "taxonomy_subfamily" } } 113 | 114 | [taxonomy_genus] 115 | description = "Taxonomy: genus" 116 | 117 | [taxonomy_genus.fields] 118 | name = { type = "str" } 119 | tribe = { type = "relation", schema = { name = "taxonomy_tribe" } } 120 | 121 | [taxonomy_species] 122 | description = "Taxonomy: species" 123 | 124 | [taxonomy_species.fields] 125 | name = { type = "str" } 126 | genus = { type = "relation", schema = { name = "taxonomy_genus" } } 127 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | BUILD_DIR=platform-build 6 | JNI_DIR=jniLibs 7 | APP_DIR=../packages/p2panda_flutter/android/src/main 8 | 9 | # Generate FFI bindings from Rust and build native libraries for Android. 10 | 11 | echo "◆ Install Rust toolchain dependencies" 12 | echo 13 | 14 | # Set up required Cargo toolbelt applications and Android compilation targets 15 | cargo install cargo-ndk 16 | cargo install cargo-expand 17 | cargo install flutter_rust_bridge_codegen@1.82.6 18 | rustup target add \ 19 | aarch64-linux-android \ 20 | armv7-linux-androideabi \ 21 | x86_64-linux-android \ 22 | i686-linux-android 23 | 24 | echo "◆ Generate FFI bindings" 25 | echo 26 | 27 | bridge_codegen() { 28 | flutter_rust_bridge_codegen \ 29 | --inline-rust \ 30 | --skip-add-mod-to-lib \ 31 | --rust-input packages/p2panda/native/src/api.rs \ 32 | --dart-output packages/p2panda/lib/src/bridge_generated.dart 33 | } 34 | 35 | # For non-Debian Linux distributions we need extra prerequisites 36 | if [[ (! -f "/etc/debian_version") && ("$OSTYPE" == "linux-gnu"* )]]; then 37 | # See: https://cjycode.com/flutter_rust_bridge/v1/integrate/deps.html#system-dependencies 38 | CPATH="$(clang -v 2>&1 | grep "Selected GCC installation" | rev | cut -d' ' -f1 | rev)/include" \ 39 | bridge_codegen 40 | else 41 | bridge_codegen 42 | fi 43 | 44 | echo 45 | echo "◆ Create folders" 46 | echo 47 | 48 | mkdir -p $BUILD_DIR 49 | cd $BUILD_DIR 50 | mkdir -p $JNI_DIR 51 | 52 | echo "◆ Build project" 53 | echo 54 | 55 | # Build the android libraries in the jniLibs directory 56 | cargo ndk -o $JNI_DIR \ 57 | --manifest-path ../packages/p2panda/native/Cargo.toml \ 58 | -t armeabi-v7a \ 59 | -t arm64-v8a \ 60 | -t x86 \ 61 | -t x86_64 \ 62 | build --release 63 | 64 | echo "◆ Publish libraries" 65 | echo 66 | 67 | # Move libraries into plugin folder 68 | cp -fvR $JNI_DIR $APP_DIR 69 | 70 | # Cleanup 71 | rm -rf $JNI_DIR 72 | -------------------------------------------------------------------------------- /scripts/clear.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | rm -ri ./target 6 | rm -ri ./platform-build 7 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | FLAVOR=${FLAVOR:-normal} 6 | TARGET_DIR=./build/app/outputs/flutter-apk 7 | 8 | version=$(grep 'version:' ./packages/app/pubspec.yaml | awk '{ print $2 }') 9 | 10 | echo "◆ Clean up previous builds" 11 | echo 12 | 13 | cd ./packages/app 14 | rm -rf ./build/app/outputs/flutter-apk 15 | 16 | echo "◆ Build multiple .apk files per architecture" 17 | echo 18 | 19 | flutter build apk \ 20 | --dart-define=PSK=$PSK \ 21 | --dart-define=RELAY_ADDRESS=$RELAY_ADDRESS \ 22 | --release \ 23 | --flavor $FLAVOR \ 24 | --split-per-abi \ 25 | --obfuscate \ 26 | --split-debug-info $TARGET_DIR 27 | 28 | echo 29 | echo "◆ Build combined .apk file for all architectures" 30 | echo 31 | 32 | flutter build apk \ 33 | --dart-define=PSK=$PSK \ 34 | --dart-define=RELAY_ADDRESS=$RELAY_ADDRESS \ 35 | --release \ 36 | --flavor $FLAVOR \ 37 | --obfuscate \ 38 | --split-debug-info $TARGET_DIR 39 | 40 | echo 41 | echo "◆ Give files nice names" 42 | echo 43 | 44 | cd $TARGET_DIR 45 | 46 | for file in *.apk*; do 47 | new_file=$(echo "$file" | sed "s/app/meli/") 48 | new_file=$(echo "$new_file" | sed "s/$FLAVOR/$FLAVOR-$version/") 49 | new_file=$(echo "$new_file" | sed "s/\+/-/") 50 | mv $file $new_file 51 | echo "- $new_file" 52 | done 53 | --------------------------------------------------------------------------------