diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c3836f22..f465e4ef 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -37,7 +37,3 @@ updates: directory: "/web" schedule: interval: "weekly" - - package-ecosystem: "pub" - directory: "/app" - schedule: - interval: "weekly" diff --git a/.github/workflows/README.md b/.github/workflows/README.md index c2cedf64..d2414540 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -13,7 +13,4 @@ - `ruby.yml` - Ruby Sinatra app testing - `rust.yml` - Matrix bridge (Rust) build and test - `javascript.yml` - Frontend test suite -- `mobile.yml` - Flutter mobile tests with coverage reporting -- `release.yml` - Flutter release builds for Android and iOS (disabled: - manual `workflow_dispatch` only; the tag-push trigger is commented out) diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml deleted file mode 100644 index be1e75de..00000000 --- a/.github/workflows/mobile.yml +++ /dev/null @@ -1,75 +0,0 @@ -# Copyright © 2025-26 l5yth & contributors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -name: Mobile - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - paths: - - '.github/**' - - 'app/**' - - 'tests/**' - -permissions: - contents: read - -jobs: - flutter: - strategy: - # Report both platforms independently: a macOS failure used to cancel the - # Ubuntu job mid-build, hiding whether the Android build actually passed. - fail-fast: false - matrix: - os: - - ubuntu-latest - - macos-latest - runs-on: ${{ matrix.os }} - defaults: - run: - working-directory: ./app - steps: - - uses: actions/checkout@v7 - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - channel: stable - cache: true - - name: Install dependencies - run: flutter pub get - - name: Run Flutter tests with coverage - run: flutter test --coverage - - name: Check formatting - run: dart format --set-exit-if-changed . - - name: Analyze Dart code - run: flutter analyze - - name: Build Android debug APK - if: matrix.os == 'ubuntu-latest' - run: flutter build apk --debug - - name: Build iOS debug IPA - if: matrix.os == 'macos-latest' - run: flutter build ipa --debug --no-codesign - - name: Upload coverage to Codecov - if: always() - uses: codecov/codecov-action@v7 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage/lcov.info - flags: flutter-mobile - name: flutter-mobile - fail_ci_if_error: false - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 90c0b4db..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,151 +0,0 @@ -# Copyright © 2025-26 l5yth & contributors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -name: Mobile Release - -# DISABLED: the Flutter reader apps are currently dysfunctional and must not be -# released until the underlying issues are fixed. The automatic tag-push trigger -# is intentionally removed so version tags (e.g. v0.7.3) do NOT build or publish -# mobile release artifacts. The workflow is retained under workflow_dispatch so -# maintainers can still run it manually while validating a fix; restore the -# "push: tags" trigger below to re-enable automatic releases. -on: - workflow_dispatch: - # push: - # tags: - # - "*" - -permissions: - contents: write - actions: write - -jobs: - android-release: - name: Android Release - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./app - env: - TAG_NAME: ${{ github.ref_name }} - steps: - - uses: actions/checkout@v7 - - name: Configure Android local.properties - run: | - cat > android/local.properties <<'EOF' - sdk.dir=${ANDROID_SDK_ROOT} - flutter.sdk=${FLUTTER_ROOT} - EOF - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - channel: stable - cache: true - cache-key: ${{ runner.os }}-flutter-${{ hashFiles('app/pubspec.lock') }} - - name: Install dependencies - run: flutter pub get - - name: Build release APK - run: flutter build apk --release - - name: Prepare artifacts - run: | - set -euo pipefail - APK_DIR="build/app/outputs/flutter-apk" - mv "${APK_DIR}/app-release.apk" "${APK_DIR}/potatomesh-reader-android-${TAG_NAME}.apk" - (cd "${APK_DIR}" && sha256sum "potatomesh-reader-android-${TAG_NAME}.apk" > "potatomesh-reader-android-${TAG_NAME}.apk.sha256sum") - - name: Upload release artifacts - uses: actions/upload-artifact@v7 - with: - name: potatomesh-reader-android-${{ env.TAG_NAME }} - path: | - app/build/app/outputs/flutter-apk/potatomesh-reader-android-${{ env.TAG_NAME }}.apk - app/build/app/outputs/flutter-apk/potatomesh-reader-android-${{ env.TAG_NAME }}.apk.sha256sum - - ios-release: - name: iOS Release - runs-on: macos-latest - defaults: - run: - working-directory: ./app - env: - TAG_NAME: ${{ github.ref_name }} - steps: - - uses: actions/checkout@v7 - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - channel: stable - cache: true - cache-key: ${{ runner.os }}-flutter-${{ hashFiles('app/pubspec.lock') }} - - name: Install dependencies - run: flutter pub get - - name: Build release IPA (no codesign) - run: flutter build ipa --release --no-codesign - - name: Prepare artifacts - run: | - set -euo pipefail - IPA_DIR="build/ios/ipa" - IPA_FILE="$(ls "${IPA_DIR}"/*.ipa | head -n 1)" - mv "${IPA_FILE}" "${IPA_DIR}/potatomesh-reader-ios-${TAG_NAME}.ipa" - (cd "${IPA_DIR}" && shasum -a 256 "potatomesh-reader-ios-${TAG_NAME}.ipa" > "potatomesh-reader-ios-${TAG_NAME}.ipa.sha256sum") - - name: Upload release artifacts - uses: actions/upload-artifact@v7 - with: - name: potatomesh-reader-ios-${{ env.TAG_NAME }} - path: | - app/build/ios/ipa/potatomesh-reader-ios-${{ env.TAG_NAME }}.ipa - app/build/ios/ipa/potatomesh-reader-ios-${{ env.TAG_NAME }}.ipa.sha256sum - - publish-release: - name: Publish Release - runs-on: ubuntu-latest - needs: - - android-release - - ios-release - env: - TAG_NAME: ${{ github.ref_name }} - PUBLISH_RELEASE: "false" - if: ${{ startsWith(github.ref, 'refs/tags/v') }} - steps: - - uses: actions/checkout@v7 - - name: Check tag format - run: | - set -euo pipefail - if [[ "${TAG_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "PUBLISH_RELEASE=true" >> "$GITHUB_ENV" - else - echo "Non point release tag (${TAG_NAME}); skipping publish." - fi - - name: Download Android artifacts - if: env.PUBLISH_RELEASE == 'true' - uses: actions/download-artifact@v8 - with: - name: potatomesh-reader-android-${{ env.TAG_NAME }} - path: artifacts/android - - name: Download iOS artifacts - if: env.PUBLISH_RELEASE == 'true' - uses: actions/download-artifact@v8 - with: - name: potatomesh-reader-ios-${{ env.TAG_NAME }} - path: artifacts/ios - - name: Attach assets to release - if: env.PUBLISH_RELEASE == 'true' - uses: softprops/action-gh-release@v3 - with: - tag_name: ${{ github.ref_name }} - generate_release_notes: true - files: | - artifacts/android/potatomesh-reader-android-${{ env.TAG_NAME }}.apk - artifacts/android/potatomesh-reader-android-${{ env.TAG_NAME }}.apk.sha256sum - artifacts/ios/potatomesh-reader-ios-${{ env.TAG_NAME }}.ipa - artifacts/ios/potatomesh-reader-ios-${{ env.TAG_NAME }}.ipa.sha256sum diff --git a/CHANGELOG.md b/CHANGELOG.md index 49fef20c..5c6ab5aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,6 +151,7 @@ * Docs: Reticulum is marked experimental and announce-only; the README says what the RNS stack transmits and that the channel filters ignore it (SPEC RD7/RN5) * Nix: the flake ingestor includes `rns`, so `PROTOCOL=reticulum` runs on NixOS; `flake.lock` moves nixpkgs to nixos-unstable of 2026-10-03, which brings Python 3.14 and Ruby 3.4.9 (SPEC DP8) * Docs: `CONTRACTS.md` states the Reticulum placeholder and headline name rules as amended (SPEC RA10, RE10) +* App: the Flutter app (`app/`) and its CI are removed (SPEC AR1) ### Features * Data/Web: Reticulum protocol support - `PROTOCOL=reticulum` ingests announces as `protocol="reticulum"` nodes end-to-end (ingest, stats, federation wire, UI) (SPEC S6/FS2/MA5/MA-F2/F2-2 as amended) diff --git a/README.md b/README.md index 9eea95f9..b76e90c9 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,6 @@ _No MQTT clutter, just local LoRa aether._ * Supports multiple ingestors per instance. * Supports Meshtastic, Meshcore, and Reticulum (experimental, announce-only). * Matrix bridge posts Meshtastic messages to a Matrix channel (no radio required). -* Mobile app to _read_ messages on your local aether (no radio required). Live demo for Berlin: [potatomesh.net](https://potatomesh.net) @@ -39,7 +38,6 @@ Jump to Contents: - [Nix](#nix) - nix deployment - [Docker](#docker) - docker deployment - [Matrix Bridge](#matrix-bridge) - configuration and deployment -- [Mobile App](#mobile-app) - reader app ## Web App @@ -601,11 +599,6 @@ channel (no radio required). See [matrix/README.md](./matrix/README.md). ![matrix bridge](./scrot-0.6.png) -## Mobile App - -Work in progress. A read-only reader app for Android and iOS. See -[app/README.md](./app/README.md). - ## Demos Post your nodes and screenshots here: diff --git a/app/.gitignore b/app/.gitignore deleted file mode 100644 index cb26a890..00000000 --- a/app/.gitignore +++ /dev/null @@ -1,29 +0,0 @@ - -# FVM Version Cache -.fvm/ - -# Scaffholding -.dart_tool/ -.flutter-plugins-dependencies -.fvmrc -.idea/ -.metadata -linux/ -macos/ -potato_mesh_reader.iml -pubspec.lock -web/ -windows/ -# Android/Gradle outputs -android/.gradle/ -android/app/build/ - -# iOS/Xcode outputs -ios/Pods/ -ios/.symlinks/ -ios/Flutter/ephemeral/ -ios/Flutter/App.framework -ios/Flutter/Flutter.framework -ios/Flutter/flutter_export_environment.sh -ios/Flutter/Generated.xcconfig -ios/Runner/GeneratedPluginRegistrant.* diff --git a/app/README.md b/app/README.md deleted file mode 100644 index 35c5972e..00000000 --- a/app/README.md +++ /dev/null @@ -1,16 +0,0 @@ - - - -# PotatoMesh Mobile - -PotatoMesh Mobile - read-only mesh chat client for Android and iOS. -Supports Meshtastic networks only. - -## Setup - -```bash -cd app -flutter create . -flutter pub get -flutter run -``` diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml deleted file mode 100644 index ff4ae7ad..00000000 --- a/app/analysis_options.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# Copyright © 2025-26 l5yth & contributors -# Licensed under the Apache License, Version 2.0 (see LICENSE) -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at https://dart.dev/lints. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/app/android/.gitignore b/app/android/.gitignore deleted file mode 100644 index be82512e..00000000 --- a/app/android/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -/.gradle -/captures/ -/local.properties -GeneratedPluginRegistrant.java -.cxx/ - -# Remember to never publicly share your keystore. -# See https://flutter.dev/to/reference-keystore -key.properties -**/*.keystore -**/*.jks diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts deleted file mode 100644 index 14792d5d..00000000 --- a/app/android/app/build.gradle.kts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright © 2025-26 l5yth & contributors - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -plugins { - id("com.android.application") - id("kotlin-android") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id("dev.flutter.flutter-gradle-plugin") -} - -android { - namespace = "net.potatomesh.reader" - compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - isCoreLibraryDesugaringEnabled = true - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "net.potatomesh.reader" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion - targetSdk = flutter.targetSdkVersion - versionCode = flutter.versionCode - versionName = flutter.versionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") - } - } -} - -flutter { - source = "../.." -} - -dependencies { - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") -} diff --git a/app/android/app/src/debug/AndroidManifest.xml b/app/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 399f6981..00000000 --- a/app/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index c11b1afb..00000000 --- a/app/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/android/app/src/main/kotlin/net/potatomesh/reader/MainActivity.kt b/app/android/app/src/main/kotlin/net/potatomesh/reader/MainActivity.kt deleted file mode 100644 index fd838683..00000000 --- a/app/android/app/src/main/kotlin/net/potatomesh/reader/MainActivity.kt +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright © 2025-26 l5yth & contributors -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package net.potatomesh.reader - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity : FlutterActivity() diff --git a/app/android/app/src/main/res/drawable-hdpi/android12splash.png b/app/android/app/src/main/res/drawable-hdpi/android12splash.png deleted file mode 100644 index ea7f6608..00000000 Binary files a/app/android/app/src/main/res/drawable-hdpi/android12splash.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png deleted file mode 100644 index 7850300f..00000000 Binary files a/app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable-hdpi/splash.png b/app/android/app/src/main/res/drawable-hdpi/splash.png deleted file mode 100644 index ea7f6608..00000000 Binary files a/app/android/app/src/main/res/drawable-hdpi/splash.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable-mdpi/android12splash.png b/app/android/app/src/main/res/drawable-mdpi/android12splash.png deleted file mode 100644 index 1d83cceb..00000000 Binary files a/app/android/app/src/main/res/drawable-mdpi/android12splash.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png deleted file mode 100644 index 162d2394..00000000 Binary files a/app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable-mdpi/splash.png b/app/android/app/src/main/res/drawable-mdpi/splash.png deleted file mode 100644 index 1d83cceb..00000000 Binary files a/app/android/app/src/main/res/drawable-mdpi/splash.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable-night-hdpi/android12splash.png b/app/android/app/src/main/res/drawable-night-hdpi/android12splash.png deleted file mode 100644 index ea7f6608..00000000 Binary files a/app/android/app/src/main/res/drawable-night-hdpi/android12splash.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable-night-mdpi/android12splash.png b/app/android/app/src/main/res/drawable-night-mdpi/android12splash.png deleted file mode 100644 index 1d83cceb..00000000 Binary files a/app/android/app/src/main/res/drawable-night-mdpi/android12splash.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable-night-xhdpi/android12splash.png b/app/android/app/src/main/res/drawable-night-xhdpi/android12splash.png deleted file mode 100644 index c10e7799..00000000 Binary files a/app/android/app/src/main/res/drawable-night-xhdpi/android12splash.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png b/app/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png deleted file mode 100644 index f3934fc8..00000000 Binary files a/app/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png b/app/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png deleted file mode 100644 index 16fd9a86..00000000 Binary files a/app/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable-v21/background.png b/app/android/app/src/main/res/drawable-v21/background.png deleted file mode 100644 index 80e98160..00000000 Binary files a/app/android/app/src/main/res/drawable-v21/background.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable-v21/launch_background.xml b/app/android/app/src/main/res/drawable-v21/launch_background.xml deleted file mode 100644 index 3cc4948a..00000000 --- a/app/android/app/src/main/res/drawable-v21/launch_background.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/app/android/app/src/main/res/drawable-xhdpi/android12splash.png b/app/android/app/src/main/res/drawable-xhdpi/android12splash.png deleted file mode 100644 index c10e7799..00000000 Binary files a/app/android/app/src/main/res/drawable-xhdpi/android12splash.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index c1c517dc..00000000 Binary files a/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable-xhdpi/splash.png b/app/android/app/src/main/res/drawable-xhdpi/splash.png deleted file mode 100644 index c10e7799..00000000 Binary files a/app/android/app/src/main/res/drawable-xhdpi/splash.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/app/android/app/src/main/res/drawable-xxhdpi/android12splash.png deleted file mode 100644 index f3934fc8..00000000 Binary files a/app/android/app/src/main/res/drawable-xxhdpi/android12splash.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 13bfeeed..00000000 Binary files a/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable-xxhdpi/splash.png b/app/android/app/src/main/res/drawable-xxhdpi/splash.png deleted file mode 100644 index f3934fc8..00000000 Binary files a/app/android/app/src/main/res/drawable-xxhdpi/splash.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable-xxxhdpi/android12splash.png b/app/android/app/src/main/res/drawable-xxxhdpi/android12splash.png deleted file mode 100644 index 16fd9a86..00000000 Binary files a/app/android/app/src/main/res/drawable-xxxhdpi/android12splash.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 7326905d..00000000 Binary files a/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable-xxxhdpi/splash.png b/app/android/app/src/main/res/drawable-xxxhdpi/splash.png deleted file mode 100644 index 16fd9a86..00000000 Binary files a/app/android/app/src/main/res/drawable-xxxhdpi/splash.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable/background.png b/app/android/app/src/main/res/drawable/background.png deleted file mode 100644 index 80e98160..00000000 Binary files a/app/android/app/src/main/res/drawable/background.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/android/app/src/main/res/drawable/ic_launcher_monochrome.xml deleted file mode 100644 index 3acf30f5..00000000 --- a/app/android/app/src/main/res/drawable/ic_launcher_monochrome.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/app/android/app/src/main/res/drawable/ic_mesh_notification.xml b/app/android/app/src/main/res/drawable/ic_mesh_notification.xml deleted file mode 100644 index ccdfa694..00000000 --- a/app/android/app/src/main/res/drawable/ic_mesh_notification.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/app/android/app/src/main/res/drawable/launch_background.xml b/app/android/app/src/main/res/drawable/launch_background.xml deleted file mode 100644 index 3cc4948a..00000000 --- a/app/android/app/src/main/res/drawable/launch_background.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 1a816d66..00000000 --- a/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 94547ae6..00000000 Binary files a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index bfd912a6..00000000 Binary files a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 7f5b7ad3..00000000 Binary files a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 492709d1..00000000 Binary files a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 17e55f6b..00000000 Binary files a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/android/app/src/main/res/values-night-v31/styles.xml b/app/android/app/src/main/res/values-night-v31/styles.xml deleted file mode 100644 index 84a1ae02..00000000 --- a/app/android/app/src/main/res/values-night-v31/styles.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - diff --git a/app/android/app/src/main/res/values-night/styles.xml b/app/android/app/src/main/res/values-night/styles.xml deleted file mode 100644 index dbc9ea9f..00000000 --- a/app/android/app/src/main/res/values-night/styles.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/app/android/app/src/main/res/values-v31/styles.xml b/app/android/app/src/main/res/values-v31/styles.xml deleted file mode 100644 index 2fbca4ed..00000000 --- a/app/android/app/src/main/res/values-v31/styles.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - diff --git a/app/android/app/src/main/res/values/colors.xml b/app/android/app/src/main/res/values/colors.xml deleted file mode 100644 index 0a7942f1..00000000 --- a/app/android/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #111417 - \ No newline at end of file diff --git a/app/android/app/src/main/res/values/styles.xml b/app/android/app/src/main/res/values/styles.xml deleted file mode 100644 index 0d1fa8fc..00000000 --- a/app/android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/app/android/app/src/profile/AndroidManifest.xml b/app/android/app/src/profile/AndroidManifest.xml deleted file mode 100644 index 399f6981..00000000 --- a/app/android/app/src/profile/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts deleted file mode 100644 index 8dedf8b1..00000000 --- a/app/android/build.gradle.kts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright © 2025-26 l5yth & contributors - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -allprojects { - repositories { - google() - mavenCentral() - } -} - -val newBuildDir: Directory = - rootProject.layout.buildDirectory - .dir("../../build") - .get() -rootProject.layout.buildDirectory.value(newBuildDir) - -subprojects { - val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) - project.layout.buildDirectory.value(newSubprojectBuildDir) -} -subprojects { - project.evaluationDependsOn(":app") -} - -tasks.register("clean") { - delete(rootProject.layout.buildDirectory) -} diff --git a/app/android/gradle.properties b/app/android/gradle.properties deleted file mode 100644 index fbee1d8c..00000000 --- a/app/android/gradle.properties +++ /dev/null @@ -1,2 +0,0 @@ -org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -android.useAndroidX=true diff --git a/app/android/gradle/wrapper/gradle-wrapper.jar b/app/android/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 13372aef..00000000 Binary files a/app/android/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/app/android/gradle/wrapper/gradle-wrapper.properties b/app/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index e4ef43fb..00000000 --- a/app/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/app/android/gradlew b/app/android/gradlew deleted file mode 100755 index 9d82f789..00000000 --- a/app/android/gradlew +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env bash - -############################################################################## -## -## Gradle start up script for UN*X -## -############################################################################## - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" - -warn ( ) { - echo "$*" -} - -die ( ) { - echo - echo "$*" - echo - exit 1 -} - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; -esac - -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" - else - JAVACMD="$JAVA_HOME/bin/java" - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=$((i+1)) - done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac -fi - -# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules -function splitJvmOpts() { - JVM_OPTS=("$@") -} -eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS -JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" - -exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/app/android/gradlew.bat b/app/android/gradlew.bat deleted file mode 100644 index aec99730..00000000 --- a/app/android/gradlew.bat +++ /dev/null @@ -1,90 +0,0 @@ -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windowz variants - -if not "%OS%" == "Windows_NT" goto win9xME_args -if "%@eval[2+2]" == "4" goto 4NT_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* -goto execute - -:4NT_args -@rem Get arguments from the 4NT Shell from JP Software -set CMD_LINE_ARGS=%$ - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/app/android/potato_mesh_reader_android.iml b/app/android/potato_mesh_reader_android.iml deleted file mode 100644 index 3bc4b3b3..00000000 --- a/app/android/potato_mesh_reader_android.iml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/app/android/settings.gradle.kts b/app/android/settings.gradle.kts deleted file mode 100644 index 822b6d7b..00000000 --- a/app/android/settings.gradle.kts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright © 2025-26 l5yth & contributors - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -pluginManagement { - val flutterSdkPath = - run { - val properties = java.util.Properties() - file("local.properties").inputStream().use { properties.load(it) } - val flutterSdkPath = properties.getProperty("flutter.sdk") - require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } - flutterSdkPath - } - - includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") - - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -plugins { - id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.11.1" apply false - id("org.jetbrains.kotlin.android") version "2.2.20" apply false -} - -include(":app") diff --git a/app/assets/icon-launcher.png b/app/assets/icon-launcher.png deleted file mode 100644 index 0b257a09..00000000 Binary files a/app/assets/icon-launcher.png and /dev/null differ diff --git a/app/assets/icon-splash.png b/app/assets/icon-splash.png deleted file mode 100644 index 209db0fa..00000000 Binary files a/app/assets/icon-splash.png and /dev/null differ diff --git a/app/assets/icon.png b/app/assets/icon.png deleted file mode 100644 index 209db0fa..00000000 Binary files a/app/assets/icon.png and /dev/null differ diff --git a/app/assets/monochrom.svg b/app/assets/monochrom.svg deleted file mode 100644 index 29160afa..00000000 --- a/app/assets/monochrom.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/assets/potatomesh-logo.svg b/app/assets/potatomesh-logo.svg deleted file mode 100644 index 1e78f738..00000000 --- a/app/assets/potatomesh-logo.svg +++ /dev/null @@ -1,352 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/debug.sh b/app/debug.sh deleted file mode 100755 index 8db11363..00000000 --- a/app/debug.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env bash - -# Copyright © 2025-26 l5yth & contributors -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -export GIT_TAG="$(git describe --tags --abbrev=0)" -export GIT_COMMITS="$(git rev-list --count ${GIT_TAG}..HEAD)" -export GIT_SHA="$(git rev-parse --short=9 HEAD)" -export GIT_DIRTY="$(git diff --quiet --ignore-submodules HEAD || echo true || echo false)" -flutter clean -flutter pub get -flutter run \ - --dart-define=GIT_TAG="${GIT_TAG}" \ - --dart-define=GIT_COMMITS="${GIT_COMMITS}" \ - --dart-define=GIT_SHA="${GIT_SHA}" \ - --dart-define=GIT_DIRTY="${GIT_DIRTY}" \ - --device-id 38151FDJH00D4C diff --git a/app/ios/.gitignore b/app/ios/.gitignore deleted file mode 100644 index 7a7f9873..00000000 --- a/app/ios/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -**/dgph -*.mode1v3 -*.mode2v3 -*.moved-aside -*.pbxuser -*.perspectivev3 -**/*sync/ -.sconsign.dblite -.tags* -**/.vagrant/ -**/DerivedData/ -Icon? -**/Pods/ -**/.symlinks/ -profile -xcuserdata -**/.generated/ -Flutter/App.framework -Flutter/Flutter.framework -Flutter/Flutter.podspec -Flutter/Generated.xcconfig -Flutter/ephemeral/ -Flutter/app.flx -Flutter/app.zip -Flutter/flutter_assets/ -Flutter/flutter_export_environment.sh -ServiceDefinitions.json -Runner/GeneratedPluginRegistrant.* - -# Exceptions to above rules. -!default.mode1v3 -!default.mode2v3 -!default.pbxuser -!default.perspectivev3 diff --git a/app/ios/Flutter/AppFrameworkInfo.plist b/app/ios/Flutter/AppFrameworkInfo.plist deleted file mode 100644 index 7e687298..00000000 --- a/app/ios/Flutter/AppFrameworkInfo.plist +++ /dev/null @@ -1,26 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - io.flutter.flutter.app - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0.0 - MinimumOSVersion - 15.0 - - diff --git a/app/ios/Flutter/Debug.xcconfig b/app/ios/Flutter/Debug.xcconfig deleted file mode 100644 index ec97fc6f..00000000 --- a/app/ios/Flutter/Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" -#include "Generated.xcconfig" diff --git a/app/ios/Flutter/Release.xcconfig b/app/ios/Flutter/Release.xcconfig deleted file mode 100644 index c4855bfe..00000000 --- a/app/ios/Flutter/Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" -#include "Generated.xcconfig" diff --git a/app/ios/Podfile b/app/ios/Podfile deleted file mode 100644 index f919abf1..00000000 --- a/app/ios/Podfile +++ /dev/null @@ -1,56 +0,0 @@ -# Copyright © 2025-26 l5yth & contributors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -platform :ios, "15.0" - -ENV["COCOAPODS_DISABLE_STATS"] = "true" - -project "Runner", { - "Debug" => :debug, - "Profile" => :release, - "Release" => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join("..", "Flutter", "Generated.xcconfig"), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Run flutter pub get and try again." -end - -require File.expand_path(File.join("packages", "flutter_tools", "bin", "podhelper"), flutter_root) - -flutter_ios_podfile_setup - -target "Runner" do - use_frameworks! - use_modular_headers! - - flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_ios_build_settings(target) - target.build_configurations.each do |config| - config.build_settings["IPHONEOS_DEPLOYMENT_TARGET"] = "15.0" - end - end -end diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index f2672f52..00000000 --- a/app/ios/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,616 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXBuildFile section */ - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 97C146E61CF9000F007C117D /* Project object */; - proxyType = 1; - remoteGlobalIDString = 97C146ED1CF9000F007C117D; - remoteInfo = Runner; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 9705A1C41CF9048500538489 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; - 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 97C146EB1CF9000F007C117D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 331C8082294A63A400263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C807B294A618700263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; - 9740EEB11CF90186004384FC /* Flutter */ = { - isa = PBXGroup; - children = ( - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 9740EEB31CF90195004384FC /* Generated.xcconfig */, - ); - name = Flutter; - sourceTree = ""; - }; - 97C146E51CF9000F007C117D = { - isa = PBXGroup; - children = ( - 9740EEB11CF90186004384FC /* Flutter */, - 97C146F01CF9000F007C117D /* Runner */, - 97C146EF1CF9000F007C117D /* Products */, - 331C8082294A63A400263BE5 /* RunnerTests */, - ); - sourceTree = ""; - }; - 97C146EF1CF9000F007C117D /* Products */ = { - isa = PBXGroup; - children = ( - 97C146EE1CF9000F007C117D /* Runner.app */, - 331C8081294A63A400263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 97C146F01CF9000F007C117D /* Runner */ = { - isa = PBXGroup; - children = ( - 97C146FA1CF9000F007C117D /* Main.storyboard */, - 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, - 97C147021CF9000F007C117D /* Info.plist */, - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - ); - path = Runner; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 331C8080294A63A400263BE5 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - 331C807D294A63A400263BE5 /* Sources */, - 331C807F294A63A400263BE5 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 331C8086294A63A400263BE5 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 97C146ED1CF9000F007C117D /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 9740EEB61CF901F6004384FC /* Run Script */, - 97C146EA1CF9000F007C117D /* Sources */, - 97C146EB1CF9000F007C117D /* Frameworks */, - 97C146EC1CF9000F007C117D /* Resources */, - 9705A1C41CF9048500538489 /* Embed Frameworks */, - 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Runner; - productName = Runner; - productReference = 97C146EE1CF9000F007C117D /* Runner.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 97C146E61CF9000F007C117D /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1510; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 331C8080294A63A400263BE5 = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 97C146ED1CF9000F007C117D; - }; - 97C146ED1CF9000F007C117D = { - CreatedOnToolsVersion = 7.3.1; - LastSwiftMigration = 1100; - }; - }; - }; - buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 97C146E51CF9000F007C117D; - productRefGroup = 97C146EF1CF9000F007C117D /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 97C146ED1CF9000F007C117D /* Runner */, - 331C8080294A63A400263BE5 /* RunnerTests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 331C807F294A63A400263BE5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EC1CF9000F007C117D /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - 9740EEB61CF901F6004384FC /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 331C807D294A63A400263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EA1CF9000F007C117D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 97C146ED1CF9000F007C117D /* Runner */; - targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 97C146FA1CF9000F007C117D /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C146FB1CF9000F007C117D /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 249021D3217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Profile; - }; - 249021D4217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = net.potatomesh.reader; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; - 331C8088294A63A400263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = net.potatomesh.reader.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Debug; - }; - 331C8089294A63A400263BE5 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = net.potatomesh.reader.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Release; - }; - 331C808A294A63A400263BE5 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = net.potatomesh.reader.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Profile; - }; - 97C147031CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 97C147041CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 97C147061CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = net.potatomesh.reader; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Debug; - }; - 97C147071CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = net.potatomesh.reader; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C8088294A63A400263BE5 /* Debug */, - 331C8089294A63A400263BE5 /* Release */, - 331C808A294A63A400263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147031CF9000F007C117D /* Debug */, - 97C147041CF9000F007C117D /* Release */, - 249021D3217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147061CF9000F007C117D /* Debug */, - 97C147071CF9000F007C117D /* Release */, - 249021D4217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 97C146E61CF9000F007C117D /* Project object */; -} diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a6..00000000 --- a/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c5..00000000 --- a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index e3773d42..00000000 --- a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/ios/Runner.xcworkspace/contents.xcworkspacedata b/app/ios/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 1d526a16..00000000 --- a/app/ios/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c5..00000000 --- a/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/app/ios/Runner/AppDelegate.swift b/app/ios/Runner/AppDelegate.swift deleted file mode 100644 index bfd25c14..00000000 --- a/app/ios/Runner/AppDelegate.swift +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright © 2025-26 l5yth & contributors -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import Flutter -import UIKit - -@main -@objc class AppDelegate: FlutterAppDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } -} diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d0d98aa1..00000000 --- a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1 +0,0 @@ -{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index 70faa41b..00000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index c5128b87..00000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index 6578c191..00000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index 5db6e142..00000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index ca0fcf20..00000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index 8b26aed0..00000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index fae4bf64..00000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index 6578c191..00000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index 7692c0a2..00000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index 6e10506c..00000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png deleted file mode 100644 index 63fd1dba..00000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png deleted file mode 100644 index 5da09a98..00000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png deleted file mode 100644 index 9e8ea787..00000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png deleted file mode 100644 index 3331cd6a..00000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index 6e10506c..00000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index 9048c7f1..00000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png deleted file mode 100644 index 04fba9cc..00000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png deleted file mode 100644 index 402197d6..00000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index c42b186a..00000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index a8115305..00000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index 3da5f2d4..00000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json b/app/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json deleted file mode 100644 index 9f447e1b..00000000 --- a/app/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "background.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/app/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png b/app/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png deleted file mode 100644 index 80e98160..00000000 Binary files a/app/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json deleted file mode 100644 index 00cabce8..00000000 --- a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "LaunchImage.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "LaunchImage@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "LaunchImage@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png deleted file mode 100644 index 1d83cceb..00000000 Binary files a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png deleted file mode 100644 index c10e7799..00000000 Binary files a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png deleted file mode 100644 index f3934fc8..00000000 Binary files a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md deleted file mode 100644 index 89c2725b..00000000 --- a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Launch Screen Assets - -You can customize the launch screen with your own desired assets by replacing the image files in this directory. - -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index 7aa6dfbc..00000000 --- a/app/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/ios/Runner/Base.lproj/Main.storyboard b/app/ios/Runner/Base.lproj/Main.storyboard deleted file mode 100644 index f3c28516..00000000 --- a/app/ios/Runner/Base.lproj/Main.storyboard +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist deleted file mode 100644 index 7212c418..00000000 --- a/app/ios/Runner/Info.plist +++ /dev/null @@ -1,51 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Potato Mesh Reader - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - PotatoMesh Reader - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - - UIStatusBarHidden - - - diff --git a/app/ios/Runner/Runner-Bridging-Header.h b/app/ios/Runner/Runner-Bridging-Header.h deleted file mode 100644 index 277595c3..00000000 --- a/app/ios/Runner/Runner-Bridging-Header.h +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright © 2025-26 l5yth & contributors -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -#import "GeneratedPluginRegistrant.h" diff --git a/app/ios/RunnerTests/RunnerTests.swift b/app/ios/RunnerTests/RunnerTests.swift deleted file mode 100644 index 4d2368b3..00000000 --- a/app/ios/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright © 2025-26 l5yth & contributors -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import Flutter -import UIKit -import XCTest - -class RunnerTests: XCTestCase { - - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. - } - -} diff --git a/app/lib/dart_plugin_registrant.dart b/app/lib/dart_plugin_registrant.dart deleted file mode 100644 index a2f0e64a..00000000 --- a/app/lib/dart_plugin_registrant.dart +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright © 2025-26 l5yth & contributors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'dart:io'; - -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:shared_preferences_android/shared_preferences_android.dart'; -import 'package:shared_preferences_foundation/shared_preferences_foundation.dart'; - -/// Minimal plugin registrant for background isolates. -/// -/// The Workmanager-provided background Flutter engine does not automatically -/// invoke the app's plugin registrant, so we register only the plugins needed -/// by our background task (notifications and shared preferences). -class DartPluginRegistrant { - static bool _initialized = false; - - static void ensureInitialized() { - if (_initialized) return; - if (Platform.isAndroid) { - try { - AndroidFlutterLocalNotificationsPlugin.registerWith(); - } catch (_) {} - try { - SharedPreferencesAndroid.registerWith(); - } catch (_) {} - } else if (Platform.isIOS) { - try { - IOSFlutterLocalNotificationsPlugin.registerWith(); - } catch (_) {} - try { - SharedPreferencesFoundation.registerWith(); - } catch (_) {} - } - _initialized = true; - } -} diff --git a/app/lib/main.dart b/app/lib/main.dart deleted file mode 100644 index 49cc610b..00000000 --- a/app/lib/main.dart +++ /dev/null @@ -1,3396 +0,0 @@ -// Copyright © 2025-26 l5yth & contributors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'dart:async'; -import 'dart:collection'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:math'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:http/http.dart' as http; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:workmanager/workmanager.dart'; -import 'package:potato_mesh_reader/dart_plugin_registrant.dart' - as dart_plugin_registrant; - -const String _gitVersionEnv = - String.fromEnvironment('GIT_VERSION', defaultValue: ''); -const String _gitTagEnv = String.fromEnvironment('GIT_TAG', defaultValue: ''); -const String _gitCommitsEnv = - String.fromEnvironment('GIT_COMMITS', defaultValue: ''); -const String _gitShaEnv = String.fromEnvironment('GIT_SHA', defaultValue: ''); -const String _gitDirtyEnv = - String.fromEnvironment('GIT_DIRTY', defaultValue: ''); -const Duration _requestTimeout = Duration(seconds: 5); -const String _themePreferenceKey = 'mesh.themeMode'; -const String _notificationChannelId = 'mesh.messages'; -const String _notificationChannelName = 'Mesh messages'; -const String _notificationChannelDescription = - 'Alerts when new PotatoMesh messages arrive'; -const String _notificationIconName = 'ic_mesh_notification'; -const String _backgroundTaskName = 'mesh_message_poll'; -const String _backgroundTaskId = 'mesh.message.poll'; -const Duration _backgroundFetchInterval = Duration(minutes: 15); - -/// Client interface used to deliver notifications when unseen messages arrive. -abstract class NotificationClient { - const NotificationClient(); - - /// Performs any platform-specific initialization, such as channel creation. - Future initialize(); - - /// Shows a notification for an unseen message. - Future showNewMessage({ - required MeshMessage message, - required String domain, - String? senderShortName, - String? senderLongName, - }); -} - -/// Display names used when rendering a notification title and subtitle. -class NotificationSender { - const NotificationSender({ - required this.shortName, - this.longName, - }); - - /// Short name fallback for the sender when a long name is unavailable. - final String shortName; - - /// Full descriptive name for the sender when provided by the mesh. - final String? longName; - - /// Preferred display name that favors the long form when available. - String get preferredName { - if (longName != null && longName!.trim().isNotEmpty) { - return longName!.trim(); - } - return shortName.trim(); - } -} - -/// No-op notification client used in tests and web builds. -class NoopNotificationClient implements NotificationClient { - const NoopNotificationClient(); - - @override - Future initialize() async {} - - @override - Future showNewMessage({ - required MeshMessage message, - required String domain, - String? senderShortName, - String? senderLongName, - }) async {} -} - -/// Platform-aware notification client backed by the Flutter Local Notifications plugin. -class LocalNotificationClient implements NotificationClient { - LocalNotificationClient({FlutterLocalNotificationsPlugin? plugin}) - : _plugin = plugin ?? FlutterLocalNotificationsPlugin(); - - final FlutterLocalNotificationsPlugin _plugin; - bool _initialized = false; - - AndroidNotificationChannel get _channel => const AndroidNotificationChannel( - _notificationChannelId, - _notificationChannelName, - description: _notificationChannelDescription, - importance: Importance.high, - ); - - @override - Future initialize() async { - if (_initialized) return; - if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) { - // Unit tests and desktop builds do not have a notification host. - _initialized = true; - return; - } - final androidInit = buildAndroidInitializationSettings(); - final iosInit = buildDarwinInitializationSettings(); - final settings = InitializationSettings(android: androidInit, iOS: iosInit); - await _plugin.initialize(settings: settings); - - final android = _plugin.resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin>(); - await android?.createNotificationChannel(_channel); - if (Platform.isAndroid) { - final enabled = await android?.areNotificationsEnabled() ?? true; - if (!enabled) { - final granted = - await android?.requestNotificationsPermission() ?? false; - debugPrint('D/Notifications: permission requested; granted=$granted'); - } - } - debugPrint('D/Notifications: initialized'); - - _initialized = true; - } - - NotificationDetails _notificationDetails() { - const androidDetails = AndroidNotificationDetails( - _notificationChannelId, - _notificationChannelName, - channelDescription: _notificationChannelDescription, - importance: Importance.high, - priority: Priority.high, - enableVibration: true, - playSound: true, - category: AndroidNotificationCategory.message, - icon: _notificationIconName, - ); - const iosDetails = DarwinNotificationDetails( - presentAlert: true, - presentBadge: true, - presentSound: true, - categoryIdentifier: 'chat', - ); - return const NotificationDetails( - android: androidDetails, - iOS: iosDetails, - ); - } - - /// Builds the default Android initialization settings. - @visibleForTesting - AndroidInitializationSettings buildAndroidInitializationSettings() { - return const AndroidInitializationSettings(_notificationIconName); - } - - /// Builds the default Darwin (iOS) initialization settings. - @visibleForTesting - DarwinInitializationSettings buildDarwinInitializationSettings() { - return const DarwinInitializationSettings( - requestAlertPermission: true, - requestBadgePermission: true, - requestSoundPermission: true, - ); - } - - /// Exposes notification details for test assertions. - @visibleForTesting - NotificationDetails notificationDetailsForTest() { - return _notificationDetails(); - } - - /// Picks the preferred sender name prioritising the long form when present. - String _preferredSenderName({ - required MeshMessage message, - String? senderLongName, - String? senderShortName, - }) { - final trimmedLong = senderLongName?.trim(); - if (trimmedLong != null && trimmedLong.isNotEmpty) { - return trimmedLong; - } - final trimmedShort = senderShortName?.trim(); - if (trimmedShort != null && trimmedShort.isNotEmpty) { - return trimmedShort; - } - return message.fromShort; - } - - @override - Future showNewMessage({ - required MeshMessage message, - required String domain, - String? senderShortName, - String? senderLongName, - }) async { - await initialize(); - if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) return; - debugPrint('D/Notifications: showing message ${message.id} on $domain'); - final displaySender = _preferredSenderName( - senderLongName: senderLongName, - senderShortName: senderShortName, - message: message, - ); - final channel = message.channelName?.trim().isNotEmpty == true - ? message.channelName!.trim() - : domain; - final title = displaySender; - final body = message.text.trim().isNotEmpty - ? message.text.trim() - : 'New message on $channel'; - await _plugin.show( - id: message.id.hashCode.abs(), - title: title, - body: body, - notificationDetails: _notificationDetails(), - payload: domain, - ); - } -} - -/// Callback dispatcher used by the Workmanager plugin. -@pragma('vm:entry-point') -void _workmanagerCallbackDispatcher() { - Workmanager().executeTask((task, inputData) async { - try { - WidgetsFlutterBinding.ensureInitialized(); - dart_plugin_registrant.DartPluginRegistrant.ensureInitialized(); - return await BackgroundSyncManager.handleBackgroundTask( - task, - inputData, - ); - } catch (error, stackTrace) { - debugPrint('E/BackgroundSync dispatcher failed: $error\n$stackTrace'); - return false; - } - }); -} - -/// Workmanager abstraction to simplify testing. -abstract class WorkmanagerAdapter { - const WorkmanagerAdapter(); - - Future initialize(Function dispatcher); - - Future registerPeriodicTask( - String taskId, - String taskName, { - Duration frequency, - ExistingPeriodicWorkPolicy existingWorkPolicy, - Duration? initialDelay, - Constraints? constraints, - }); - - Future cancelAll(); -} - -/// Real Workmanager adapter used in production builds. -class FlutterWorkmanagerAdapter implements WorkmanagerAdapter { - FlutterWorkmanagerAdapter({Workmanager? delegate}) - : _delegate = delegate ?? Workmanager(); - - final Workmanager _delegate; - - @override - Future initialize(Function dispatcher) { - return _delegate.initialize(dispatcher); - } - - @override - Future registerPeriodicTask( - String taskId, - String taskName, { - Duration frequency = _backgroundFetchInterval, - ExistingPeriodicWorkPolicy existingWorkPolicy = - ExistingPeriodicWorkPolicy.keep, - Duration? initialDelay, - Constraints? constraints, - }) { - return _delegate.registerPeriodicTask( - taskId, - taskName, - frequency: frequency, - existingWorkPolicy: existingWorkPolicy, - initialDelay: initialDelay, - constraints: constraints, - inputData: const {}, - ); - } - - @override - Future cancelAll() { - return _delegate.cancelAll(); - } -} - -/// Factories used to build repositories and notifications for background work. -class BackgroundDependencies { - const BackgroundDependencies({ - required this.repositoryBuilder, - required this.notificationBuilder, - }); - - final Future Function() repositoryBuilder; - final Future Function() notificationBuilder; -} - -/// Schedules and executes periodic background message refreshes. -class BackgroundSyncManager { - BackgroundSyncManager({ - required this.workmanager, - required this.dependencies, - }); - - final WorkmanagerAdapter workmanager; - final BackgroundDependencies dependencies; - - static final BackgroundDependencies _defaultDependencies = - BackgroundDependencies( - repositoryBuilder: () async => MeshRepository(), - notificationBuilder: () async => LocalNotificationClient(), - ); - static BackgroundDependencies? _registeredDependencies; - - /// Registers dependencies for the Workmanager callback and schedules polling. - Future initialize({bool debugMode = false}) async { - _registeredDependencies = dependencies; - await workmanager.initialize(_workmanagerCallbackDispatcher); - } - - /// Schedules the periodic background fetch task. - Future ensurePeriodicTask() { - return workmanager.registerPeriodicTask( - _backgroundTaskId, - _backgroundTaskName, - frequency: _backgroundFetchInterval, - existingWorkPolicy: ExistingPeriodicWorkPolicy.keep, - initialDelay: const Duration(minutes: 1), - constraints: Constraints( - networkType: NetworkType.connected, - ), - ); - } - - /// Clears registered dependencies; intended for tests only. - @visibleForTesting - static void resetForTest() { - _registeredDependencies = null; - } - - /// Executes the background task to fetch messages and post notifications. - @pragma('vm:entry-point') - static Future handleBackgroundTask( - String task, - Map? inputData, - ) async { - WidgetsFlutterBinding.ensureInitialized(); - final deps = _registeredDependencies ?? - ((Platform.isAndroid || Platform.isIOS) ? _defaultDependencies : null); - if (deps == null) { - debugPrint('D/BackgroundSync: no dependencies registered; skipping'); - return true; - } - try { - debugPrint('D/BackgroundSync: start task=$task'); - final repository = await deps.repositoryBuilder(); - final notification = await deps.notificationBuilder(); - await notification.initialize(); - - final domain = await repository.loadSelectedDomainOrDefault(); - final messages = await repository.loadMessages(domain: domain); - final unseen = await repository.detectUnseenMessages( - domain: domain, - messages: messages, - ); - - debugPrint( - 'D/BackgroundSync: task=$task domain=$domain fetched=${messages.length} unseen=${unseen.length}'); - - for (final message in unseen) { - final sender = repository.resolveNotificationSender( - domain: domain, - message: message, - ); - debugPrint( - 'D/BackgroundSync: notifying message=${message.id} sender=${sender.preferredName}'); - await notification.showNewMessage( - message: message, - domain: domain, - senderShortName: sender.shortName, - senderLongName: sender.longName, - ); - } - return true; - } on SocketException catch (error) { - debugPrint('W/BackgroundSync: network unavailable ($error); will retry'); - return true; - } catch (error, stackTrace) { - debugPrint('E/BackgroundSync failed: $error\n$stackTrace'); - // Return true to avoid aggressive retries if the environment blocks plugins. - return true; - } - } -} - -void _logHttp(String message) { - debugPrint('D/$message'); -} - -Future>> _decodeJsonList(String body) { - return compute(_decodeJsonListSync, body); -} - -List> _decodeJsonListSync(String body) { - final dynamic decoded = jsonDecode(body); - if (decoded is! List) { - throw const FormatException('Expected JSON array'); - } - return decoded.whereType>().toList(); -} - -Future> _decodeJsonMap(String body) { - return compute(_decodeJsonMapSync, body); -} - -Map _decodeJsonMapSync(String body) { - final dynamic decoded = jsonDecode(body); - if (decoded is! Map) { - throw const FormatException('Expected JSON object'); - } - return decoded; -} - -Future main() async { - WidgetsFlutterBinding.ensureInitialized(); - final notificationClient = LocalNotificationClient(); - await notificationClient.initialize(); - if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { - final backgroundManager = BackgroundSyncManager( - workmanager: FlutterWorkmanagerAdapter(), - dependencies: BackgroundDependencies( - repositoryBuilder: () async => MeshRepository(), - notificationBuilder: () async => LocalNotificationClient(), - ), - ); - await backgroundManager.initialize(); - await backgroundManager.ensurePeriodicTask(); - } - runApp(PotatoMeshReaderApp( - notificationClient: notificationClient, - )); -} - -/// Persistent storage for the theme choice so the UI can honor user intent. -class ThemePreferenceStore { - const ThemePreferenceStore(); - - Future load() async { - final prefs = await SharedPreferences.getInstance(); - final stored = prefs.getString(_themePreferenceKey); - switch (stored) { - case 'dark': - return ThemeMode.dark; - case 'light': - return ThemeMode.light; - case 'system': - return ThemeMode.system; - default: - return ThemeMode.system; - } - } - - Future save(ThemeMode mode) async { - final prefs = await SharedPreferences.getInstance(); - final value = mode == ThemeMode.dark - ? 'dark' - : mode == ThemeMode.light - ? 'light' - : 'system'; - await prefs.setString(_themePreferenceKey, value); - } -} - -/// Function type used to fetch messages from a specific endpoint. -typedef MessageFetcher = Future> Function({ - http.Client? client, - String domain, -}); - -/// PotatoMesh Reader root widget that configures theming and the home screen. -class PotatoMeshReaderApp extends StatefulWidget { - const PotatoMeshReaderApp({ - super.key, - this.fetcher = fetchMessages, - this.instanceFetcher = fetchInstances, - this.initialDomain = 'potatomesh.net', - this.repository, - this.bootstrapper, - this.enableAutoRefresh = true, - this.themeStore = const ThemePreferenceStore(), - this.notificationClient = const NoopNotificationClient(), - }); - - /// Fetch function injected to simplify testing and offline previews. - final MessageFetcher fetcher; - - /// Loader for federation instance metadata, overridable in tests. - final Future> Function({http.Client? client}) - instanceFetcher; - - /// Initial endpoint domain used when the app boots. - final String initialDomain; - - /// Backing repository controlling persistence and caching. - final MeshRepository? repository; - - /// Optional bootstrapper override for testing or previews. - final Future Function({ProgressCallback? onProgress})? - bootstrapper; - - /// Whether the chat view should periodically refresh messages. - final bool enableAutoRefresh; - - /// Storage used to persist the chosen theme. - final ThemePreferenceStore themeStore; - - /// Client responsible for platform notifications. - final NotificationClient notificationClient; - - @override - State createState() => _PotatoMeshReaderAppState(); -} - -class _PotatoMeshReaderAppState extends State { - late String _endpointDomain; - int _endpointVersion = 0; - late final MeshRepository _repository; - late final NotificationClient _notificationClient; - final GlobalKey _messengerKey = - GlobalKey(); - BootstrapProgress _progress = - const BootstrapProgress(stage: 'loading instances'); - Future? _bootstrapFuture; - BootstrapResult? _bootstrapResult; - Object? _lastError; - ThemeMode _themeMode = ThemeMode.system; - - @override - void initState() { - super.initState(); - _endpointDomain = widget.initialDomain; - _repository = widget.repository ?? MeshRepository(); - _notificationClient = widget.notificationClient; - NodeShortNameCache.instance.registerResolver(_repository); - _loadThemeMode(); - _startBootstrap(); - } - - Future _loadThemeMode() async { - final mode = await widget.themeStore.load(); - if (!mounted) return; - setState(() { - _themeMode = mode; - }); - } - - void _startBootstrap() { - final loader = widget.bootstrapper ?? - (({ProgressCallback? onProgress}) => _repository.bootstrap( - initialDomain: widget.initialDomain, - onProgress: onProgress, - )); - - setState(() { - _bootstrapFuture = loader(onProgress: _updateProgress); - }); - - _bootstrapFuture!.then((result) { - if (!mounted) return; - setState(() { - _bootstrapResult = result; - _endpointDomain = result.selectedDomain; - _endpointVersion += 1; - _lastError = null; - }); - }).catchError((error) { - if (!mounted) return; - setState(() { - _lastError = error; - }); - }); - } - - void _updateProgress(BootstrapProgress progress) { - if (!mounted) return; - setState(() { - _progress = progress; - }); - } - - String _normalizeDomain(String domain) { - var cleaned = domain.trim().toLowerCase(); - if (cleaned.startsWith('https://')) cleaned = cleaned.substring(8); - if (cleaned.startsWith('http://')) cleaned = cleaned.substring(7); - if (cleaned.endsWith('/')) { - cleaned = cleaned.substring(0, cleaned.length - 1); - } - return cleaned; - } - - String? _instanceNameFor(String domain) { - final normalized = _normalizeDomain(domain); - final candidates = [ - ..._repository.instances, - if (_bootstrapResult != null) ..._bootstrapResult!.instances, - ]; - for (final instance in candidates) { - if (_normalizeDomain(instance.domain) == normalized) { - return instance.displayName; - } - } - return null; - } - - Future> _loadInstances({bool refresh = false}) async { - if (!refresh && _repository.instances.isNotEmpty) { - return _repository.instances; - } - final instances = await widget.instanceFetcher(); - await _repository.updateInstances(instances); - return instances; - } - - Future _handleThemeChanged(ThemeMode mode) async { - setState(() { - _themeMode = mode; - }); - await widget.themeStore.save(mode); - } - - Future _handleEndpointChanged(String newDomain) async { - if (newDomain.isEmpty || newDomain == _endpointDomain) { - return; - } - - final previousDomain = _endpointDomain; - final previousSelectedDomain = _repository.selectedDomain; - await _repository.rememberSelectedDomain(newDomain); - final future = _repository - .loadDomainData( - domain: newDomain, - forceFull: true, - onProgress: _updateProgress, - ) - .then( - (domainResult) => BootstrapResult( - instances: _repository.instances, - nodes: domainResult.nodes, - messages: domainResult.messages, - selectedDomain: domainResult.domain, - ), - ); - - setState(() { - _bootstrapFuture = future; - _endpointDomain = newDomain; - _endpointVersion += 1; - _lastError = null; - }); - - try { - final result = await future; - if (!mounted) return; - setState(() { - _bootstrapResult = result; - _endpointDomain = result.selectedDomain; - _lastError = null; - }); - } catch (error) { - if (!mounted) return; - setState(() { - _lastError = error; - _endpointDomain = previousDomain; - }); - await _repository.rememberSelectedDomain(previousSelectedDomain); - _messengerKey.currentState?.showSnackBar( - SnackBar(content: Text('Failed to switch instance: $error')), - ); - } - } - - Future> _fetchMessagesForCurrentDomain({ - http.Client? client, - String domain = '', - }) { - final activeDomain = domain.isNotEmpty ? domain : _endpointDomain; - final hasCustomFetcher = !identical(widget.fetcher, fetchMessages); - if (hasCustomFetcher) { - return widget.fetcher(domain: activeDomain, client: client); - } - return _repository.loadMessages(domain: activeDomain); - } - - @override - Widget build(BuildContext context) { - final seed = Colors.teal; - final lightTheme = ThemeData( - brightness: Brightness.light, - colorScheme: ColorScheme.fromSeed( - seedColor: seed, - brightness: Brightness.light, - ), - useMaterial3: true, - textTheme: const TextTheme( - bodyMedium: TextStyle( - fontFamily: 'monospace', - fontSize: 13, - height: 1.15, - ), - ), - ); - final darkTheme = ThemeData( - brightness: Brightness.dark, - colorScheme: ColorScheme.fromSeed( - seedColor: seed, - brightness: Brightness.dark, - ), - useMaterial3: true, - textTheme: const TextTheme( - bodyMedium: TextStyle( - fontFamily: 'monospace', - fontSize: 13, - height: 1.15, - ), - ), - ); - - return MaterialApp( - title: '🥔 PotatoMesh Reader', - debugShowCheckedModeBanner: false, - scaffoldMessengerKey: _messengerKey, - theme: lightTheme, - darkTheme: darkTheme, - themeMode: _themeMode, - home: FutureBuilder( - future: _bootstrapFuture, - builder: (context, snapshot) { - final effectiveResult = snapshot.data ?? _bootstrapResult; - if (effectiveResult == null) { - return LoadingScreen( - progress: _progress, - error: _lastError ?? snapshot.error, - ); - } - - final domain = _repository.selectedDomain.isNotEmpty - ? _repository.selectedDomain - : effectiveResult.selectedDomain; - final instanceName = _instanceNameFor(domain); - final initialMessages = (effectiveResult.selectedDomain == domain) - ? effectiveResult.messages - : const []; - return MessagesScreen( - key: ValueKey(domain), - fetcher: _fetchMessagesForCurrentDomain, - resetToken: _endpointVersion, - domain: domain, - repository: _repository, - notificationClient: _notificationClient, - instanceName: instanceName, - enableAutoRefresh: widget.enableAutoRefresh, - initialMessages: initialMessages, - onOpenSettings: (context) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => SettingsScreen( - currentDomain: _repository.selectedDomain.isNotEmpty - ? _repository.selectedDomain - : domain, - onDomainChanged: _handleEndpointChanged, - loadInstances: ({bool refresh = false}) => - _loadInstances(refresh: refresh), - themeMode: _themeMode, - onThemeChanged: _handleThemeChanged, - ), - ), - ); - }, - ); - }, - ), - ); - } -} - -/// Splash-style loading view shown while federation data is hydrated. -class LoadingScreen extends StatelessWidget { - const LoadingScreen({ - super.key, - required this.progress, - this.error, - }); - - final BootstrapProgress progress; - final Object? error; - - @override - Widget build(BuildContext context) { - final label = error != null - ? 'Failed to load: $error' - : (progress.label.isNotEmpty ? progress.label : 'Loading…'); - return Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 24), - child: Image.asset( - 'assets/icon-splash.png', - height: 120, - semanticLabel: 'PotatoMesh', - ), - ), - const CircularProgressIndicator(), - const SizedBox(height: 28), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Text( - label, - textAlign: TextAlign.center, - ), - ), - ], - ), - ), - ); - } -} - -/// Progress payload used to render bootstrap and domain load status. -class BootstrapProgress { - const BootstrapProgress({ - required this.stage, - this.current, - this.total, - this.detail, - }); - - final String stage; - final int? current; - final int? total; - final String? detail; - - /// Human-friendly label summarising the current progress state. - String get label { - final buffer = StringBuffer(stage); - if (current != null && total != null && total! > 0) { - buffer.write(' ${current!}/${total!}'); - } - if (detail != null && detail!.isNotEmpty) { - buffer.write(' • ${detail!}'); - } - return buffer.toString(); - } -} - -/// Result container returned once federation, nodes, and messages are loaded. -class BootstrapResult { - const BootstrapResult({ - required this.instances, - required this.nodes, - required this.messages, - required this.selectedDomain, - }); - - final List instances; - final List nodes; - final List messages; - final String selectedDomain; -} - -/// Domain-level fetch outcome used when switching between instances. -class DomainLoadResult { - const DomainLoadResult({ - required this.domain, - required this.nodes, - required this.messages, - }); - - final String domain; - final List nodes; - final List messages; -} - -typedef ProgressCallback = void Function(BootstrapProgress progress); - -/// Thin wrapper around [SharedPreferences] used to persist federation data. -class MeshLocalStore { - MeshLocalStore(this._prefs); - - final SharedPreferences _prefs; - - static const String _instancesKey = 'mesh.instances'; - static const String _selectedDomainKey = 'mesh.selectedDomain'; - static const String _lastSeenKey = 'mesh.lastSeen'; - - String _safeKey(String domain) { - final base = domain.trim().isEmpty ? 'potatomesh.net' : domain.trim(); - return base.replaceAll(RegExp(r'[^a-zA-Z0-9._-]'), '_'); - } - - Future saveInstances(List instances) async { - final encoded = jsonEncode(instances.map((i) => i.toJson()).toList()); - await _prefs.setString(_instancesKey, encoded); - } - - List loadInstances() { - final raw = _prefs.getString(_instancesKey); - if (raw == null || raw.isEmpty) return const []; - try { - final dynamic decoded = jsonDecode(raw); - if (decoded is! List) return const []; - return decoded - .whereType>() - .map(MeshInstance.fromJson) - .where((instance) => instance.domain.isNotEmpty) - .toList(); - } catch (_) { - return const []; - } - } - - Future saveSelectedDomain(String domain) async { - await _prefs.setString(_selectedDomainKey, domain.trim()); - } - - String? loadSelectedDomain() { - return _prefs.getString(_selectedDomainKey); - } - - Future saveNodes(String domain, List nodes) async { - final encoded = jsonEncode(nodes.map((n) => n.toJson()).toList()); - await _prefs.setString('mesh.nodes.${_safeKey(domain)}', encoded); - } - - List loadNodes(String domain) { - final raw = _prefs.getString('mesh.nodes.${_safeKey(domain)}'); - if (raw == null || raw.isEmpty) return const []; - try { - final dynamic decoded = jsonDecode(raw); - if (decoded is! List) return const []; - return decoded - .whereType>() - .map(MeshNode.fromJson) - .where((node) => node.nodeId.isNotEmpty) - .toList(); - } catch (_) { - return const []; - } - } - - Future saveMessages(String domain, List messages) async { - final trimmed = messages.length > 1200 - ? messages.sublist(messages.length - 1200) - : messages; - final encoded = jsonEncode(trimmed.map((m) => m.toJson()).toList()); - await _prefs.setString('mesh.messages.${_safeKey(domain)}', encoded); - } - - List loadMessages(String domain) { - final raw = _prefs.getString('mesh.messages.${_safeKey(domain)}'); - if (raw == null || raw.isEmpty) return const []; - try { - final dynamic decoded = jsonDecode(raw); - if (decoded is! List) return const []; - return decoded - .whereType>() - .map(MeshMessage.fromJson) - .toList(); - } catch (_) { - return const []; - } - } - - Future saveLastSeenMessageKey(String domain, String key) async { - await _prefs.setString('$_lastSeenKey.${_safeKey(domain)}', key); - } - - String? loadLastSeenMessageKey(String domain) { - return _prefs.getString('$_lastSeenKey.${_safeKey(domain)}'); - } -} - -/// Provider used by [NodeShortNameCache] to resolve cached node metadata. -abstract class MeshNodeResolver { - MeshNode? findNode(String domain, String nodeId); -} - -/// Repository responsible for federation discovery, caching, and persistence. -class MeshRepository implements MeshNodeResolver { - MeshRepository({ - SharedPreferences? prefs, - http.Client? client, - Random? random, - }) : _prefs = prefs, - _client = client, - _random = random ?? Random(); - - SharedPreferences? _prefs; - MeshLocalStore? _store; - MessageSeenTracker? _tracker; - final http.Client? _client; - final Random _random; - - final Map> _nodesByDomain = {}; - final Map> _messagesByDomain = {}; - final Map _messagesLoaded = {}; - final Map> _nodeFetchInFlight = {}; - List _instances = const []; - String _selectedDomain = 'potatomesh.net'; - - List get instances => _instances; - String get selectedDomain => _selectedDomain; - - Future _ensureStore() async { - if (_store != null) return _store!; - _prefs ??= await SharedPreferences.getInstance(); - _store = MeshLocalStore(_prefs!); - return _store!; - } - - /// Returns the last selected domain, defaulting to the provided fallback. - Future loadSelectedDomainOrDefault( - {String fallback = 'potatomesh.net'}) async { - final store = await _ensureStore(); - final cached = store.loadSelectedDomain(); - _selectedDomain = (cached != null && cached.isNotEmpty) ? cached : fallback; - return _selectedDomain; - } - - Future _ensureTracker() async { - if (_tracker != null) return _tracker!; - final store = await _ensureStore(); - _tracker = MessageSeenTracker(store); - return _tracker!; - } - - /// Persist the selected domain choice without performing network calls. - Future rememberSelectedDomain(String domain) async { - _selectedDomain = _domainKey(domain); - final store = await _ensureStore(); - await store.saveSelectedDomain(_selectedDomain); - } - - String _domainKey(String domain) { - var cleaned = domain.trim(); - if (cleaned.isEmpty) return 'potatomesh.net'; - cleaned = cleaned.toLowerCase(); - if (cleaned.startsWith('https://')) cleaned = cleaned.substring(8); - if (cleaned.startsWith('http://')) cleaned = cleaned.substring(7); - if (cleaned.endsWith('/')) { - cleaned = cleaned.substring(0, cleaned.length - 1); - } - if (cleaned.isEmpty) return 'potatomesh.net'; - return cleaned; - } - - /// Kicks off the full bootstrap flow including federation discovery, node - /// validation, and initial message downloads. - Future bootstrap({ - String initialDomain = 'potatomesh.net', - ProgressCallback? onProgress, - }) async { - final store = await _ensureStore(); - final cachedInstances = store.loadInstances(); - final hasCachedInstances = cachedInstances.isNotEmpty; - if (hasCachedInstances) { - _instances = cachedInstances; - } - - final cachedDomain = store.loadSelectedDomain(); - _selectedDomain = (cachedDomain != null && cachedDomain.isNotEmpty) - ? cachedDomain - : initialDomain; - - final httpClient = _client ?? http.Client(); - final shouldCloseClient = _client == null; - - if (!hasCachedInstances) { - final discovered = await _discoverInstances( - client: httpClient, - onProgress: onProgress, - ); - final validated = await _validateInstances( - discovered, - httpClient, - onProgress, - ); - if (validated.isNotEmpty) { - _instances = validated; - await store.saveInstances(validated); - } - } - - _selectedDomain = _resolveSelectedDomain(_instances, _selectedDomain); - await store.saveSelectedDomain(_selectedDomain); - - // Hydrate caches from storage before hitting the network so the UI has - // something to render if connectivity is constrained. - final cachedNodes = store.loadNodes(_selectedDomain); - if (cachedNodes.isNotEmpty) { - final key = _domainKey(_selectedDomain); - _nodesByDomain[key] = cachedNodes; - NodeShortNameCache.instance - .prime(domain: _selectedDomain, nodes: cachedNodes); - } - final cachedMessages = store.loadMessages(_selectedDomain); - if (cachedMessages.isNotEmpty) { - _messagesByDomain[_domainKey(_selectedDomain)] = cachedMessages; - _messagesLoaded[_domainKey(_selectedDomain)] = true; - } - - final domainResult = await _loadFirstResponsiveInstance( - preferredDomain: _selectedDomain, - candidates: _instances, - httpClient: httpClient, - onProgress: onProgress, - ); - - if (shouldCloseClient) { - httpClient.close(); - } - - return BootstrapResult( - instances: _instances, - nodes: domainResult.nodes, - messages: domainResult.messages, - selectedDomain: _selectedDomain, - ); - } - - /// Loads nodes and messages for a domain, persisting the selection. - Future loadDomainData({ - required String domain, - ProgressCallback? onProgress, - http.Client? httpClient, - bool forceFull = false, - }) async { - final store = await _ensureStore(); - final targetDomain = - domain.trim().isEmpty ? 'potatomesh.net' : domain.trim(); - - final client = httpClient ?? _client ?? http.Client(); - final shouldClose = httpClient == null && _client == null; - - try { - final nodes = await _fetchNodesList( - domain: targetDomain, - client: client, - persist: true, - useCacheWhenAvailable: !forceFull, - onProgress: onProgress, - ); - - final messages = await _loadMessagesInternal( - domain: targetDomain, - client: client, - forceFull: forceFull, - onProgress: onProgress, - ); - - _selectedDomain = targetDomain; - await store.saveSelectedDomain(_selectedDomain); - - return DomainLoadResult( - domain: targetDomain, - nodes: nodes, - messages: messages, - ); - } finally { - if (shouldClose) { - client.close(); - } - } - } - - /// Fetches messages, using a full sync initially and incremental loads later. - Future> loadMessages({required String domain}) async { - await _ensureStore(); - final key = _domainKey(domain); - final loaded = _messagesLoaded[key] ?? false; - final client = _client ?? http.Client(); - final shouldClose = _client == null; - try { - // Ensure cached data is available for immediate rendering. - if (!loaded && !_messagesByDomain.containsKey(key)) { - final cached = _store?.loadMessages(domain) ?? const []; - if (cached.isNotEmpty) { - _messagesByDomain[key] = cached; - } - } - return await _loadMessagesInternal( - domain: domain, - client: client, - forceFull: !loaded, - ); - } finally { - if (shouldClose) { - client.close(); - } - } - } - - /// Returns any messages that arrived after the last recorded entry for a domain. - Future> detectUnseenMessages({ - required String domain, - required List messages, - }) async { - final tracker = await _ensureTracker(); - return tracker.unseenSince(domain: domain, messages: messages); - } - - /// Stores a nodes snapshot for quick lookup without refetching mid-session. - Future> loadNodes({required String domain}) async { - await _ensureStore(); - final key = _domainKey(domain); - if (_nodesByDomain.containsKey(key)) { - return _nodesByDomain[key]!; - } - - final cached = _store?.loadNodes(domain) ?? const []; - if (cached.isNotEmpty) { - _nodesByDomain[key] = cached; - NodeShortNameCache.instance.prime(domain: domain, nodes: cached); - return cached; - } - - final client = _client ?? http.Client(); - final shouldClose = _client == null; - try { - return await _fetchNodesList( - domain: domain, - client: client, - persist: true, - useCacheWhenAvailable: true, - ); - } finally { - if (shouldClose) { - client.close(); - } - } - } - - /// Public entry point for fetching and caching federation instances. - Future> discoverInstances({ - http.Client? client, - ProgressCallback? onProgress, - }) async { - final store = await _ensureStore(); - final cached = store.loadInstances(); - final httpClient = client ?? _client ?? http.Client(); - final shouldClose = client == null && _client == null; - try { - final discovered = await _discoverInstances( - client: httpClient, - onProgress: onProgress, - ); - final validated = await _validateInstances( - discovered, - httpClient, - onProgress, - ); - if (validated.isNotEmpty) { - _instances = validated; - await store.saveInstances(validated); - return validated; - } - if (cached.isNotEmpty) return cached; - return discovered; - } finally { - if (shouldClose) { - httpClient.close(); - } - } - } - - /// Overwrites the cached instances and persists them to local storage. - Future updateInstances(List instances) async { - _instances = instances; - final store = await _ensureStore(); - await store.saveInstances(instances); - } - - @override - MeshNode? findNode(String domain, String nodeId) { - final key = _domainKey(domain); - final nodes = _nodesByDomain[key]; - if (nodes == null) return null; - final trimmed = nodeId.trim(); - for (final node in nodes) { - if (_matchesNodeId(node.nodeId, trimmed)) { - return node; - } - } - return null; - } - - Future> _discoverInstances({ - required http.Client client, - ProgressCallback? onProgress, - }) async { - final seen = {}; - final queue = Queue(); - final results = []; - - Future enqueueFromDomain(String domain) async { - try { - final uri = _buildInstancesUri(domain); - _logHttp('GET $uri'); - final resp = await client.get(uri).timeout(_requestTimeout); - _logHttp('HTTP ${resp.statusCode} $uri'); - if (resp.statusCode != 200) return; - final decoded = await _decodeJsonList(resp.body); - final parsed = decoded - .map(MeshInstance.fromJson) - .where((instance) => instance.domain.isNotEmpty) - .toList(); - for (final instance in parsed) { - final key = _domainKey(instance.domain); - if (seen.contains(key)) continue; - seen.add(key); - results.add(instance); - queue.add(instance.domain); - } - } catch (_) { - // Skip unreachable domains during discovery. - } - } - - onProgress?.call(const BootstrapProgress(stage: 'loading instances')); - await enqueueFromDomain('potatomesh.net'); - - while (queue.isNotEmpty) { - final domain = queue.removeFirst(); - onProgress?.call( - BootstrapProgress( - stage: 'discovering instances', - current: results.length, - total: null, - detail: domain, - ), - ); - await enqueueFromDomain(domain); - } - - final deduped = {}; - for (final instance in results) { - final key = _domainKey(instance.domain); - if (instance.isPrivate) continue; - deduped[key] = instance; - } - - final list = deduped.values.toList() - ..sort((a, b) => - a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase())); - return list; - } - - Future> _validateInstances( - List candidates, - http.Client client, - ProgressCallback? onProgress, - ) async { - if (candidates.isEmpty) return const []; - final now = DateTime.now().toUtc(); - final valid = []; - final total = candidates.length; - for (var i = 0; i < candidates.length; i++) { - final candidate = candidates[i]; - onProgress?.call( - BootstrapProgress( - stage: 'verifying instances', - current: i + 1, - total: total, - detail: candidate.domain, - ), - ); - try { - final nodes = await _fetchNodesList( - domain: candidate.domain, - client: client, - limit: 200, - persist: false, - useCacheWhenAvailable: false, - ); - final active = nodes - .where((node) => node.isActive(const Duration(hours: 24), now)) - .toList(); - if (active.length >= 10) { - valid.add(candidate); - } - } catch (_) { - // Invalid instance; skip. - } - } - return valid.isNotEmpty ? valid : candidates; - } - - Future _loadFirstResponsiveInstance({ - required String preferredDomain, - required List candidates, - required http.Client httpClient, - ProgressCallback? onProgress, - }) async { - final store = await _ensureStore(); - final ordered = { - preferredDomain, - ...candidates.map((c) => c.domain) - }; - DomainLoadResult? result; - Object? lastError; - for (final domain in ordered) { - try { - result = await loadDomainData( - domain: domain, - onProgress: onProgress, - httpClient: httpClient, - forceFull: true, - ); - break; - } catch (error) { - lastError = error; - continue; - } - } - - if (result != null) { - return result; - } - - final cachedNodes = store.loadNodes(preferredDomain); - final cachedMessages = store.loadMessages(preferredDomain); - if (cachedNodes.isNotEmpty || cachedMessages.isNotEmpty) { - _selectedDomain = preferredDomain; - await store.saveSelectedDomain(_selectedDomain); - return DomainLoadResult( - domain: preferredDomain, - nodes: cachedNodes, - messages: cachedMessages, - ); - } - - throw lastError ?? Exception('No responsive instances'); - } - - String _resolveSelectedDomain( - List available, - String desired, - ) { - if (available.isEmpty) { - return desired.trim().isNotEmpty ? desired.trim() : 'potatomesh.net'; - } - final desiredKey = _domainKey(desired); - for (final instance in available) { - if (_domainKey(instance.domain) == desiredKey) { - return instance.domain; - } - } - return available[_random.nextInt(available.length)].domain; - } - - Future> _fetchNodesList({ - required String domain, - required http.Client client, - int limit = 1000, - bool persist = true, - bool useCacheWhenAvailable = true, - ProgressCallback? onProgress, - }) async { - final key = _domainKey(domain); - if (useCacheWhenAvailable && _nodesByDomain.containsKey(key)) { - return _nodesByDomain[key]!; - } - final uri = _buildNodesUri(domain, limit: limit); - _logHttp('GET $uri'); - final resp = await client.get(uri).timeout(_requestTimeout); - _logHttp('HTTP ${resp.statusCode} $uri'); - if (resp.statusCode != 200) { - throw Exception('HTTP ${resp.statusCode}: ${resp.body}'); - } - final decoded = await _decodeJsonList(resp.body); - final nodes = []; - var index = 0; - for (final entry in decoded) { - index += 1; - final node = MeshNode.fromJson(entry); - if (node.nodeId.isEmpty) continue; - nodes.add(node); - onProgress?.call( - BootstrapProgress( - stage: 'loading nodes', - current: index, - total: decoded.length, - detail: domain, - ), - ); - } - - if (persist) { - _nodesByDomain[key] = nodes; - NodeShortNameCache.instance.prime(domain: domain, nodes: nodes); - await _store?.saveNodes(domain, nodes); - } - - return nodes; - } - - Future> _loadMessagesInternal({ - required String domain, - required http.Client client, - bool forceFull = false, - ProgressCallback? onProgress, - }) async { - final key = _domainKey(domain); - final alreadyLoaded = _messagesLoaded[key] ?? false; - final initialFetch = forceFull || !alreadyLoaded; - final since = initialFetch ? 0 : _latestRxTimeSeconds(domain); - const limit = 1000; - - final uri = _buildMessagesUri(domain, since: since, limit: limit); - _logHttp('GET $uri'); - final resp = await client.get(uri).timeout(_requestTimeout); - _logHttp('HTTP ${resp.statusCode} $uri'); - if (resp.statusCode != 200) { - throw Exception('HTTP ${resp.statusCode}: ${resp.body}'); - } - final decoded = await _decodeJsonList(resp.body); - - final messages = []; - var index = 0; - for (final entry in decoded) { - index += 1; - final message = MeshMessage.fromJson(entry); - messages.add(message); - onProgress?.call( - BootstrapProgress( - stage: 'loading messages', - current: index, - total: decoded.length, - detail: domain, - ), - ); - } - - final merged = _mergeMessages(domain, messages); - _messagesLoaded[key] = true; - await _store?.saveMessages(domain, merged); - - // Ensure new senders are cached locally for name lookups. - await _hydrateMissingNodes( - domain: domain, messages: messages, client: client); - - return merged; - } - - List _mergeMessages(String domain, List incoming) { - final key = _domainKey(domain); - final existing = List.from(_messagesByDomain[key] ?? const []); - final seen = existing.map(MessageSeenTracker.messageKey).toSet(); - for (final msg in incoming) { - final key = MessageSeenTracker.messageKey(msg); - if (seen.contains(key)) { - continue; - } - existing.add(msg); - seen.add(key); - } - final sorted = sortMessagesByRxTime(existing); - if (sorted.length > 1200) { - sorted.removeRange(0, sorted.length - 1200); - } - _messagesByDomain[key] = sorted; - return sorted; - } - - int _latestRxTimeSeconds(String domain) { - final key = _domainKey(domain); - final messages = _messagesByDomain[key]; - if (messages == null || messages.isEmpty) return 0; - - var maxSeconds = 0; - for (final message in messages) { - final rxMillis = message.rxTime?.toUtc().millisecondsSinceEpoch; - final candidate = message.rxTimeSeconds ?? - (rxMillis != null ? rxMillis ~/ 1000 : null) ?? - _coerceIsoSeconds(message.rxIso); - if (candidate != null && candidate > maxSeconds) { - maxSeconds = candidate; - } - } - return maxSeconds; - } - - int? _coerceIsoSeconds(String rxIso) { - if (rxIso.isEmpty) return null; - try { - return DateTime.parse(rxIso).toUtc().millisecondsSinceEpoch ~/ 1000; - } catch (_) { - return null; - } - } - - Future _hydrateMissingNodes({ - required String domain, - required List messages, - required http.Client client, - }) async { - final store = await _ensureStore(); - final key = _domainKey(domain); - var nodes = List.from(_nodesByDomain[key] ?? const []); - if (nodes.isEmpty) { - final cached = store.loadNodes(domain); - if (cached.isNotEmpty) { - nodes = List.from(cached); - _nodesByDomain[key] = nodes; - NodeShortNameCache.instance.prime(domain: domain, nodes: cached); - } - } - final knownIds = nodes.map((n) => _normalizeNodeId(n.nodeId)).toSet(); - final inFlight = _nodeFetchInFlight.putIfAbsent(key, () => {}); - for (final message in messages) { - final rawNodeId = message.lookupNodeId.trim(); - final nodeId = _normalizeNodeId(rawNodeId); - if (nodeId.isEmpty || knownIds.contains(nodeId)) continue; - if (inFlight.contains(nodeId)) continue; - inFlight.add(nodeId); - try { - final uri = _buildNodeUri(domain, rawNodeId); - _logHttp('GET $uri'); - final resp = await client.get(uri).timeout(_requestTimeout); - _logHttp('HTTP ${resp.statusCode} $uri'); - if (resp.statusCode != 200) { - knownIds.add(nodeId); - continue; - } - final decoded = await _decodeJsonMap(resp.body); - final node = MeshNode.fromJson(decoded); - if (node.nodeId.isEmpty) continue; - nodes = List.from(nodes)..add(node); - _nodesByDomain[key] = nodes; - NodeShortNameCache.instance.prime(domain: domain, nodes: [node]); - await _store?.saveNodes(domain, nodes); - knownIds.add(_normalizeNodeId(node.nodeId)); - } catch (_) { - // Swallow node lookup errors during refresh. - } finally { - inFlight.remove(nodeId); - } - } - } - - /// Resolves the best-effort sender names for notification payloads. - NotificationSender resolveNotificationSender({ - required String domain, - required MeshMessage message, - }) { - final node = findNode(domain, message.lookupNodeId); - final trimmedLong = node?.longName.trim(); - final fallbackId = - message.lookupNodeId.isNotEmpty ? message.lookupNodeId : message.fromId; - final shortFromNode = node?.displayShortName.trim(); - final shortName = (shortFromNode != null && shortFromNode.isNotEmpty) - ? shortFromNode - : NodeShortNameCache.fallbackShortName(fallbackId); - - return NotificationSender( - shortName: shortName, - longName: - trimmedLong != null && trimmedLong.isNotEmpty ? trimmedLong : null, - ); - } - - bool _matchesNodeId(String existing, String candidate) { - final cleanExisting = _normalizeNodeId(existing); - final cleanCandidate = _normalizeNodeId(candidate); - return cleanExisting.trim() == cleanCandidate.trim(); - } - - String _normalizeNodeId(String id) { - return id.startsWith('!') ? id.substring(1) : id; - } -} - -/// Tracks and persists the most recently seen message per domain. -class MessageSeenTracker { - MessageSeenTracker(this._store); - - final MeshLocalStore _store; - - /// Returns unseen messages that arrived after the last recorded message, - /// while updating the persisted marker to the newest entry. - Future> unseenSince({ - required String domain, - required List messages, - }) async { - if (messages.isEmpty) return const []; - - final lastSeen = _store.loadLastSeenMessageKey(domain); - final ordered = sortMessagesByRxTime(List.from(messages)); - final latestKey = messageKey(ordered.last); - await _store.saveLastSeenMessageKey(domain, latestKey); - - if (lastSeen == null || lastSeen.isEmpty) { - return const []; - } - - var markerFound = false; - final unseen = []; - for (final message in ordered) { - if (markerFound) { - unseen.add(message); - continue; - } - if (messageKey(message) == lastSeen) { - markerFound = true; - } - } - - return markerFound ? unseen : const []; - } - - /// Returns the stable key used to detect repeated messages. - static String messageKey(MeshMessage msg) { - return '${msg.id}-${msg.rxIso}-${msg.fromId}-${msg.text}'; - } -} - -/// Displays the fetched mesh messages and supports pull-to-refresh. -class MessagesScreen extends StatefulWidget { - const MessagesScreen({ - super.key, - this.fetcher = fetchMessages, - this.onOpenSettings, - this.resetToken = 0, - required this.domain, - this.repository, - this.initialMessages = const [], - this.instanceName, - this.enableAutoRefresh = true, - this.notificationClient = const NoopNotificationClient(), - }); - - /// Fetch function used to load messages from the PotatoMesh API. - final Future> Function() fetcher; - - /// Handler invoked when the settings icon is tapped. - final void Function(BuildContext context)? onOpenSettings; - - /// Bumps when the endpoint changes to force a refresh of cached data. - final int resetToken; - - /// Active endpoint domain used for auxiliary lookups like node metadata. - final String domain; - - /// Optional repository backing persistence for this screen. - final MeshRepository? repository; - - /// Messages obtained during the bootstrap phase to avoid re-fetching. - final List initialMessages; - - /// Human-friendly name of the selected instance if the user picked one. - final String? instanceName; - - /// Whether periodic background refresh is enabled. - final bool enableAutoRefresh; - - /// Client used to deliver local notifications for unseen messages. - final NotificationClient notificationClient; - - @override - State createState() => _MessagesScreenState(); -} - -class _MessagesScreenState extends State - with WidgetsBindingObserver { - late Future> _future; - List _messages = const []; - final ScrollController _scrollController = ScrollController(); - Timer? _refreshTimer; - bool _isForeground = true; - int _fetchVersion = 0; - late final NotificationClient _notificationClient; - late final Future _notificationReady; - - @override - void initState() { - super.initState(); - _notificationClient = widget.notificationClient; - _notificationReady = _notificationClient.initialize(); - _messages = List.from(widget.initialMessages); - _future = Future.value(_messages); - _startFetch(clear: _messages.isEmpty); - WidgetsBinding.instance.addObserver(this); - WidgetsBinding.instance.addPostFrameCallback((_) { - _refresh(); - _startAutoRefresh(); - }); - } - - /// When the fetcher changes, reload the future so the widget reflects the - /// new data source on rebuilds. - @override - void didUpdateWidget(covariant MessagesScreen oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.notificationClient != widget.notificationClient) { - _notificationClient = widget.notificationClient; - _notificationReady = _notificationClient.initialize(); - } - if (oldWidget.fetcher != widget.fetcher || - oldWidget.resetToken != widget.resetToken || - oldWidget.enableAutoRefresh != widget.enableAutoRefresh) { - _restartAutoRefresh(); - setState(() { - _messages = List.from(widget.initialMessages); - _future = Future.value(_messages); - }); - _startFetch(clear: _messages.isEmpty); - } - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _refreshTimer?.cancel(); - _scrollController.dispose(); - super.dispose(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - final nowForeground = state == AppLifecycleState.resumed || - state == AppLifecycleState.inactive; - if (nowForeground != _isForeground) { - _isForeground = nowForeground; - if (_isForeground) { - _refresh(); - _startAutoRefresh(); - } else { - _refreshTimer?.cancel(); - } - } - } - - /// Reloads the message feed and waits for completion for pull-to-refresh. - /// - /// Errors are intentionally swallowed so the [FutureBuilder] can surface them - /// via its `snapshot.error` state without bubbling an exception to the - /// gesture handler. - Future _refresh({bool appendOnly = false}) async { - await _startFetch(appendOnly: appendOnly); - } - - void _appendMessages(List newMessages) { - if (newMessages.isEmpty) return; - final existingKeys = _messages.map(_messageKey).toSet(); - var added = 0; - final combined = List.from(_messages); - for (final msg in newMessages) { - final key = _messageKey(msg); - if (existingKeys.contains(key)) continue; - combined.add(msg); - existingKeys.add(key); - added += 1; - } - if (added == 0 && _messages.isNotEmpty) { - _scheduleScrollToBottom(); - return; - } - setState(() { - _messages = combined; - }); - _scheduleScrollToBottom(); - } - - String _messageKey(MeshMessage msg) { - return MessageSeenTracker.messageKey(msg); - } - - void _scheduleScrollToBottom({int retries = 5}) { - if (retries <= 0) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!_scrollController.hasClients) { - _scheduleScrollToBottom(retries: retries - 1); - return; - } - _scrollController.jumpTo(_scrollController.position.maxScrollExtent); - }); - } - - void _startAutoRefresh() { - if (!widget.enableAutoRefresh) return; - _refreshTimer?.cancel(); - if (!_isForeground) return; - _refreshTimer = Timer.periodic( - const Duration(seconds: 60), - (_) => _refresh(appendOnly: true), - ); - } - - void _restartAutoRefresh() { - if (_isForeground) { - _startAutoRefresh(); - } - } - - Future _notifyUnseenMessages(List fetched) async { - final repo = widget.repository; - if (repo == null || fetched.isEmpty) return; - try { - final unseen = await repo.detectUnseenMessages( - domain: widget.domain, - messages: fetched, - ); - if (_isForeground || unseen.isEmpty) { - return; - } - await _notificationReady; - for (final message in unseen) { - final sender = repo.resolveNotificationSender( - domain: widget.domain, - message: message, - ); - await _notificationClient.showNewMessage( - message: message, - domain: widget.domain, - senderShortName: sender.shortName, - senderLongName: sender.longName, - ); - } - } catch (error) { - debugPrint('D/Notification error: $error'); - } - } - - Future _startFetch( - {bool clear = false, bool appendOnly = false}) async { - final version = ++_fetchVersion; - final future = widget.fetcher(); - if (!appendOnly) { - setState(() { - if (clear) { - _messages = const []; - } - _future = future; - }); - } - try { - final msgs = await future; - if (version != _fetchVersion) return; - _appendMessages(msgs); - await _notifyUnseenMessages(msgs); - } catch (error) { - if (appendOnly) { - debugPrint('D/Failed to append messages: $error'); - return; - } - rethrow; - } - } - - String _dateLabelFor(MeshMessage message) { - if (message.rxTime != null) { - final local = message.rxTime!.toLocal(); - final y = local.year.toString().padLeft(4, '0'); - final m = local.month.toString().padLeft(2, '0'); - final d = local.day.toString().padLeft(2, '0'); - return '$y-$m-$d'; - } - if (message.rxIso.isNotEmpty && message.rxIso.length >= 10) { - return message.rxIso.substring(0, 10); - } - return 'Unknown'; - } - - Color _zebraColor(BuildContext context, int index) { - final isEven = index.isEven; - final brightness = Theme.of(context).brightness; - if (brightness == Brightness.dark) { - return isEven - ? Colors.black - : Color.lerp(Colors.black, Colors.white, 0.05)!; - } - return isEven - ? Colors.white - : Color.lerp(Colors.white, Colors.black, 0.05)!; - } - - @override - Widget build(BuildContext context) { - final titleText = - (widget.instanceName != null && widget.instanceName!.trim().isNotEmpty) - ? '🥔 ${widget.instanceName!.trim()}' - : '🥔 PotatoMesh Reader'; - return Scaffold( - appBar: AppBar( - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: SvgPicture.asset( - 'assets/potatomesh-logo.svg', - height: 28, - semanticsLabel: 'PotatoMesh logo', - ), - ), - title: Text(titleText), - actions: [ - IconButton( - tooltip: 'Refresh', - icon: const Icon(Icons.refresh), - onPressed: _refresh, - ), - IconButton( - tooltip: 'Settings', - icon: const Icon(Icons.settings), - onPressed: () { - if (widget.onOpenSettings != null) { - widget.onOpenSettings!(context); - } - }, - ), - ], - ), - body: FutureBuilder>( - future: _future, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting && - _messages.isEmpty) { - return const Center(child: CircularProgressIndicator()); - } - if (snapshot.hasError && _messages.isEmpty) { - return Center( - child: Padding( - padding: const EdgeInsets.all(16), - child: Text( - 'Failed to load messages:\n${snapshot.error}', - textAlign: TextAlign.center, - ), - ), - ); - } - final messages = _messages; - if (messages.isEmpty) { - return const Center(child: Text('No messages yet.')); - } - - return RefreshIndicator( - onRefresh: _refresh, - child: ScrollConfiguration( - behavior: const ScrollBehavior().copyWith(scrollbars: false), - child: ListView.builder( - controller: _scrollController, - padding: EdgeInsets.zero, - itemCount: messages.length, - itemBuilder: (context, index) { - final msg = messages[index]; - final currentLabel = _dateLabelFor(msg); - final prevLabel = - index > 0 ? _dateLabelFor(messages[index - 1]) : null; - final needsDivider = - prevLabel == null || currentLabel != prevLabel; - final zebraColor = _zebraColor(context, index); - final content = needsDivider - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DateDivider(label: currentLabel), - ChatLine( - message: msg, - domain: widget.domain, - ), - ], - ) - : ChatLine( - message: msg, - domain: widget.domain, - ); - return Container(color: zebraColor, child: content); - }, - ), - ), - ); - }, - ), - ); - } -} - -/// Individual chat line styled in IRC-inspired format. -class ChatLine extends StatelessWidget { - const ChatLine({ - super.key, - required this.message, - required this.domain, - }); - - static final Map _indentCache = {}; - - /// Message data to render. - final MeshMessage message; - final String domain; - - /// Generates a stable color from the nickname characters by hashing to a hue. - Color _nickColor(String nick) { - final h = nick.codeUnits.fold(0, (a, b) => (a + b) % 360); - return HSLColor.fromAHSL(1, h.toDouble(), 0.5, 0.6).toColor(); - } - - List _buildLinkedSpans( - String text, - TextStyle baseStyle, - TextStyle linkStyle, - ) { - final spans = []; - final urlPattern = RegExp(r'(https?:\/\/[^\s]+)'); - int start = 0; - - for (final match in urlPattern.allMatches(text)) { - if (match.start > start) { - spans.add(TextSpan( - text: text.substring(start, match.start), - style: baseStyle, - )); - } - - final urlText = match.group(0) ?? ''; - final uri = Uri.tryParse(urlText); - spans.add(TextSpan( - text: urlText, - style: linkStyle, - recognizer: TapGestureRecognizer() - ..onTap = () async { - if (uri != null) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - }, - )); - start = match.end; - } - - if (start < text.length) { - spans.add(TextSpan( - text: text.substring(start), - style: baseStyle, - )); - } - - if (spans.isEmpty) { - spans.add(TextSpan(text: text, style: baseStyle)); - } - - return spans; - } - - String _fallbackShortName(String fromId) { - return NodeShortNameCache.fallbackShortName(fromId); - } - - double _computeIndentPixels(TextStyle baseStyle, BuildContext context) { - final key = - '${baseStyle.fontFamily}-${baseStyle.fontSize}-${baseStyle.fontWeight}-${baseStyle.fontStyle}'; - final cached = _indentCache[key]; - if (cached != null) return cached; - final painter = TextPainter( - text: TextSpan(text: ' ', style: baseStyle), - textDirection: Directionality.of(context), - )..layout(); - final width = painter.size.width * 8; - _indentCache[key] = width; - return width; - } - - @override - Widget build(BuildContext context) { - final timeStr = '[${message.timeFormatted}]'; - final rawId = message.fromId.isNotEmpty ? message.fromId : '?'; - final lookupId = - message.lookupNodeId.isNotEmpty ? message.lookupNodeId : rawId; - final nick = rawId.startsWith('!') ? rawId : '!$rawId'; - final channel = '#${message.channelName ?? ''}'.trim(); - final bodyText = message.text.isEmpty ? '⟂ (no text)' : message.text; - final colorScheme = Theme.of(context).colorScheme; - final baseStyle = DefaultTextStyle.of(context) - .style - .copyWith(color: colorScheme.onSurface); - final linkStyle = baseStyle.copyWith( - color: colorScheme.tertiary, - decoration: TextDecoration.underline, - ); - final indentPx = _computeIndentPixels(baseStyle, context); - - return FutureBuilder( - future: NodeShortNameCache.instance.shortNameFor( - domain: domain, - nodeId: lookupId, - ), - builder: (context, snapshot) { - final shortName = snapshot.data?.isNotEmpty == true - ? snapshot.data! - : _fallbackShortName(lookupId); - final paddedShortName = NodeShortNameCache.padToWidth(shortName); - return SelectionArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text.rich( - TextSpan( - children: [ - TextSpan( - text: timeStr, - style: const TextStyle( - color: Colors.grey, - fontWeight: FontWeight.w500, - ), - ), - const TextSpan(text: ' '), - TextSpan( - text: '<$nick>', - style: TextStyle( - color: _nickColor(message.fromShort), - fontWeight: FontWeight.w600, - ), - ), - const TextSpan(text: ' '), - TextSpan( - text: '($paddedShortName)', - style: baseStyle.copyWith( - color: _nickColor(message.fromShort), - fontWeight: FontWeight.w600, - ), - ), - const TextSpan(text: ' '), - TextSpan( - text: channel, - style: TextStyle( - color: Theme.of(context).colorScheme.tertiary), - ), - ], - style: baseStyle, - ), - ), - Padding( - padding: EdgeInsets.only(left: indentPx), - child: SelectableText.rich( - TextSpan( - children: _buildLinkedSpans( - bodyText, - baseStyle, - linkStyle, - ), - ), - ), - ), - ], - ), - ); - }); - } -} - -/// Bold, grey date divider between chat messages. -class DateDivider extends StatelessWidget { - const DateDivider({super.key, required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - final color = Theme.of(context).colorScheme.onSurfaceVariant; - return Padding( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), - child: Text( - '-- $label --', - style: TextStyle( - fontWeight: FontWeight.w700, - color: color, - ), - ), - ); - } -} - -/// MVP settings view offering endpoint selection and about info. -class SettingsScreen extends StatefulWidget { - const SettingsScreen({ - super.key, - required this.currentDomain, - required this.onDomainChanged, - this.loadInstances = _defaultInstanceLoader, - this.themeMode = ThemeMode.system, - this.onThemeChanged, - }); - - /// Currently selected endpoint domain. - final String currentDomain; - - /// Callback fired when the user changes the endpoint. - final ValueChanged onDomainChanged; - - /// Loader used to fetch federation instance metadata. - final Future> Function({bool refresh}) loadInstances; - - /// Current theme mode selection. - final ThemeMode themeMode; - - /// Callback when the theme selection changes. - final ValueChanged? onThemeChanged; - - static Future> _defaultInstanceLoader( - {bool refresh = false}) { - return fetchInstances(); - } - - @override - State createState() => _SettingsScreenState(); -} - -class _SettingsScreenState extends State { - static const String _defaultDomain = 'potatomesh.net'; - static const String _defaultName = 'BerlinMesh'; - List _instances = const []; - bool _loading = false; - String _selectedDomain = ''; - String? _error; - String _versionLabel = ''; - Future? _instanceVersionFuture; - late ThemeMode _selectedThemeMode; - - @override - void initState() { - super.initState(); - _selectedDomain = widget.currentDomain; - _selectedThemeMode = widget.themeMode; - _fetchInstances(); - _loadVersion(); - _instanceVersionFuture = - InstanceVersionCache.instance.fetch(domain: _selectedDomain); - } - - @override - void didUpdateWidget(covariant SettingsScreen oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.currentDomain != widget.currentDomain) { - _selectedDomain = widget.currentDomain; - _instanceVersionFuture = - InstanceVersionCache.instance.fetch(domain: _selectedDomain); - } - if (oldWidget.themeMode != widget.themeMode) { - _selectedThemeMode = widget.themeMode; - } - } - - Future _fetchInstances({bool refresh = false}) async { - setState(() { - _loading = true; - _error = null; - }); - - try { - final fetched = await widget.loadInstances(refresh: refresh); - if (!mounted) return; - setState(() { - _instances = fetched; - }); - } catch (error) { - if (!mounted) return; - setState(() { - _instances = const []; - _error = error.toString(); - }); - } finally { - if (mounted) { - setState(() { - _loading = false; - }); - } - } - } - - Future _loadVersion() async { - try { - final info = await PackageInfo.fromPlatform(); - final label = _composeGitAwareVersion(info); - if (!mounted) return; - setState(() { - _versionLabel = label; - }); - } catch (_) { - if (!mounted) return; - setState(() { - _versionLabel = 'v0.0.0'; - }); - } - } - - void _onEndpointChanged(String? domain) { - if (domain == null || domain.isEmpty) { - return; - } - - setState(() { - _selectedDomain = domain; - }); - widget.onDomainChanged(domain); - setState(() { - _instanceVersionFuture = - InstanceVersionCache.instance.fetch(domain: _selectedDomain); - }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Endpoint set to $domain')), - ); - } - - void _onThemeModeChanged(ThemeMode? mode) { - if (mode == null) return; - setState(() { - _selectedThemeMode = mode; - }); - widget.onThemeChanged?.call(mode); - } - - List> _buildEndpointOptions() { - final seen = {}; - void addOption(String domain, String name) { - final key = domain.trim(); - if (key.isEmpty || seen.containsKey(key)) return; - seen[key] = name; - } - - addOption(_defaultDomain, _defaultName); - for (final instance in _instances) { - addOption(instance.domain, instance.displayName); - } - if (_selectedDomain.isNotEmpty && !seen.containsKey(_selectedDomain)) { - addOption(_selectedDomain, 'Custom (${_selectedDomain.trim()})'); - } - - final sortedKeys = seen.keys.toList() - ..sort( - (a, b) => seen[a]!.toLowerCase().compareTo(seen[b]!.toLowerCase())); - - return sortedKeys - .map( - (domain) => DropdownMenuItem( - value: domain, - child: Text(seen[domain]!), - ), - ) - .toList(); - } - - @override - Widget build(BuildContext context) { - final endpointItems = _buildEndpointOptions(); - return Scaffold( - appBar: AppBar(title: const Text('Settings')), - body: ListView( - children: [ - ListTile( - leading: const Icon(Icons.cloud), - title: const Text('Endpoint'), - subtitle: Text('$_selectedDomain/api/messages'), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: DropdownButtonFormField( - key: ValueKey(_selectedDomain), - initialValue: _selectedDomain.isNotEmpty - ? _selectedDomain - : _defaultDomain, - isExpanded: true, - decoration: const InputDecoration( - labelText: 'Select endpoint', - border: OutlineInputBorder(), - ), - items: endpointItems, - onChanged: _loading ? null : _onEndpointChanged, - ), - ), - const SizedBox(width: 8), - IconButton( - tooltip: 'Refresh instances', - icon: const Icon(Icons.refresh), - onPressed: _loading - ? null - : () => _fetchInstances(refresh: true), - ), - ], - ), - const SizedBox(height: 8), - if (_loading) - const LinearProgressIndicator() - else if (_error != null) - Text( - 'Failed to load instances: $_error', - style: const TextStyle(color: Colors.redAccent), - ) - else if (_instances.isEmpty) - const Text('No federation instances returned.'), - ], - ), - ), - FutureBuilder( - key: ValueKey(_selectedDomain), - future: _instanceVersionFuture, - builder: (context, snapshot) { - final info = snapshot.data; - final domainDisplay = _selectedDomain.trim().isEmpty - ? 'potatomesh.net' - : _selectedDomain.trim(); - final domainUri = _buildDomainUrl(domainDisplay); - Widget subtitle; - if (snapshot.connectionState == ConnectionState.waiting) { - subtitle = const Text('Loading version info…'); - } else if (info != null) { - subtitle = Text(info.summary); - } else { - subtitle = const Text('Version info unavailable'); - } - return ListTile( - leading: const Icon(Icons.storage), - title: const Text('Instance'), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - subtitle, - const SizedBox(height: 4), - RichText( - text: TextSpan( - text: domainDisplay, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - decoration: TextDecoration.underline, - ), - recognizer: TapGestureRecognizer() - ..onTap = () async { - if (domainUri != null) { - await launchUrl( - domainUri, - mode: LaunchMode.externalApplication, - ); - } - }, - ), - ), - ], - ), - ); - }, - ), - const Divider(), - const ListTile( - leading: Icon(Icons.palette_outlined), - title: Text('Theme'), - subtitle: Text('Select preferred appearance'), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: DropdownButtonFormField( - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'Appearance', - ), - initialValue: _selectedThemeMode, - items: const [ - DropdownMenuItem( - value: ThemeMode.system, - child: Text('System'), - ), - DropdownMenuItem( - value: ThemeMode.light, - child: Text('Light'), - ), - DropdownMenuItem( - value: ThemeMode.dark, - child: Text('Dark'), - ), - ], - onChanged: _onThemeModeChanged, - ), - ), - const Divider(), - const ListTile( - leading: Icon(Icons.info_outline), - title: Text('About'), - subtitle: Text( - '🥔 PotatoMesh Reader - a read-only view of a selected Meshtastic region.'), - ), - ListTile( - leading: const Icon(Icons.tag), - title: const Text('Version'), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(_versionLabel.isNotEmpty ? _versionLabel : 'Loading…'), - const SizedBox(height: 4), - RichText( - text: TextSpan( - text: 'github.com/l5yth/potato-mesh', - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - decoration: TextDecoration.underline, - ), - recognizer: TapGestureRecognizer() - ..onTap = () async { - final uri = Uri.parse( - 'https://github.com/l5yth/potato-mesh/', - ); - await launchUrl( - uri, - mode: LaunchMode.externalApplication, - ); - }, - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -/// --- Data layer ------------------------------------------------------------ - -/// Representation of a single mesh message returned by the PotatoMesh API. -class MeshMessage { - final int id; - final int? rxTimeSeconds; - final DateTime? rxTime; - final String rxIso; - final String fromId; - final String nodeId; - final String toId; - final int? channel; - final String? channelName; - final String portnum; - final String text; - final int? rssi; - final double? snr; - final int? hopLimit; - - /// Creates a [MeshMessage] with all properties parsed from the API response. - MeshMessage({ - required this.id, - this.rxTimeSeconds, - required this.rxTime, - required this.rxIso, - required this.fromId, - this.nodeId = '', - required this.toId, - required this.channel, - required this.channelName, - required this.portnum, - required this.text, - required this.rssi, - required this.snr, - required this.hopLimit, - }); - - /// Parses a [MeshMessage] from the raw JSON map returned by the API. - factory MeshMessage.fromJson(Map json) { - int? parsedSeconds; - final rawSeconds = json['rx_time']; - if (rawSeconds != null) { - parsedSeconds = int.tryParse(rawSeconds.toString()); - } - - DateTime? parsedTime; - if (json['rx_iso'] is String) { - try { - final parsedIso = DateTime.parse(json['rx_iso'] as String); - parsedTime = parsedIso.toLocal(); - parsedSeconds ??= parsedIso.toUtc().millisecondsSinceEpoch ~/ 1000; - } catch (_) { - parsedTime = null; - } - } - - final parsedMillis = parsedTime?.toUtc().millisecondsSinceEpoch; - if (parsedMillis != null && parsedSeconds == null) { - parsedSeconds = parsedMillis ~/ 1000; - } - parsedTime ??= parsedSeconds != null - ? DateTime.fromMillisecondsSinceEpoch(parsedSeconds * 1000, isUtc: true) - .toLocal() - : null; - - double? parseDouble(dynamic v) { - if (v == null) return null; - if (v is num) return v.toDouble(); - return double.tryParse(v.toString()); - } - - int? parseInt(dynamic v) { - if (v == null) return null; - if (v is int) return v; - return int.tryParse(v.toString()); - } - - return MeshMessage( - id: parseInt(json['id']) ?? 0, - rxTimeSeconds: parsedSeconds, - rxTime: parsedTime, - rxIso: json['rx_iso']?.toString() ?? '', - fromId: json['from_id']?.toString() ?? '', - nodeId: json['node_id']?.toString() ?? '', - toId: json['to_id']?.toString() ?? '', - channel: parseInt(json['channel']), - channelName: json['channel_name']?.toString(), - portnum: json['portnum']?.toString() ?? '', - text: json['text']?.toString() ?? '', - rssi: parseInt(json['rssi']), - snr: parseDouble(json['snr']), - hopLimit: parseInt(json['hop_limit']), - ); - } - - /// Formats the message time as HH:MM in local time. - String get timeFormatted { - if (rxTime == null) return '--:--'; - final h = rxTime!.hour.toString().padLeft(2, '0'); - final m = rxTime!.minute.toString().padLeft(2, '0'); - return '$h:$m'; - } - - /// Returns sender without a leading `!` prefix for display. - String get fromShort { - if (fromId.isEmpty) return '?'; - return fromId.startsWith('!') ? fromId.substring(1) : fromId; - } - - /// Prefer the explicit node id when present, falling back to the sender id. - String get lookupNodeId => nodeId.isNotEmpty ? nodeId : fromId; - - /// Serialises the message for persistence in local storage. - Map toJson() { - return { - 'id': id, - 'rx_time': rxTimeSeconds, - 'rx_iso': rxIso, - 'from_id': fromId, - 'node_id': nodeId, - 'to_id': toId, - 'channel': channel, - 'channel_name': channelName, - 'portnum': portnum, - 'text': text, - 'rssi': rssi, - 'snr': snr, - 'hop_limit': hopLimit, - }; - } -} - -/// Mesh federation instance metadata used to configure endpoints. -class MeshInstance { - const MeshInstance({ - required this.name, - required this.domain, - this.id = '', - this.isPrivate = false, - this.lastUpdateTime, - }); - - /// Human-friendly instance name. - final String name; - - /// Instance domain hosting the PotatoMesh API. - final String domain; - - /// Unique identifier for the instance when provided by the API. - final String id; - - /// True when the instance is marked as private and should be hidden. - final bool isPrivate; - - /// Optional last update timestamp from the federation payload. - final DateTime? lastUpdateTime; - - /// Prefer the provided name, falling back to the domain. - String get displayName => name.isNotEmpty ? name : domain; - - /// Parse a [MeshInstance] from an API payload entry. - factory MeshInstance.fromJson(Map json) { - final domain = json['domain']?.toString().trim() ?? ''; - final name = json['name']?.toString().trim() ?? ''; - final id = json['id']?.toString().trim() ?? ''; - final isPrivateRaw = json['isPrivate'] ?? json['private']; - final isPrivate = isPrivateRaw is bool - ? isPrivateRaw - : isPrivateRaw?.toString().toLowerCase() == 'true'; - DateTime? lastUpdate; - final lastUpdateRaw = json['lastUpdateTime']; - if (lastUpdateRaw != null) { - final seconds = int.tryParse(lastUpdateRaw.toString()); - if (seconds != null) { - lastUpdate = - DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true); - } - } - - return MeshInstance( - name: name, - domain: domain, - id: id, - isPrivate: isPrivate, - lastUpdateTime: lastUpdate, - ); - } - - /// Serialize the instance for persistence. - Map toJson() { - return { - 'id': id, - 'name': name, - 'domain': domain, - 'isPrivate': isPrivate, - 'lastUpdateTime': lastUpdateTime != null - ? lastUpdateTime!.millisecondsSinceEpoch ~/ 1000 - : null, - }; - } -} - -/// Node metadata persisted locally to avoid repeated network lookups. -class MeshNode { - const MeshNode({ - required this.nodeId, - this.shortName = '', - this.longName = '', - this.lastHeard, - this.firstHeard, - this.hwModel, - this.latitude, - this.longitude, - }); - - final String nodeId; - final String shortName; - final String longName; - final DateTime? lastHeard; - final DateTime? firstHeard; - final String? hwModel; - final double? latitude; - final double? longitude; - - /// Returns a name suitable for chat rendering. - String get displayShortName => shortName.isNotEmpty - ? shortName - : NodeShortNameCache.fallbackShortName(nodeId); - - /// Whether the node was heard within the provided freshness window. - bool isActive(Duration freshness, DateTime nowUtc) { - if (lastHeard == null) return false; - final threshold = nowUtc.subtract(freshness); - return lastHeard!.isAfter(threshold); - } - - factory MeshNode.fromJson(Map json) { - DateTime? parseSeconds(dynamic value) { - if (value == null) return null; - final seconds = int.tryParse(value.toString()); - if (seconds == null) return null; - return DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true); - } - - double? parseDouble(dynamic value) { - if (value == null) return null; - if (value is num) return value.toDouble(); - return double.tryParse(value.toString()); - } - - return MeshNode( - nodeId: json['node_id']?.toString() ?? json['id']?.toString() ?? '', - shortName: - json['short_name']?.toString() ?? json['shortName']?.toString() ?? '', - longName: - json['long_name']?.toString() ?? json['longName']?.toString() ?? '', - lastHeard: - parseSeconds(json['last_heard']) ?? parseSeconds(json['lastSeen']), - firstHeard: parseSeconds(json['first_heard']), - hwModel: json['hw_model']?.toString(), - latitude: parseDouble(json['latitude']), - longitude: parseDouble(json['longitude']), - ); - } - - Map toJson() { - return { - 'node_id': nodeId, - 'short_name': shortName, - 'long_name': longName, - 'last_heard': - lastHeard != null ? lastHeard!.millisecondsSinceEpoch ~/ 1000 : null, - 'first_heard': firstHeard != null - ? firstHeard!.millisecondsSinceEpoch ~/ 1000 - : null, - 'hw_model': hwModel, - 'latitude': latitude, - 'longitude': longitude, - }; - } -} - -/// The protocol identifier sent to the API to filter results to Meshtastic only. -const String _kProtocolFilter = 'meshtastic'; - -/// Build a messages API URI for a given domain or absolute URL. -Uri _buildMessagesUri(String domain, {int since = 0, int limit = 1000}) { - final trimmed = domain.trim(); - final params = { - 'limit': limit.toString(), - 'encrypted': 'false', - 'since': since.toString(), - 'protocol': _kProtocolFilter, - }; - if (trimmed.isEmpty) { - return Uri.https('potatomesh.net', '/api/messages', params); - } - - if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { - final parsed = Uri.parse(trimmed); - return parsed.replace( - path: '/api/messages', - queryParameters: params, - ); - } - - return Uri.https(trimmed, '/api/messages', params); -} - -/// Build a node metadata API URI for a given domain. -Uri _buildNodeUri(String domain, String nodeId) { - final trimmedDomain = domain.trim(); - final encodedId = Uri.encodeComponent(nodeId); - - if (trimmedDomain.isEmpty) { - return Uri.https('potatomesh.net', '/api/nodes/$encodedId'); - } - - if (trimmedDomain.startsWith('http://') || - trimmedDomain.startsWith('https://')) { - final parsed = Uri.parse(trimmedDomain); - return parsed.replace(path: '/api/nodes/$encodedId'); - } - - return Uri.https(trimmedDomain, '/api/nodes/$encodedId'); -} - -/// Build the bulk nodes API URI for fetching recent nodes. -Uri _buildNodesUri(String domain, {int limit = 1000}) { - final trimmedDomain = domain.trim(); - final params = {'limit': limit.toString(), 'protocol': _kProtocolFilter}; - - if (trimmedDomain.isEmpty) { - return Uri.https('potatomesh.net', '/api/nodes', params); - } - - if (trimmedDomain.startsWith('http://') || - trimmedDomain.startsWith('https://')) { - final parsed = Uri.parse(trimmedDomain); - return parsed.replace(path: '/api/nodes', queryParameters: params); - } - - return Uri.https(trimmedDomain, '/api/nodes', params); -} - -/// Build a /version endpoint URI for a given domain. -Uri _buildVersionUri(String domain) { - final trimmed = domain.trim(); - if (trimmed.isEmpty) { - return Uri.https('potatomesh.net', '/version'); - } - if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { - final parsed = Uri.parse(trimmed); - return parsed.replace(path: '/version'); - } - return Uri.https(trimmed, '/version'); -} - -/// Build an instances API URI for federation discovery. -Uri _buildInstancesUri(String domain) { - final trimmed = domain.trim(); - if (trimmed.isEmpty || trimmed == 'potatomesh.net') { - return Uri.https('potatomesh.net', '/api/instances'); - } - if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { - final parsed = Uri.parse(trimmed); - return parsed.replace(path: '/api/instances'); - } - return Uri.https(trimmed, '/api/instances'); -} - -String _composeGitAwareVersion(PackageInfo info) { - const versionDefine = _gitVersionEnv; - if (versionDefine.isNotEmpty) { - return versionDefine.startsWith('v') ? versionDefine : 'v$versionDefine'; - } - - const tagDefine = _gitTagEnv; - if (tagDefine.isNotEmpty) { - final tag = tagDefine.startsWith('v') ? tagDefine : 'v$tagDefine'; - final suffixParts = []; - const commitsDefine = _gitCommitsEnv; - const shaDefine = _gitShaEnv; - const dirtyDefine = _gitDirtyEnv; - final commits = commitsDefine.trim(); - final sha = shaDefine.trim(); - final dirtyFlag = dirtyDefine.toLowerCase().trim(); - final dirty = dirtyFlag == 'true' || dirtyFlag == '1' || dirtyFlag == 'yes'; - - if (commits.isNotEmpty && commits != '0') { - suffixParts.add(commits); - if (sha.isNotEmpty) { - suffixParts.add(sha); - } - } else if (sha.isNotEmpty) { - suffixParts.add(sha); - } - - if (dirty) { - if (suffixParts.isEmpty) { - suffixParts.add('dirty'); - } else { - suffixParts[suffixParts.length - 1] = '${suffixParts.last}-dirty'; - } - } - - return suffixParts.isEmpty ? tag : '$tag+${suffixParts.join('-')}'; - } - - final base = 'v${info.version}'; - return info.buildNumber.isNotEmpty ? '$base+${info.buildNumber}' : base; -} - -Uri? _buildDomainUrl(String domain) { - final trimmed = domain.trim(); - if (trimmed.isEmpty) return null; - final hasScheme = - trimmed.startsWith('http://') || trimmed.startsWith('https://'); - final candidate = hasScheme ? trimmed : 'https://$trimmed'; - return Uri.tryParse(candidate); -} - -/// Fetches the latest PotatoMesh messages and returns them sorted by receive time. -/// -/// A custom [client] can be supplied for testing; otherwise a short-lived -/// [http.Client] is created and closed after the request completes. -Future> fetchMessages({ - http.Client? client, - String domain = 'potatomesh.net', - int since = 0, -}) async { - final uri = _buildMessagesUri(domain, since: since, limit: 1000); - _logHttp('GET $uri'); - - final httpClient = client ?? http.Client(); - final shouldClose = client == null; - - final resp = await httpClient.get(uri).timeout(_requestTimeout); - _logHttp('HTTP ${resp.statusCode} $uri'); - if (shouldClose) { - httpClient.close(); - } - if (resp.statusCode != 200) { - throw Exception('HTTP ${resp.statusCode}: ${resp.body}'); - } - - final decoded = await _decodeJsonList(resp.body); - final msgs = decoded.map(MeshMessage.fromJson).toList(); - - return sortMessagesByRxTime(msgs); -} - -/// Memoised loader for node short names sourced from the API. -class NodeShortNameCache { - NodeShortNameCache._(); - - /// Singleton instance used by chat line rendering. - static final NodeShortNameCache instance = NodeShortNameCache._(); - - MeshNodeResolver? _resolver; - final Map> _cache = {}; - final Map> _primedShortNames = {}; - bool _allowRemoteLookups = true; - - /// Registers a resolver that can supply locally cached node metadata. - void registerResolver(MeshNodeResolver resolver) { - _resolver = resolver; - } - - /// Clears memoised entries; primarily used in tests. - void clear() { - _cache.clear(); - _primedShortNames.clear(); - } - - /// Enables or disables remote lookups for short names. - set allowRemoteLookups(bool enabled) { - _allowRemoteLookups = enabled; - } - - /// Seeds the cache with a batch of node metadata to avoid network calls. - void prime({required String domain, required Iterable nodes}) { - final key = _normalizeDomainKey(domain); - final map = _primedShortNames.putIfAbsent(key, () => {}); - for (final node in nodes) { - final id = node.nodeId.trim(); - final name = node.shortName.trim(); - if (id.isEmpty || name.isEmpty) continue; - map[id] = name; - } - } - - /// Resolve the short name for a node, defaulting to the fallback suffix. - Future shortNameFor({ - required String domain, - required String nodeId, - http.Client? client, - }) { - final trimmedId = nodeId.trim(); - final normalizedDomain = _normalizeDomainKey(domain); - final normalizedId = _normalizeNodeId(trimmedId); - final fallback = fallbackShortName(trimmedId); - if (normalizedId.isEmpty) return Future.value(fallback); - if (!_allowRemoteLookups) return Future.value(fallback); - - final domainKey = normalizedDomain; - final primed = _primedShortNames[domainKey]; - if (primed != null) { - final primedName = - primed[trimmedId] ?? primed['!$trimmedId'] ?? primed[normalizedId]; - if (primedName != null && primedName.isNotEmpty) { - return Future.value(padToWidth(primedName)); - } - } - - final resolved = _resolver?.findNode(domainKey, trimmedId); - if (resolved != null && resolved.displayShortName.isNotEmpty) { - final name = resolved.displayShortName; - _storePrimed(domainKey, trimmedId, name); - return Future.value(padToWidth(name)); - } - - final key = '$domainKey|$normalizedId'; - if (_cache.containsKey(key)) { - return _cache[key]!; - } - - final future = _loadShortName( - domain: domain, - nodeId: trimmedId, - fallback: fallback, - client: client, - ); - _cache[key] = future; - return future; - } - - Future _loadShortName({ - required String domain, - required String nodeId, - required String fallback, - http.Client? client, - }) async { - final normalizedDomain = _normalizeDomainKey(domain); - final normalizedId = _normalizeNodeId(nodeId); - final cacheKey = '$normalizedDomain|$normalizedId'; - if (_cache.containsKey(cacheKey)) { - return _cache[cacheKey]!; - } - - final uri = _buildNodeUri(domain, nodeId); - final httpClient = client ?? http.Client(); - final shouldClose = client == null; - - try { - _logHttp('GET $uri'); - final resp = await httpClient.get(uri).timeout(_requestTimeout); - _logHttp('HTTP ${resp.statusCode} $uri'); - if (resp.statusCode != 200) return fallback; - - final decoded = await _decodeJsonMap(resp.body); - final raw = decoded['short_name'] ?? decoded['shortName']; - if (raw != null) { - final name = raw.toString().trim(); - if (name.isNotEmpty) { - _storePrimed(domain, nodeId, name); - return padToWidth(name); - } - } - - return fallback; - } catch (_) { - return fallback; - } finally { - if (shouldClose) { - httpClient.close(); - } - } - } - - /// Fallback that uses the trailing four characters of the node id. - static String fallbackShortName(String fromId) { - final trimmed = fromId.startsWith('!') ? fromId.substring(1) : fromId; - if (trimmed.isEmpty) return '????'; - final suffix = - trimmed.length <= 4 ? trimmed : trimmed.substring(trimmed.length - 4); - return padToWidth(suffix); - } - - /// Ensures the provided short name is at least [width] characters wide. - static String padToWidth(String value, {int width = 4}) { - if (value.length >= width) return value; - return value.padLeft(width); - } - - void _storePrimed(String domain, String nodeId, String name) { - final domainKey = _normalizeDomainKey(domain); - final map = _primedShortNames.putIfAbsent(domainKey, () => {}); - final trimmedId = nodeId.trim(); - final normalizedId = _normalizeNodeId(trimmedId); - map[trimmedId] = name; - map['!$normalizedId'] = name; - map[normalizedId] = name; - } - - String _normalizeDomainKey(String domain) { - var cleaned = domain.trim(); - if (cleaned.startsWith('https://')) cleaned = cleaned.substring(8); - if (cleaned.startsWith('http://')) cleaned = cleaned.substring(7); - if (cleaned.endsWith('/')) { - cleaned = cleaned.substring(0, cleaned.length - 1); - } - if (cleaned.isEmpty) return 'potatomesh.net'; - return cleaned.toLowerCase(); - } - - String _normalizeNodeId(String id) { - final trimmed = id.trim(); - return trimmed.startsWith('!') ? trimmed.substring(1) : trimmed; - } -} - -/// Cached metadata describing an instance's public version payload. -class InstanceVersion { - const InstanceVersion({ - required this.name, - required this.channel, - required this.frequency, - required this.instanceDomain, - }); - - final String name; - final String? channel; - final String? frequency; - final String? instanceDomain; - - String get summary { - final parts = []; - if (name.isNotEmpty) parts.add(name); - if (channel != null && channel!.isNotEmpty) parts.add(channel!); - if (frequency != null && frequency!.isNotEmpty) parts.add(frequency!); - return parts.isNotEmpty ? parts.join(' · ') : 'Unknown'; - } - - factory InstanceVersion.fromJson(Map json) { - final config = json['config'] is Map - ? json['config'] as Map - : {}; - final siteName = config['siteName']?.toString().trim() ?? ''; - final name = (json['name']?.toString().trim() ?? '').isNotEmpty - ? json['name'].toString().trim() - : siteName; - return InstanceVersion( - name: name, - channel: config['channel']?.toString().trim(), - frequency: config['frequency']?.toString().trim(), - instanceDomain: config['instanceDomain']?.toString().trim(), - ); - } -} - -/// Memoised loader for instance version payloads. -class InstanceVersionCache { - InstanceVersionCache._(); - - static final InstanceVersionCache instance = InstanceVersionCache._(); - - final Map> _cache = {}; - - Future fetch({ - required String domain, - http.Client? client, - }) { - final key = domain.trim().isEmpty ? 'potatomesh.net' : domain.trim(); - if (_cache.containsKey(key)) { - return _cache[key]!; - } - final future = _load(key, client: client); - _cache[key] = future; - return future; - } - - Future _load( - String domain, { - http.Client? client, - }) async { - final uri = _buildVersionUri(domain); - final httpClient = client ?? http.Client(); - final shouldClose = client == null; - try { - _logHttp('GET $uri'); - final resp = await httpClient.get(uri).timeout(_requestTimeout); - _logHttp('HTTP ${resp.statusCode} $uri'); - if (resp.statusCode != 200) return null; - final decoded = await _decodeJsonMap(resp.body); - return InstanceVersion.fromJson(decoded); - } catch (_) { - return null; - } finally { - if (shouldClose) { - httpClient.close(); - } - } - } -} - -/// Fetches and validates federation instances, persisting them locally. -Future> fetchInstances({http.Client? client}) { - final repository = MeshRepository(client: client); - return repository.discoverInstances(client: client); -} - -/// Returns a new list sorted by receive time so older messages render first. -/// -/// Messages that lack a receive time keep their original positions to avoid -/// shuffling "unknown" entries to the start or end of the feed. Only messages -/// with a concrete [rxTime] are re-ordered chronologically. -List sortMessagesByRxTime(List messages) { - final knownTimes = messages.where((m) => m.rxTime != null).toList() - ..sort((a, b) => a.rxTime!.compareTo(b.rxTime!)); - - var knownIndex = 0; - return messages.map((message) { - if (message.rxTime == null) { - return message; - } - - final sortedMessage = knownTimes[knownIndex]; - knownIndex += 1; - return sortedMessage; - }).toList(); -} diff --git a/app/pubspec.lock b/app/pubspec.lock deleted file mode 100644 index 420716e3..00000000 --- a/app/pubspec.lock +++ /dev/null @@ -1,738 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - ansicolor: - dependency: transitive - description: - name: ansicolor - sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" - url: "https://pub.dev" - source: hosted - version: "2.0.3" - archive: - dependency: transitive - description: - name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" - url: "https://pub.dev" - source: hosted - version: "4.0.7" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - async: - dependency: transitive - description: - name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" - url: "https://pub.dev" - source: hosted - version: "2.13.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - characters: - dependency: transitive - description: - name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.dev" - source: hosted - version: "1.4.1" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" - url: "https://pub.dev" - source: hosted - version: "2.0.4" - cli_util: - dependency: transitive - description: - name: cli_util - sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c - url: "https://pub.dev" - source: hosted - version: "0.4.2" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" - csslib: - dependency: transitive - description: - name: csslib - sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" - url: "https://pub.dev" - source: hosted - version: "1.0.2" - dbus: - dependency: transitive - description: - name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" - url: "https://pub.dev" - source: hosted - version: "0.7.11" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" - source: hosted - version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - ffi_leak_tracker: - dependency: transitive - description: - name: ffi_leak_tracker - sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" - url: "https://pub.dev" - source: hosted - version: "0.1.2" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_launcher_icons: - dependency: "direct dev" - description: - name: flutter_launcher_icons - sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" - url: "https://pub.dev" - source: hosted - version: "0.14.4" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.dev" - source: hosted - version: "6.0.0" - flutter_local_notifications: - dependency: "direct main" - description: - name: flutter_local_notifications - sha256: "357be7ff996dc272e2037c69c1bdd5d1bd21172219d55f8bd2e11aeea602e6d3" - url: "https://pub.dev" - source: hosted - version: "22.3.1" - flutter_local_notifications_linux: - dependency: transitive - description: - name: flutter_local_notifications_linux - sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b" - url: "https://pub.dev" - source: hosted - version: "8.0.1" - flutter_local_notifications_platform_interface: - dependency: transitive - description: - name: flutter_local_notifications_platform_interface - sha256: "43c3761d916c9bd3d5c7ebbc44d82f4990329840c0c5d62ad5260cc1b5d399bd" - url: "https://pub.dev" - source: hosted - version: "12.2.0" - flutter_local_notifications_web: - dependency: transitive - description: - name: flutter_local_notifications_web - sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - flutter_local_notifications_windows: - dependency: transitive - description: - name: flutter_local_notifications_windows - sha256: "6f43bdd03b171b7a90f22647506fea33e2bb12294b7c7c7a3d690e960a382945" - url: "https://pub.dev" - source: hosted - version: "3.1.1" - flutter_native_splash: - dependency: "direct dev" - description: - name: flutter_native_splash - sha256: "9db4b80b044e9af17cc4b1272137fc7ace0054d879ef8210a76adc34aaf4cdff" - url: "https://pub.dev" - source: hosted - version: "2.4.8" - flutter_svg: - dependency: "direct main" - description: - name: flutter_svg - sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" - url: "https://pub.dev" - source: hosted - version: "2.3.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - html: - dependency: transitive - description: - name: html - sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" - url: "https://pub.dev" - source: hosted - version: "0.15.6" - http: - dependency: "direct main" - description: - name: http - sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.dev" - source: hosted - version: "1.6.0" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - image: - dependency: transitive - description: - name: image - sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" - url: "https://pub.dev" - source: hosted - version: "4.5.4" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" - url: "https://pub.dev" - source: hosted - version: "4.9.0" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" - source: hosted - version: "11.0.2" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" - source: hosted - version: "3.0.10" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - lints: - dependency: transitive - description: - name: lints - sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 - url: "https://pub.dev" - source: hosted - version: "6.0.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" - url: "https://pub.dev" - source: hosted - version: "0.12.20" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.dev" - source: hosted - version: "0.13.0" - meta: - dependency: transitive - description: - name: meta - sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" - url: "https://pub.dev" - source: hosted - version: "1.19.0" - package_info_plus: - dependency: "direct main" - description: - name: package_info_plus - sha256: "73ef2cd5c11a7548f9b7f6fc2a4f6113de2e64aa2a70de63feede95548822908" - url: "https://pub.dev" - source: hosted - version: "10.2.2" - package_info_plus_platform_interface: - dependency: transitive - description: - name: package_info_plus_platform_interface - sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 - url: "https://pub.dev" - source: hosted - version: "4.1.0" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - path_parsing: - dependency: transitive - description: - name: path_parsing - sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.dev" - source: hosted - version: "2.2.1" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" - url: "https://pub.dev" - source: hosted - version: "7.0.1" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - posix: - dependency: transitive - description: - name: posix - sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" - url: "https://pub.dev" - source: hosted - version: "6.0.3" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf - url: "https://pub.dev" - source: hosted - version: "2.5.5" - shared_preferences_android: - dependency: "direct main" - description: - name: shared_preferences_android - sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" - url: "https://pub.dev" - source: hosted - version: "2.4.28" - shared_preferences_foundation: - dependency: "direct main" - description: - name: shared_preferences_foundation - sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" - url: "https://pub.dev" - source: hosted - version: "2.5.7" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 - url: "https://pub.dev" - source: hosted - version: "2.4.3" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_span: - dependency: transitive - description: - name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" - url: "https://pub.dev" - source: hosted - version: "1.10.1" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" - url: "https://pub.dev" - source: hosted - version: "0.7.12" - timezone: - dependency: transitive - description: - name: timezone - sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" - url: "https://pub.dev" - source: hosted - version: "0.11.1" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - universal_io: - dependency: transitive - description: - name: universal_io - sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 - url: "https://pub.dev" - source: hosted - version: "2.3.1" - url_launcher: - dependency: "direct main" - description: - name: url_launcher - sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda - url: "https://pub.dev" - source: hosted - version: "6.3.3" - url_launcher_android: - dependency: transitive - description: - name: url_launcher_android - sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" - url: "https://pub.dev" - source: hosted - version: "6.3.28" - url_launcher_ios: - dependency: transitive - description: - name: url_launcher_ios - sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad - url: "https://pub.dev" - source: hosted - version: "6.3.6" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a - url: "https://pub.dev" - source: hosted - version: "3.2.2" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" - url: "https://pub.dev" - source: hosted - version: "3.2.5" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" - url: "https://pub.dev" - source: hosted - version: "3.1.5" - vector_graphics: - dependency: transitive - description: - name: vector_graphics - sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" - url: "https://pub.dev" - source: hosted - version: "1.2.3" - vector_graphics_codec: - dependency: transitive - description: - name: vector_graphics_codec - sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" - url: "https://pub.dev" - source: hosted - version: "1.1.13" - vector_graphics_compiler: - dependency: transitive - description: - name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc - url: "https://pub.dev" - source: hosted - version: "1.1.19" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" - url: "https://pub.dev" - source: hosted - version: "2.4.3" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" - url: "https://pub.dev" - source: hosted - version: "15.0.2" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - win32: - dependency: transitive - description: - name: win32 - sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d - url: "https://pub.dev" - source: hosted - version: "6.4.0" - workmanager: - dependency: "direct main" - description: - name: workmanager - sha256: "96b09a4a852ec487d29371f27031dab80ce2e260911e2dbe0f904951e22a3956" - url: "https://pub.dev" - source: hosted - version: "0.10.10" - workmanager_android: - dependency: transitive - description: - name: workmanager_android - sha256: ffe8aa061885370c9363f7ae2d2196d31855217c58e7b15899d915d36c768c4a - url: "https://pub.dev" - source: hosted - version: "0.10.9" - workmanager_apple: - dependency: transitive - description: - name: workmanager_apple - sha256: "9e0ca0b857df986b37ca964ffa3b21990e1bc0f7935356a9706b47e5b71a7524" - url: "https://pub.dev" - source: hosted - version: "0.9.11" - workmanager_linux: - dependency: transitive - description: - name: workmanager_linux - sha256: "7d8520e1103b910a43d8bafe37ec2415f4c7c148afcb1eee0ec73ae67b4cdd22" - url: "https://pub.dev" - source: hosted - version: "0.1.1+1" - workmanager_platform_interface: - dependency: transitive - description: - name: workmanager_platform_interface - sha256: a1022dca1284d64ba8bbe046edc9089cc5158b3d0e2cf9f68c7cde2fbf89746c - url: "https://pub.dev" - source: hosted - version: "0.10.5" - workmanager_web: - dependency: transitive - description: - name: workmanager_web - sha256: "2ac481ade599bdd957ee2891be09a0c34be87923a2c3ca4dfe36add6fc735ffd" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - xml: - dependency: transitive - description: - name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" - url: "https://pub.dev" - source: hosted - version: "6.6.1" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" -sdks: - dart: ">=3.12.0 <4.0.0" - flutter: ">=3.44.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml deleted file mode 100644 index 36e1a823..00000000 --- a/app/pubspec.yaml +++ /dev/null @@ -1,51 +0,0 @@ -# Copyright © 2025-26 l5yth & contributors -# Licensed under the Apache License, Version 2.0 (see LICENSE) -name: potato_mesh_reader -description: Meshtastic Reader — read-only view for PotatoMesh messages. -publish_to: "none" -version: 1.0.0 - -environment: - sdk: ">=3.4.0 <4.0.0" - -dependencies: - flutter: - sdk: flutter - http: ^1.2.0 - package_info_plus: ^10.2.2 - flutter_svg: ^2.3.0 - url_launcher: ^6.3.3 - shared_preferences: ^2.5.5 - flutter_local_notifications: ^22.3.1 - workmanager: ^0.10.10 - # Direct only because lib/dart_plugin_registrant.dart imports them for the - # background isolate. Keep carets: Dependabot turns `any` into an exact pin. - shared_preferences_android: ^2.4.28 - shared_preferences_foundation: ^2.5.7 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^6.0.0 - flutter_launcher_icons: ^0.14.4 - flutter_native_splash: ^2.4.8 - -flutter: - uses-material-design: true - assets: - - assets/ - -flutter_launcher_icons: - android: true - ios: true - image_path: assets/icon-launcher.png - remove_alpha_ios: true - adaptive_icon_background: "#111417" - adaptive_icon_foreground: assets/icon-launcher.png - -flutter_native_splash: - color: "#111417" - image: assets/icon-splash.png - android_12: - color: "#111417" - image: assets/icon.png diff --git a/app/release.sh b/app/release.sh deleted file mode 100755 index 93f23c9e..00000000 --- a/app/release.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash - -# Copyright © 2025-26 l5yth & contributors -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -set -euo pipefail - -export GIT_TAG="$(git describe --tags --abbrev=0)" -export GIT_COMMITS="$(git rev-list --count ${GIT_TAG}..HEAD)" -export GIT_SHA="$(git rev-parse --short=9 HEAD)" -export GIT_DIRTY="$(git diff --quiet --ignore-submodules HEAD || echo true || echo false)" -flutter clean -flutter pub get -flutter build apk --release \ - --dart-define=GIT_TAG="${GIT_TAG}" \ - --dart-define=GIT_COMMITS="${GIT_COMMITS}" \ - --dart-define=GIT_SHA="${GIT_SHA}" \ - --dart-define=GIT_DIRTY="${GIT_DIRTY}" - -if [ "$GIT_COMMITS" -eq 0 ]; then - TAG_NAME="$GIT_TAG" -else - TAG_NAME="${GIT_TAG}+${GIT_COMMITS}.g${GIT_SHA}" -fi - -if [ "$GIT_DIRTY" = "true" ]; then - TAG_NAME="${TAG_NAME}.dirty" -fi - -export APK_DIR="build/app/outputs/flutter-apk" -mv -v "${APK_DIR}/app-release.apk" "${APK_DIR}/potatomesh-reader-android-${TAG_NAME}.apk" -(cd "${APK_DIR}" && sha256sum "potatomesh-reader-android-${TAG_NAME}.apk" > "potatomesh-reader-android-${TAG_NAME}.apk.sha256sum") diff --git a/app/test/background_sync_manager_test.dart b/app/test/background_sync_manager_test.dart deleted file mode 100644 index 0c5aae92..00000000 --- a/app/test/background_sync_manager_test.dart +++ /dev/null @@ -1,198 +0,0 @@ -// Copyright © 2025-26 l5yth & contributors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'package:flutter_test/flutter_test.dart'; -import 'package:potato_mesh_reader/main.dart'; -import 'package:workmanager/workmanager.dart'; - -class _FakeWorkmanagerAdapter implements WorkmanagerAdapter { - bool initialized = false; - bool registered = false; - Duration? frequency; - ExistingPeriodicWorkPolicy? policy; - Constraints? constraints; - Duration? initialDelay; - Function? dispatcher; - - @override - Future initialize(Function dispatcher) async { - initialized = true; - this.dispatcher = dispatcher; - } - - @override - Future registerPeriodicTask( - String taskId, - String taskName, { - Duration frequency = const Duration(minutes: 15), - ExistingPeriodicWorkPolicy existingWorkPolicy = - ExistingPeriodicWorkPolicy.keep, - Duration? initialDelay, - Constraints? constraints, - }) async { - registered = true; - this.frequency = frequency; - policy = existingWorkPolicy; - this.constraints = constraints; - this.initialDelay = initialDelay; - } - - @override - Future cancelAll() async {} -} - -class _FakeNotificationClient extends NotificationClient { - _FakeNotificationClient(); - - int calls = 0; - MeshMessage? lastMessage; - String? lastDomain; - String? lastShortName; - String? lastLongName; - - @override - Future initialize() async {} - - @override - Future showNewMessage({ - required MeshMessage message, - required String domain, - String? senderShortName, - String? senderLongName, - }) async { - calls += 1; - lastMessage = message; - lastDomain = domain; - lastShortName = senderShortName; - lastLongName = senderLongName; - } -} - -class _FakeRepository extends MeshRepository { - _FakeRepository({ - required this.domain, - required this.messages, - required this.unseen, - NotificationSender? sender, - }) : sender = sender ?? - const NotificationSender(shortName: 'MOCK', longName: 'Mocky'), - super(); - - final String domain; - final List messages; - final List unseen; - final NotificationSender sender; - int loadMessagesCalls = 0; - - @override - Future loadSelectedDomainOrDefault( - {String fallback = 'potatomesh.net'}) async { - return domain; - } - - @override - Future> loadMessages({required String domain}) async { - loadMessagesCalls += 1; - return messages; - } - - @override - Future> detectUnseenMessages({ - required String domain, - required List messages, - }) async { - return unseen; - } - - @override - NotificationSender resolveNotificationSender({ - required String domain, - required MeshMessage message, - }) { - return sender; - } -} - -MeshMessage _buildMessage(int id, String text) { - final rx = DateTime.utc(2024, 1, 1, 12, id); - return MeshMessage( - id: id, - rxTime: rx, - rxIso: rx.toIso8601String(), - fromId: '!tester$id', - nodeId: '!tester$id', - toId: '^', - channel: 1, - channelName: 'Main', - portnum: 'TEXT', - text: text, - rssi: -50, - snr: 1.0, - hopLimit: 1, - ); -} - -void main() { - setUp(() { - BackgroundSyncManager.resetForTest(); - }); - - test('registers and schedules background task', () async { - final fakeWork = _FakeWorkmanagerAdapter(); - final fakeRepo = _FakeRepository( - domain: 'potatomesh.net', - messages: [_buildMessage(1, 'hello')], - unseen: [_buildMessage(2, 'new')], - sender: const NotificationSender( - shortName: 'TEST', - longName: 'Test Sender', - ), - ); - final notifier = _FakeNotificationClient(); - - final manager = BackgroundSyncManager( - workmanager: fakeWork, - dependencies: BackgroundDependencies( - repositoryBuilder: () async => fakeRepo, - notificationBuilder: () async => notifier, - ), - ); - - await manager.initialize(); - await manager.ensurePeriodicTask(); - - expect(fakeWork.initialized, isTrue); - expect(fakeWork.registered, isTrue); - expect(fakeWork.policy, ExistingPeriodicWorkPolicy.keep); - expect(fakeWork.frequency, const Duration(minutes: 15)); - expect(fakeWork.constraints?.networkType, NetworkType.connected); - expect(fakeWork.initialDelay, const Duration(minutes: 1)); - - final handled = - await BackgroundSyncManager.handleBackgroundTask('task', {}); - - expect(handled, isTrue); - expect(fakeRepo.loadMessagesCalls, 1); - expect(notifier.calls, 1); - expect(notifier.lastDomain, 'potatomesh.net'); - expect(notifier.lastShortName, 'TEST'); - expect(notifier.lastLongName, 'Test Sender'); - }); - - test('returns true and no-ops when dependencies are missing', () async { - final handled = - await BackgroundSyncManager.handleBackgroundTask('task', {}); - expect(handled, isTrue); - }); -} diff --git a/app/test/cache_test.dart b/app/test/cache_test.dart deleted file mode 100644 index 92c8bbe8..00000000 --- a/app/test/cache_test.dart +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright © 2025-26 l5yth & contributors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; -import 'package:potato_mesh_reader/main.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - setUp(() { - SharedPreferences.setMockInitialValues({}); - NodeShortNameCache.instance.clear(); - }); - - test('NodeShortNameCache fetches and memoizes short names', () async { - var calls = 0; - final client = MockClient((request) async { - calls += 1; - expect(request.url.path, '/api/nodes/!cache-test'); - return http.Response('{"short_name":"NODE"}', 200); - }); - - final first = await NodeShortNameCache.instance.shortNameFor( - domain: 'cache.test', - nodeId: '!cache-test', - client: client, - ); - final second = await NodeShortNameCache.instance.shortNameFor( - domain: 'cache.test', - nodeId: '!cache-test', - client: client, - ); - - expect(first, 'NODE'); - expect(second, 'NODE'); - expect(calls, 1, reason: 'memoises results per domain/id'); - }); - - test('NodeShortNameCache falls back to padded suffix', () { - expect(NodeShortNameCache.fallbackShortName('!ab'), ' ab'); - expect(NodeShortNameCache.fallbackShortName('!abcdef'), 'cdef'); - expect(NodeShortNameCache.fallbackShortName(''), '????'); - }); - - test('InstanceVersionCache fetches and caches version payloads', () async { - var calls = 0; - final client = MockClient((request) async { - calls += 1; - expect(request.url.path, '/version'); - return http.Response( - '{"name":"BerlinMesh","config":{"channel":"#MediumFast","frequency":"868MHz","instanceDomain":"potatomesh.net"}}', - 200, - ); - }); - - final first = await InstanceVersionCache.instance - .fetch(domain: 'version.test', client: client); - final second = await InstanceVersionCache.instance - .fetch(domain: 'version.test', client: client); - - expect(first?.summary, contains('BerlinMesh')); - expect(first?.summary, contains('#MediumFast')); - expect(calls, 1, reason: 'cache should avoid duplicate network calls'); - expect(second?.summary, first?.summary); - }); -} diff --git a/app/test/main_utilities_test.dart b/app/test/main_utilities_test.dart deleted file mode 100644 index 827c5018..00000000 --- a/app/test/main_utilities_test.dart +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright © 2025-26 l5yth & contributors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:potato_mesh_reader/main.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - test('BootstrapProgress renders stage, counts, and detail', () { - const progress = BootstrapProgress( - stage: 'Downloading', - current: 2, - total: 5, - detail: 'instances', - ); - - expect(progress.label, 'Downloading 2/5 • instances'); - - const fallback = BootstrapProgress(stage: 'Starting'); - expect(fallback.label, 'Starting'); - }); - - test('InstanceVersion summary prefers populated fields', () { - const populated = InstanceVersion( - name: 'BerlinMesh', - channel: '#MediumFast', - frequency: '868MHz', - instanceDomain: 'potatomesh.net', - ); - expect(populated.summary, 'BerlinMesh · #MediumFast · 868MHz'); - - const minimal = InstanceVersion( - name: '', - channel: null, - frequency: null, - instanceDomain: null, - ); - expect(minimal.summary, 'Unknown'); - }); - - test('sortMessagesByRxTime keeps unknown timestamps in place', () { - MeshMessage buildMessage({ - required int id, - required String text, - required String rxIso, - DateTime? rxTime, - }) { - return MeshMessage( - id: id, - rxTime: rxTime, - rxIso: rxIso, - fromId: '!$id', - nodeId: '!$id', - toId: '^', - channelName: '#general', - channel: 1, - portnum: 'TEXT', - text: text, - rssi: -50, - snr: 1.0, - hopLimit: 1, - ); - } - - final withTime = buildMessage( - id: 2, - rxTime: DateTime.utc(2024, 1, 1, 12, 1), - rxIso: '2024-01-01T12:01:00Z', - text: 'timed', - ); - final withoutTime = buildMessage( - id: 1, - rxTime: null, - rxIso: 'unknown', - text: 'unknown', - ); - final laterTime = buildMessage( - id: 3, - rxTime: DateTime.utc(2024, 1, 1, 12, 5), - rxIso: '2024-01-01T12:05:00Z', - text: 'later', - ); - - final sorted = sortMessagesByRxTime([withoutTime, laterTime, withTime]); - - expect(sorted.first.id, withoutTime.id, - reason: 'messages without rxTime should retain position'); - expect(sorted[1].id, withTime.id, - reason: 'messages with timestamps should be ordered chronologically'); - expect(sorted.last.id, laterTime.id); - }); - - testWidgets('LoadingScreen displays progress label and icon', (tester) async { - const screen = LoadingScreen( - progress: BootstrapProgress(stage: 'Fetching'), - ); - - await tester.pumpWidget(const MaterialApp(home: screen)); - - expect(find.byType(CircularProgressIndicator), findsOneWidget); - expect(find.text('Fetching'), findsOneWidget); - expect(find.bySemanticsLabel('PotatoMesh'), findsOneWidget); - }); - - testWidgets('LoadingScreen surfaces errors', (tester) async { - const screen = LoadingScreen( - progress: BootstrapProgress(stage: 'Loading'), - error: 'boom', - ); - - await tester.pumpWidget(const MaterialApp(home: screen)); - - expect(find.textContaining('Failed to load: boom'), findsOneWidget); - }); -} diff --git a/app/test/mesh_message_test.dart b/app/test/mesh_message_test.dart deleted file mode 100644 index 20acaf41..00000000 --- a/app/test/mesh_message_test.dart +++ /dev/null @@ -1,264 +0,0 @@ -// Copyright © 2025-26 l5yth & contributors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; -import 'package:potato_mesh_reader/main.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -/// Unit tests for [MeshMessage] parsing and the sorting helper. -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - setUp(() { - SharedPreferences.setMockInitialValues({}); - NodeShortNameCache.instance.clear(); - }); - - group('MeshMessage.fromJson', () { - test('parses fields and strips leading bang from sender', () { - final msg = MeshMessage.fromJson({ - 'id': '7', - 'rx_iso': '2024-01-02T03:04:00Z', - 'from_id': '!NICK', - 'to_id': '^', - 'channel': '1', - 'channel_name': 'BerlinMesh', - 'portnum': 'TEXT', - 'text': 'Hello world', - 'rssi': '-90', - 'snr': '5.25', - 'hop_limit': '3', - }); - - expect(msg.id, 7); - expect(msg.rxIso, '2024-01-02T03:04:00Z'); - expect(msg.rxTime!.toUtc().hour, 3); - expect(msg.fromShort, 'NICK'); - expect(msg.channelName, 'BerlinMesh'); - expect(msg.text, 'Hello world'); - expect(msg.rssi, -90); - expect(msg.snr, closeTo(5.25, 0.0001)); - expect(msg.hopLimit, 3); - }); - - test('handles invalid timestamps and non-numeric fields', () { - final msg = MeshMessage.fromJson({ - 'id': null, - 'rx_iso': 'not-a-date', - 'from_id': '', - 'to_id': '', - 'channel': 'abc', - 'portnum': 'TEXT', - 'text': '', - 'rssi': 'missing', - 'snr': 'noise', - 'hop_limit': null, - }); - - expect(msg.id, 0); - expect(msg.rxTime, isNull); - expect(msg.timeFormatted, '--:--'); - expect(msg.fromShort, '?'); - expect(msg.channel, isNull); - expect(msg.rssi, isNull); - expect(msg.snr, isNull); - expect(msg.hopLimit, isNull); - expect(msg.text, ''); - }); - }); - - group('sortMessagesByRxTime', () { - test('orders messages oldest to newest even with null timestamps', () { - final older = MeshMessage( - id: 1, - rxTime: DateTime.utc(2023, 12, 31, 23, 59), - rxIso: '2023-12-31T23:59:00Z', - fromId: 'A', - toId: 'B', - channel: 1, - channelName: 'Main', - portnum: 'TEXT', - text: 'Old', - rssi: -50, - snr: 1.0, - hopLimit: 1, - ); - final unknownTime = MeshMessage( - id: 2, - rxTime: null, - rxIso: '', - fromId: 'B', - toId: 'A', - channel: 1, - channelName: 'Main', - portnum: 'TEXT', - text: 'Unknown', - rssi: -55, - snr: 1.5, - hopLimit: 1, - ); - final newer = MeshMessage( - id: 3, - rxTime: DateTime.utc(2024, 01, 01, 0, 10), - rxIso: '2024-01-01T00:10:00Z', - fromId: 'C', - toId: 'D', - channel: 1, - channelName: 'Main', - portnum: 'TEXT', - text: 'New', - rssi: -60, - snr: 2.0, - hopLimit: 1, - ); - - final sorted = sortMessagesByRxTime([newer, unknownTime, older]); - - expect(sorted.first.id, older.id); - expect(sorted.last.id, newer.id); - expect(sorted[1].id, unknownTime.id); - }); - }); - - group('fetchMessages', () { - test('parses, sorts, and returns API messages', () async { - final calls = []; - final client = MockClient((request) async { - calls.add(request.url); - return http.Response( - jsonEncode([ - { - 'id': 2, - 'rx_iso': '2024-01-02T00:01:00Z', - 'from_id': '!b', - 'to_id': '^', - 'channel': 1, - 'portnum': 'TEXT', - 'text': 'Later' - }, - { - 'id': 1, - 'rx_iso': '2024-01-01T23:59:00Z', - 'from_id': '!a', - 'to_id': '^', - 'channel': 1, - 'portnum': 'TEXT', - 'text': 'Earlier' - }, - ]), - 200, - ); - }); - - final messages = await fetchMessages(client: client); - - expect(calls.single.queryParameters['limit'], '1000'); - expect(calls.single.queryParameters['since'], '0'); - expect(messages.first.id, 1); - expect(messages.last.id, 2); - expect(messages.first.fromShort, 'a'); - }); - - test('throws on non-200 responses', () async { - final client = MockClient((request) async => http.Response('nope', 500)); - - expect( - () => fetchMessages(client: client), - throwsA(isA()), - ); - }); - - test('throws on unexpected response shapes', () async { - final client = - MockClient((request) async => http.Response('{"id":1}', 200)); - - expect( - () => fetchMessages(client: client), - throwsA(isA()), - ); - }); - - test('uses custom domains including full URLs', () async { - final calls = []; - final client = MockClient((request) async { - calls.add(request.url); - return http.Response(jsonEncode([]), 200); - }); - - await fetchMessages(client: client, domain: 'mesh.example.org'); - await fetchMessages( - client: client, domain: 'https://mesh.alt.example/api'); - - expect(calls[0].host, 'mesh.example.org'); - expect(calls[0].path, '/api/messages'); - expect(calls[0].queryParameters['protocol'], 'meshtastic'); - expect(calls[1].scheme, 'https'); - expect(calls[1].path, '/api/messages'); - expect(calls[1].queryParameters['protocol'], 'meshtastic'); - }); - - test('README names only the protocol the client requests', () async { - final calls = []; - final client = MockClient((request) async { - calls.add(request.url); - return http.Response(jsonEncode([]), 200); - }); - - await fetchMessages(client: client, domain: 'mesh.example.org'); - - final requested = calls.single.queryParameters['protocol']!; - final supports = File('README.md') - .readAsLinesSync() - .firstWhere((line) => line.startsWith('Supports ')) - .toLowerCase(); - expect(supports, contains(requested)); - for (final other in ['meshcore', 'reticulum']) { - expect(supports, isNot(contains(other))); - } - }); - }); - - group('fetchInstances', () { - test('parses and sorts instance list', () async { - final client = MockClient((request) async { - return http.Response( - jsonEncode([ - {'name': 'Bravo', 'domain': 'bravo.example'}, - {'name': 'Alpha', 'domain': 'alpha.example'}, - {'name': '', 'domain': ''}, - ]), - 200, - ); - }); - - final instances = await fetchInstances(client: client); - - expect(instances.map((i) => i.displayName), ['Alpha', 'Bravo']); - expect( - instances.map((i) => i.domain), ['alpha.example', 'bravo.example']); - }); - - test('returns empty list on failed fetch', () async { - final client = MockClient((request) async => http.Response('oops', 500)); - - final instances = await fetchInstances(client: client); - expect(instances, isEmpty); - }); - }); -} diff --git a/app/test/mesh_repository_test.dart b/app/test/mesh_repository_test.dart deleted file mode 100644 index 14a3f168..00000000 --- a/app/test/mesh_repository_test.dart +++ /dev/null @@ -1,388 +0,0 @@ -// Copyright © 2025-26 l5yth & contributors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'dart:convert'; -import 'dart:math'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; -import 'package:potato_mesh_reader/main.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -void main() { - setUp(() { - SharedPreferences.setMockInitialValues({}); - NodeShortNameCache.instance.clear(); - }); - - test('bootstrap filters private and inactive instances', () async { - final nowSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; - final client = MockClient((request) async { - if (request.url.path == '/api/instances' && - request.url.host == 'potatomesh.net') { - return http.Response( - jsonEncode([ - {'name': 'Public Mesh', 'domain': 'public.mesh'}, - { - 'name': 'Hidden Mesh', - 'domain': 'private.mesh', - 'isPrivate': true - }, - {'name': 'Stale Mesh', 'domain': 'stale.mesh'}, - ]), - 200, - ); - } - if (request.url.path == '/api/instances' && - request.url.host == 'public.mesh') { - return http.Response('[]', 200); - } - if (request.url.path == '/api/instances' && - request.url.host == 'stale.mesh') { - return http.Response('[]', 200); - } - if (request.url.path == '/api/nodes' && - request.url.host == 'public.mesh') { - final nodes = List.generate(12, (i) { - return { - 'node_id': '!pub$i', - 'short_name': 'P$i', - 'last_heard': nowSeconds, - }; - }); - return http.Response(jsonEncode(nodes), 200); - } - if (request.url.path == '/api/nodes' && - request.url.host == 'stale.mesh') { - final stale = nowSeconds - (60 * 60 * 48); - return http.Response( - jsonEncode([ - {'node_id': '!old', 'short_name': 'OLD', 'last_heard': stale}, - {'node_id': '!older', 'short_name': 'OLD2', 'last_heard': stale}, - ]), - 200, - ); - } - if (request.url.path == '/api/messages') { - return http.Response( - jsonEncode([ - { - 'id': 1, - 'rx_iso': '2024-01-01T00:00:00Z', - 'from_id': '!pub1', - 'to_id': '^', - 'channel': 1, - 'portnum': 'TEXT', - 'text': 'hello' - }, - { - 'id': 2, - 'rx_iso': '2024-01-01T00:10:00Z', - 'from_id': '!pub2', - 'to_id': '^', - 'channel': 1, - 'portnum': 'TEXT', - 'text': 'world' - }, - ]), - 200, - ); - } - if (request.url.path.startsWith('/api/nodes/')) { - return http.Response( - jsonEncode({ - 'node_id': '!fallback', - 'short_name': 'FLBK', - 'last_heard': nowSeconds - }), - 200, - ); - } - return http.Response('[]', 200); - }); - - final repository = MeshRepository(client: client, random: Random(1)); - final result = await repository.bootstrap(initialDomain: 'public.mesh'); - - expect(result.instances.map((i) => i.domain), ['public.mesh']); - expect(result.selectedDomain, 'public.mesh'); - expect(result.nodes.length, 12); - expect(result.messages.length, 2); - }); - - test('loadMessages performs incremental refresh after initial sync', - () async { - final nowSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; - final sinces = []; - final client = MockClient((request) async { - if (request.url.path == '/api/nodes') { - return http.Response( - jsonEncode([ - {'node_id': '!a', 'short_name': 'A', 'last_heard': nowSeconds}, - ]), - 200, - ); - } - if (request.url.path.startsWith('/api/nodes/')) { - return http.Response( - jsonEncode( - {'node_id': '!a', 'short_name': 'A', 'last_heard': nowSeconds}), - 200, - ); - } - if (request.url.path == '/api/messages') { - sinces.add(request.url.queryParameters['since'] ?? ''); - expect(request.url.queryParameters['limit'], '1000'); - expect(request.url.queryParameters['protocol'], 'meshtastic'); - if (sinces.length == 1) { - return http.Response( - jsonEncode([ - { - 'id': 1, - 'rx_iso': '2024-01-01T00:00:00Z', - 'from_id': '!a', - 'to_id': '^', - 'channel': 1, - 'portnum': 'TEXT', - 'text': 'first' - } - ]), - 200, - ); - } - return http.Response( - jsonEncode([ - { - 'id': 2, - 'rx_iso': '2024-01-01T00:10:00Z', - 'from_id': '!a', - 'to_id': '^', - 'channel': 1, - 'portnum': 'TEXT', - 'text': 'new' - } - ]), - 200, - ); - } - return http.Response('[]', 200); - }); - - final repository = MeshRepository(client: client); - final domainResult = await repository.loadDomainData( - domain: 'potatomesh.net', - forceFull: true, - ); - expect(domainResult.messages.length, 1); - - final refreshed = await repository.loadMessages(domain: 'potatomesh.net'); - - final expectedSince = - DateTime.parse('2024-01-01T00:00:00Z').toUtc().millisecondsSinceEpoch ~/ - 1000; - expect(sinces.first, '0'); - expect(sinces.last, expectedSince.toString()); - expect(refreshed.length, 2); - expect(refreshed.last.text, 'new'); - }); - - test('bootstrap falls back to next responsive instance when first fails', - () async { - final nowSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; - final client = MockClient((request) async { - if (request.url.host == 'potatomesh.net' && - request.url.path == '/api/instances') { - return http.Response( - jsonEncode([ - {'name': 'Broken', 'domain': 'broken.mesh'}, - {'name': 'Healthy', 'domain': 'healthy.mesh'}, - ]), - 200, - ); - } - if (request.url.host == 'broken.mesh' && - request.url.path == '/api/nodes') { - return http.Response('Not found', 404); - } - if (request.url.host == 'healthy.mesh' && - request.url.path == '/api/nodes') { - final nodes = List.generate(10, (i) { - return { - 'node_id': '!ok$i', - 'short_name': 'OK$i', - 'last_heard': nowSeconds, - }; - }); - return http.Response(jsonEncode(nodes), 200); - } - if (request.url.host == 'healthy.mesh' && - request.url.path == '/api/messages') { - return http.Response( - jsonEncode([ - { - 'id': 10, - 'rx_iso': '2024-01-01T00:00:00Z', - 'from_id': '!ok1', - 'to_id': '^', - 'channel': 1, - 'portnum': 'TEXT', - 'text': 'hi' - } - ]), - 200, - ); - } - if (request.url.path.startsWith('/api/nodes/')) { - return http.Response( - jsonEncode({ - 'node_id': '!ok1', - 'short_name': 'OK1', - 'last_heard': nowSeconds - }), - 200, - ); - } - return http.Response('[]', 200); - }); - - final repository = MeshRepository(client: client); - final result = await repository.bootstrap(initialDomain: 'broken.mesh'); - - expect(result.selectedDomain, 'healthy.mesh'); - expect(result.messages.single.text, 'hi'); - }); - - test('bootstrap skips instance discovery when cache is populated', () async { - final nowSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; - final cachedInstances = jsonEncode([ - { - 'id': '1', - 'name': 'Cached Mesh', - 'domain': 'cached.mesh', - 'isPrivate': false, - 'lastUpdateTime': nowSeconds, - } - ]); - SharedPreferences.setMockInitialValues({ - 'mesh.instances': cachedInstances, - 'mesh.selectedDomain': 'cached.mesh', - }); - - var instancesCalls = 0; - final client = MockClient((request) async { - if (request.url.path == '/api/instances') { - instancesCalls += 1; - return http.Response('[]', 200); - } - if (request.url.path == '/api/nodes') { - final nodes = List.generate(10, (i) { - return { - 'node_id': '!cached$i', - 'short_name': 'C$i', - 'last_heard': nowSeconds, - }; - }); - return http.Response(jsonEncode(nodes), 200); - } - if (request.url.path == '/api/messages') { - return http.Response( - jsonEncode([ - { - 'id': 20, - 'rx_iso': '2024-01-01T00:00:00Z', - 'from_id': '!cached1', - 'to_id': '^', - 'channel': 1, - 'portnum': 'TEXT', - 'text': 'cached' - } - ]), - 200, - ); - } - if (request.url.path.startsWith('/api/nodes/')) { - return http.Response( - jsonEncode({ - 'node_id': '!cached1', - 'short_name': 'C1', - 'last_heard': nowSeconds - }), - 200, - ); - } - return http.Response('[]', 200); - }); - - final repository = MeshRepository(client: client); - final result = await repository.bootstrap(initialDomain: 'cached.mesh'); - - expect(instancesCalls, 0, reason: 'should not refetch instance list'); - expect(result.selectedDomain, 'cached.mesh'); - expect(result.messages.single.text, 'cached'); - }); - - test('loadMessages prefers cached nodes over remote lookups', () async { - final savedNodes = jsonEncode([ - {'node_id': '!a', 'short_name': 'A', 'last_heard': 0} - ]); - SharedPreferences.setMockInitialValues({ - 'mesh.nodes.potatomesh.net': savedNodes, - }); - - var nodeDetailHits = 0; - final client = MockClient((request) async { - if (request.url.path == '/api/messages') { - return http.Response( - jsonEncode([ - { - 'id': 1, - 'rx_iso': '2024-01-01T00:00:00Z', - 'from_id': '!a', - 'to_id': '^', - 'channel': 1, - 'portnum': 'TEXT', - 'text': 'cached node' - } - ]), - 200, - ); - } - if (request.url.path.startsWith('/api/nodes/')) { - nodeDetailHits += 1; - return http.Response( - jsonEncode({'node_id': '!a', 'short_name': 'A', 'last_heard': 0}), - 200, - ); - } - return http.Response('[]', 200); - }); - - final repository = MeshRepository(client: client); - final messages = await repository.loadMessages(domain: 'potatomesh.net'); - - expect(messages.single.text, 'cached node'); - expect(nodeDetailHits, 0); - }); - - test('rememberSelectedDomain persists normalized choice', () async { - final repo = MeshRepository(); - await repo.rememberSelectedDomain('HTTP://Example.Mesh/'); - expect(repo.selectedDomain, 'example.mesh'); - - final prefs = await SharedPreferences.getInstance(); - final store = MeshLocalStore(prefs); - expect(store.loadSelectedDomain(), 'example.mesh'); - }); -} diff --git a/app/test/message_seen_tracker_test.dart b/app/test/message_seen_tracker_test.dart deleted file mode 100644 index f2c44c1b..00000000 --- a/app/test/message_seen_tracker_test.dart +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright © 2025-26 l5yth & contributors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'package:flutter_test/flutter_test.dart'; -import 'package:potato_mesh_reader/main.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -MeshMessage _buildMessage({ - required int id, - required int minute, - String text = 'msg', -}) { - final rxTime = DateTime.utc(2024, 1, 1, 12, minute); - return MeshMessage( - id: id, - rxTime: rxTime, - rxIso: rxTime.toIso8601String(), - fromId: '!sender$id', - nodeId: '!sender$id', - toId: '^', - channel: 1, - channelName: 'Main', - portnum: 'TEXT', - text: text, - rssi: -40, - snr: 1.2, - hopLimit: 1, - ); -} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - setUp(() { - SharedPreferences.setMockInitialValues({}); - }); - - test('marks latest message seen when no prior marker exists', () async { - final prefs = await SharedPreferences.getInstance(); - final store = MeshLocalStore(prefs); - final tracker = MessageSeenTracker(store); - final messages = [ - _buildMessage(id: 1, minute: 1), - _buildMessage(id: 2, minute: 2), - ]; - - final unseen = - await tracker.unseenSince(domain: 'potatomesh.net', messages: messages); - - expect(unseen, isEmpty); - expect( - store.loadLastSeenMessageKey('potatomesh.net'), - MessageSeenTracker.messageKey(messages.last), - ); - }); - - test('returns messages that arrive after last seen marker', () async { - final prefs = await SharedPreferences.getInstance(); - final store = MeshLocalStore(prefs); - final tracker = MessageSeenTracker(store); - final first = _buildMessage(id: 1, minute: 1, text: 'old'); - final second = _buildMessage(id: 2, minute: 2, text: 'new'); - final third = _buildMessage(id: 3, minute: 3, text: 'newer'); - - await store.saveLastSeenMessageKey( - 'potatomesh.net', - MessageSeenTracker.messageKey(first), - ); - - final unseen = await tracker.unseenSince( - domain: 'potatomesh.net', - messages: [first, second, third], - ); - - expect(unseen.length, 2); - expect(unseen.first.text, 'new'); - expect(unseen.last.text, 'newer'); - expect( - store.loadLastSeenMessageKey('potatomesh.net'), - MessageSeenTracker.messageKey(third), - ); - }); - - test('ignores notifications if last seen marker is missing from payload', - () async { - final prefs = await SharedPreferences.getInstance(); - final store = MeshLocalStore(prefs); - final tracker = MessageSeenTracker(store); - final messages = [ - _buildMessage(id: 10, minute: 1), - _buildMessage(id: 11, minute: 2), - ]; - - await store.saveLastSeenMessageKey( - 'potatomesh.net', - 'nonexistent-key', - ); - - final unseen = - await tracker.unseenSince(domain: 'potatomesh.net', messages: messages); - - expect(unseen, isEmpty); - expect( - store.loadLastSeenMessageKey('potatomesh.net'), - MessageSeenTracker.messageKey(messages.last), - ); - }); -} diff --git a/app/test/messages_screen_test.dart b/app/test/messages_screen_test.dart deleted file mode 100644 index 42eea936..00000000 --- a/app/test/messages_screen_test.dart +++ /dev/null @@ -1,322 +0,0 @@ -// Copyright © 2025-26 l5yth & contributors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; -import 'package:potato_mesh_reader/main.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -/// Widget-level tests that exercise UI states and rendering branches. -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - setUp(() { - SharedPreferences.setMockInitialValues({}); - NodeShortNameCache.instance.allowRemoteLookups = false; - NodeShortNameCache.instance.clear(); - }); - - testWidgets('PotatoMeshReaderApp wires theming and home screen', - (tester) async { - final fetchCalls = []; - Future> fakeFetch({ - http.Client? client, - String domain = 'potatomesh.net', - }) async { - fetchCalls.add(1); - return [ - MeshMessage( - id: 1, - rxTime: DateTime.utc(2024, 1, 1, 12, 0), - rxIso: '2024-01-01T12:00:00Z', - fromId: '!tester', - toId: '^', - channel: 1, - channelName: 'Main', - portnum: 'TEXT', - text: 'Hello', - rssi: -50, - snr: 2.2, - hopLimit: 1, - ), - ]; - } - - Future bootstrapper({ProgressCallback? onProgress}) async { - onProgress?.call(const BootstrapProgress(stage: 'loading instances')); - return BootstrapResult( - instances: const [], - nodes: const [], - messages: await fakeFetch(domain: 'potatomesh.net'), - selectedDomain: 'potatomesh.net', - ); - } - - await tester.pumpWidget(PotatoMeshReaderApp( - fetcher: fakeFetch, - bootstrapper: bootstrapper, - enableAutoRefresh: false, - )); - await tester.pumpAndSettle(); - - expect(find.textContaining('PotatoMesh Reader'), findsOneWidget); - expect(find.byType(MessagesScreen), findsOneWidget); - expect(fetchCalls.length, greaterThanOrEqualTo(1)); - }); - - testWidgets('MessagesScreen shows loading, data, refresh, and empty states', - (tester) async { - var fetchCount = 0; - final completer = Completer>(); - Future> fetcher() { - fetchCount += 1; - if (fetchCount == 1) { - return completer.future; - } - return Future.value([ - MeshMessage( - id: fetchCount, - rxTime: DateTime.utc(2024, 1, 1, 10, fetchCount), - rxIso: '2024-01-01T10:00:00Z', - fromId: '!a', - toId: '^', - channel: 1, - channelName: 'General', - portnum: 'TEXT', - text: 'Message $fetchCount', - rssi: -40, - snr: 1.1, - hopLimit: 1, - ), - ]); - } - - await tester.pumpWidget( - MaterialApp( - home: MessagesScreen( - fetcher: fetcher, - domain: 'potatomesh.net', - enableAutoRefresh: false, - ), - ), - ); - - expect(find.byType(CircularProgressIndicator), findsOneWidget); - - completer.complete([ - MeshMessage( - id: 1, - rxTime: DateTime.utc(2024, 1, 1, 9, 0), - rxIso: '2024-01-01T09:00:00Z', - fromId: '!nick', - toId: '^', - channel: 1, - channelName: 'General', - portnum: 'TEXT', - text: 'Loaded', - rssi: -42, - snr: 1.5, - hopLimit: 1, - ), - ]); - - await tester.pumpAndSettle(); - - expect(fetchCount, greaterThanOrEqualTo(2)); - - await tester.tap(find.byIcon(Icons.refresh)); - await tester.pump(); - await tester.pumpAndSettle(); - - expect(fetchCount, greaterThanOrEqualTo(3)); - expect(find.textContaining('Message'), findsWidgets); - - await tester.pumpWidget( - MaterialApp( - home: MessagesScreen( - fetcher: () async => [], - domain: 'potatomesh.net', - enableAutoRefresh: false, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('No messages yet.'), findsOneWidget); - }); - - testWidgets('Settings button navigates to SettingsScreen', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: MessagesScreen( - fetcher: () async => [], - domain: 'potatomesh.net', - enableAutoRefresh: false, - onOpenSettings: (context) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => SettingsScreen( - currentDomain: 'potatomesh.net', - onDomainChanged: (_) {}, - loadInstances: ({bool refresh = false}) async => const [], - ), - ), - ); - }, - resetToken: 0, - ), - ), - ); - - await tester.tap(find.byIcon(Icons.settings)); - await tester.pumpAndSettle(); - - expect(find.text('Settings'), findsOneWidget); - expect(find.textContaining('PotatoMesh Reader'), findsOneWidget); - }); - - // Stale fetch completions are ignored by versioned fetch guard; covered - // indirectly by other tests that rely on append ordering. - - testWidgets('changing endpoint triggers a refresh with new domain', - (tester) async { - final calls = []; - Future> fetcher({ - http.Client? client, - String domain = 'potatomesh.net', - }) async { - calls.add(domain); - return [ - MeshMessage( - id: 1, - rxTime: null, - rxIso: '2024-01-01T00:00:00Z', - fromId: '!a', - toId: '^', - channel: 1, - channelName: 'Main', - portnum: 'TEXT', - text: domain, - rssi: null, - snr: null, - hopLimit: null, - ) - ]; - } - - Future> loader({bool refresh = false}) async => const [ - MeshInstance(name: 'Mesh Berlin', domain: 'berlin.mesh'), - ]; - - final mockClient = MockClient((request) async { - if (request.url.path.contains('/api/nodes')) { - return http.Response('[]', 200); - } - if (request.url.path.contains('/api/messages')) { - return http.Response('[]', 200); - } - if (request.url.path.contains('/api/instances')) { - return http.Response( - '[{"name":"Mesh Berlin","domain":"berlin.mesh"}]', 200); - } - return http.Response('[]', 200); - }); - - final repository = MeshRepository(client: mockClient); - - Future bootstrapper({ProgressCallback? onProgress}) async { - onProgress?.call(const BootstrapProgress(stage: 'loading instances')); - final initialMessages = await fetcher(domain: 'potatomesh.net'); - final instances = await loader(); - await repository.updateInstances(instances); - return BootstrapResult( - instances: instances, - nodes: const [], - messages: initialMessages, - selectedDomain: 'potatomesh.net', - ); - } - - await tester.pumpWidget( - PotatoMeshReaderApp( - fetcher: fetcher, - instanceFetcher: ({http.Client? client}) => loader(), - bootstrapper: bootstrapper, - repository: repository, - enableAutoRefresh: false, - ), - ); - await tester.pumpAndSettle(); - - expect(calls.first, 'potatomesh.net'); - expect(find.text('potatomesh.net'), findsOneWidget); - - await tester.tap(find.byIcon(Icons.settings)); - await tester.pumpAndSettle(); - await tester.tap(find.byType(DropdownButtonFormField)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Mesh Berlin').last); - await tester.pumpAndSettle(); - await tester.pageBack(); - final messagesFinder = find.byType(MessagesScreen); - for (var i = 0; i < 10 && messagesFinder.evaluate().isEmpty; i++) { - await tester.pump(const Duration(milliseconds: 100)); - } - - expect(messagesFinder, findsOneWidget); - expect(repository.selectedDomain, 'berlin.mesh'); - expect(calls.contains('berlin.mesh'), isTrue); - expect(find.text('berlin.mesh'), findsOneWidget); - expect(find.text('potatomesh.net'), findsNothing); - expect(find.text('🥔 Mesh Berlin'), findsOneWidget); - }); - - testWidgets('ChatLine renders placeholders and nick colour', (tester) async { - final message = MeshMessage( - id: 1, - rxTime: null, - rxIso: '', - fromId: '!ColorNick', - toId: '^', - channel: 1, - channelName: null, - portnum: 'TEXT', - text: '', - rssi: null, - snr: null, - hopLimit: null, - ); - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: ChatLine( - message: message, - domain: 'potatomesh.net', - ), - ), - ), - ); - - final nickText = find.textContaining(''); - final placeholder = find.text('⟂ (no text)'); - expect(nickText, findsOneWidget); - expect(placeholder, findsOneWidget); - expect(find.textContaining('[--:--]'), findsOneWidget); - }); -} diff --git a/app/test/notification_client_test.dart b/app/test/notification_client_test.dart deleted file mode 100644 index 1ceb84d3..00000000 --- a/app/test/notification_client_test.dart +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright © 2025-26 l5yth & contributors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:potato_mesh_reader/main.dart'; - -void main() { - test('uses drawable name for Android init and notifications', () { - final client = LocalNotificationClient(); - - final androidInit = client.buildAndroidInitializationSettings(); - expect(androidInit.defaultIcon, 'ic_mesh_notification'); - - final details = client.notificationDetailsForTest(); - final androidDetails = details.android as AndroidNotificationDetails; - expect(androidDetails.icon, 'ic_mesh_notification'); - expect(androidDetails.category, AndroidNotificationCategory.message); - }); -} diff --git a/app/test/notification_sender_test.dart b/app/test/notification_sender_test.dart deleted file mode 100644 index e57c6a4a..00000000 --- a/app/test/notification_sender_test.dart +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright © 2025-26 l5yth & contributors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'package:flutter_test/flutter_test.dart'; -import 'package:potato_mesh_reader/main.dart'; - -class _StubRepository extends MeshRepository { - _StubRepository(this.node) : super(); - - final MeshNode? node; - - @override - MeshNode? findNode(String domain, String nodeId) { - return node; - } -} - -MeshMessage _buildMessage({ - required int id, - required String nodeId, - String text = 'hello', -}) { - final rx = DateTime.utc(2024, 1, 1, 12, id); - return MeshMessage( - id: id, - rxTime: rx, - rxIso: rx.toIso8601String(), - fromId: nodeId, - nodeId: nodeId, - toId: '^', - channel: 1, - channelName: 'Main', - portnum: 'TEXT', - text: text, - rssi: -50, - snr: 1.0, - hopLimit: 1, - ); -} - -void main() { - setUp(() { - NodeShortNameCache.instance.clear(); - }); - - test('prefers node long name when resolving sender', () { - const node = MeshNode( - nodeId: '!NODE1', - shortName: 'N1', - longName: 'Verbose Node', - ); - final repo = _StubRepository(node); - final sender = repo.resolveNotificationSender( - domain: 'potatomesh.net', - message: _buildMessage(id: 1, nodeId: '!NODE1'), - ); - - expect(sender.longName, 'Verbose Node'); - expect(sender.shortName, 'N1'); - expect(sender.preferredName, 'Verbose Node'); - }); - - test('falls back to short identifier when metadata is missing', () { - final repo = _StubRepository(null); - final sender = repo.resolveNotificationSender( - domain: 'potatomesh.net', - message: _buildMessage(id: 2, nodeId: '!NODE2'), - ); - - expect(sender.longName, isNull); - expect(sender.shortName, 'ODE2'); - expect(sender.preferredName, 'ODE2'); - }); -} diff --git a/app/test/settings_screen_test.dart b/app/test/settings_screen_test.dart deleted file mode 100644 index 263c911c..00000000 --- a/app/test/settings_screen_test.dart +++ /dev/null @@ -1,129 +0,0 @@ -// Copyright © 2025-26 l5yth & contributors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:potato_mesh_reader/main.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -void main() { - setUp(() { - SharedPreferences.setMockInitialValues({}); - }); - - testWidgets('SettingsScreen lists instances and updates selection', - (tester) async { - final selections = []; - Future> loader({bool refresh = false}) async => const [ - MeshInstance(name: 'Mesh Dresden', domain: 'map.meshdresden.eu'), - MeshInstance(name: 'Mesh Berlin', domain: 'berlin.mesh'), - ]; - - await tester.pumpWidget( - MaterialApp( - home: SettingsScreen( - currentDomain: 'potatomesh.net', - onDomainChanged: selections.add, - loadInstances: loader, - ), - ), - ); - - await tester.pumpAndSettle(); - - await tester.tap(find.byType(DropdownButtonFormField)); - await tester.pumpAndSettle(); - expect(find.text('Mesh Dresden'), findsOneWidget); - await tester.tap(find.text('Mesh Berlin').last); - await tester.pumpAndSettle(); - - expect(selections.single, 'berlin.mesh'); - expect(find.textContaining('berlin.mesh'), findsWidgets); - }); - - testWidgets('SettingsScreen surfaces load errors', (tester) async { - Future> loader({bool refresh = false}) => - Future.error(StateError('boom')); - - await tester.pumpWidget( - MaterialApp( - home: SettingsScreen( - currentDomain: 'potatomesh.net', - onDomainChanged: (_) {}, - loadInstances: loader, - ), - ), - ); - - await tester.pumpAndSettle(); - - expect(find.textContaining('Failed to load instances'), findsOneWidget); - }); - - testWidgets('SettingsScreen refresh button refetches instances', - (tester) async { - final refreshCalls = []; - Future> loader({bool refresh = false}) async { - refreshCalls.add(refresh); - return const [ - MeshInstance(name: 'Mesh Berlin', domain: 'berlin.mesh'), - ]; - } - - await tester.pumpWidget( - MaterialApp( - home: SettingsScreen( - currentDomain: 'potatomesh.net', - onDomainChanged: (_) {}, - loadInstances: loader, - ), - ), - ); - - await tester.pumpAndSettle(); - expect(refreshCalls, [false]); - - await tester.tap(find.byIcon(Icons.refresh)); - await tester.pumpAndSettle(); - - expect(refreshCalls, contains(true)); - expect(refreshCalls.length, greaterThanOrEqualTo(2)); - }); - - testWidgets('SettingsScreen toggles theme mode', (tester) async { - final selected = []; - - await tester.pumpWidget( - MaterialApp( - home: SettingsScreen( - currentDomain: 'potatomesh.net', - onDomainChanged: (_) {}, - loadInstances: ({bool refresh = false}) async => const [], - themeMode: ThemeMode.light, - onThemeChanged: selected.add, - ), - ), - ); - - await tester.pumpAndSettle(); - - expect(find.text('Appearance'), findsOneWidget); - await tester.tap(find.byType(DropdownButtonFormField)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Dark').last); - await tester.pumpAndSettle(); - - expect(selected.single, ThemeMode.dark); - }); -} diff --git a/app/test/theme_preference_store_test.dart b/app/test/theme_preference_store_test.dart deleted file mode 100644 index 4cbf0e8a..00000000 --- a/app/test/theme_preference_store_test.dart +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright © 2025-26 l5yth & contributors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:potato_mesh_reader/main.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - setUp(() { - SharedPreferences.setMockInitialValues({}); - }); - - test('ThemePreferenceStore persists and restores theme mode', () async { - final store = ThemePreferenceStore(); - final initial = await store.load(); - expect(initial, ThemeMode.system); - - await store.save(ThemeMode.dark); - final loaded = await store.load(); - - expect(loaded, ThemeMode.dark); - }); -} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart deleted file mode 100644 index 63cbc5be..00000000 --- a/app/test/widget_test.dart +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright © 2025-26 l5yth & contributors -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; -import 'package:shared_preferences/shared_preferences.dart'; - -import 'package:potato_mesh_reader/main.dart'; - -void main() { - setUp(() { - SharedPreferences.setMockInitialValues({}); - NodeShortNameCache.instance.clear(); - }); - - testWidgets('renders messages from fetcher and refreshes list', - (WidgetTester tester) async { - final sampleMessages = [ - MeshMessage( - id: 1, - rxTime: null, - rxIso: '2025-01-01T00:00:00Z', - fromId: '!nodeA', - toId: '^all', - channel: 1, - channelName: 'TEST', - portnum: 'TEXT_MESSAGE_APP', - text: 'hello world', - rssi: -100, - snr: -5.0, - hopLimit: 3, - ), - MeshMessage( - id: 2, - rxTime: null, - rxIso: '2025-01-01T01:00:00Z', - fromId: '!nodeB', - toId: '^all', - channel: 1, - channelName: 'TEST', - portnum: 'TEXT_MESSAGE_APP', - text: 'second message', - rssi: -90, - snr: -4.0, - hopLimit: 3, - ), - ]; - - var fetchCount = 0; - Future> mockFetcher({ - http.Client? client, - String domain = 'potatomesh.net', - }) async { - final idx = fetchCount >= sampleMessages.length - ? sampleMessages.length - 1 - : fetchCount; - fetchCount += 1; - return [sampleMessages[idx]]; - } - - Future bootstrapper({ProgressCallback? onProgress}) async { - onProgress?.call(const BootstrapProgress(stage: 'loading instances')); - return BootstrapResult( - instances: const [], - nodes: const [], - messages: sampleMessages, - selectedDomain: 'potatomesh.net', - ); - } - - await tester.pumpWidget( - PotatoMeshReaderApp( - fetcher: mockFetcher, - bootstrapper: bootstrapper, - ), - ); - await tester.pumpAndSettle(); - - expect(find.textContaining('PotatoMesh Reader'), findsOneWidget); - expect(find.textContaining('[--:--]'), findsWidgets); - expect(find.byType(ChatLine), findsNWidgets(2)); - expect(find.textContaining('hello world'), findsOneWidget); - expect(find.textContaining('#TEST'), findsWidgets); - expect(find.textContaining(''), findsOneWidget); - expect(find.textContaining('second message'), findsOneWidget); - }); -} diff --git a/scripts/check-license-headers.sh b/scripts/check-license-headers.sh index 9e837ec3..d0dbf8fc 100755 --- a/scripts/check-license-headers.sh +++ b/scripts/check-license-headers.sh @@ -9,9 +9,9 @@ set -euo pipefail NOTICE='Copyright © 2025-26 l5yth & contributors' missing=$(git ls-files \ - '*.rb' '*.py' '*.js' '*.rs' '*.dart' \ + '*.rb' '*.py' '*.js' '*.rs' \ '*.yml' '*.yaml' '*.toml' '*.sh' '*.nix' 'Dockerfile' '*/Dockerfile' \ - | grep -vE '(^|/)(vendor|node_modules|build|\.dart_tool)/' \ + | grep -vE '(^|/)(vendor|node_modules|build)/' \ | xargs grep -L "$NOTICE" || true) if [ -n "$missing" ]; then diff --git a/tests/test_dependency_manifests.py b/tests/test_dependency_manifests.py index 535db342..cede57ba 100644 --- a/tests/test_dependency_manifests.py +++ b/tests/test_dependency_manifests.py @@ -95,17 +95,14 @@ DOCUMENTED_ECOSYSTEMS = frozenset( # Language dependency manifests (file-name glob, Dependabot ecosystem) that # need an entry for their directory. Lockfiles sit beside their manifests, so -# the manifests alone decide the directories. Container base images and the -# Flutter platform build files (Gradle, CocoaPods) are deliberately absent: -# the image pins are bounded on purpose (ACCEPTANCE DK-A2) and the platform -# files belong to the Flutter toolchain. +# the manifests alone decide the directories. Container base images are +# deliberately absent: the image pins are bounded on purpose (ACCEPTANCE DK-A2). MANIFEST_ECOSYSTEMS = ( ("Gemfile", "bundler"), ("requirements*.txt", "pip"), ("pyproject.toml", "pip"), ("package.json", "npm"), ("Cargo.toml", "cargo"), - ("pubspec.yaml", "pub"), ) # Developer tools that belong in requirements-dev.txt, never in the image. diff --git a/tests/test_version_sync.py b/tests/test_version_sync.py index 56dd4669..e4daba1b 100644 --- a/tests/test_version_sync.py +++ b/tests/test_version_sync.py @@ -55,17 +55,6 @@ def _javascript_package_version() -> str: raise AssertionError("package.json does not expose a string version") -def _flutter_package_version() -> str: - pubspec_path = REPO_ROOT / "app" / "pubspec.yaml" - for line in pubspec_path.read_text(encoding="utf-8").splitlines(): - if line.startswith("version:"): - version = line.split(":", 1)[1].strip() - if version: - return version - break - raise AssertionError("pubspec.yaml does not expose a version") - - def _rust_package_version() -> str: cargo_path = REPO_ROOT / "matrix" / "Cargo.toml" inside_package = False @@ -86,7 +75,7 @@ def _rust_package_version() -> str: def test_version_identifiers_match_across_languages() -> None: - """Guard against version drift between Python, Ruby, JavaScript, Flutter, and Rust.""" + """Guard against version drift between Python, Ruby, JavaScript, and Rust.""" python_version = getattr(data, "__version__", None) assert ( @@ -95,13 +84,6 @@ def test_version_identifiers_match_across_languages() -> None: ruby_version = _ruby_fallback_version() javascript_version = _javascript_package_version() - flutter_version = _flutter_package_version() rust_version = _rust_package_version() - assert ( - python_version - == ruby_version - == javascript_version - == flutter_version - == rust_version - ) + assert python_version == ruby_version == javascript_version == rust_version diff --git a/web/spec/meshcore_dedup_spec.rb b/web/spec/meshcore_dedup_spec.rb index 83f9e9ad..027267be 100644 --- a/web/spec/meshcore_dedup_spec.rb +++ b/web/spec/meshcore_dedup_spec.rb @@ -441,7 +441,7 @@ RSpec.describe "MeshCore cross-ingestor message dedup" do ingest("/api/messages", channel_copy(slot: 6, from_id: synthetic_id, dt: dt)) end - # The chat feed every reader (dashboard, Matrix bridge, mobile app) sees. + # The chat feed every reader (dashboard, Matrix bridge) sees. # # @return [Array] GET /api/messages rows for MeshCore. def served_messages