diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index dc1e9c7..b9b6207 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -1,39 +1,39 @@ name: "Build APK" on: - push: - branches: ["main"] - tags: ["*"] - workflow_dispatch: + push: + branches: ["main"] + tags: ["*"] + workflow_dispatch: jobs: - build-apk: - runs-on: ubuntu-latest + build-apk: + runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - submodules: recursive + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + submodules: recursive - - name: Lix GHA Installer Action - uses: samueldr/lix-gha-installer-action@v2026-02-22 - with: - extra_nix_config: experimental-features = nix-command flakes flake-self-attrs - - - name: Decode keystore - run: echo "$KEYSTORE_CONTENT" | base64 --decode > keystore.jks - env: - KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }} + - name: Lix GHA Installer Action + uses: samueldr/lix-gha-installer-action@v2026-02-22 + with: + extra_nix_config: experimental-features = nix-command flakes flake-self-attrs - - name: Build app - run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --release" - env: - KEYSTORE_PATH: ../../keystore.jks - KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + - name: Decode keystore + run: echo "$KEYSTORE_CONTENT" | base64 --decode > keystore.jks + env: + KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }} - - name: Upload installer artifact - uses: actions/upload-artifact@v6 - with: - name: APK - path: build/app/outputs/flutter-apk/app-release.apk \ No newline at end of file + - name: Build app + run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --target-platform android-arm64 --dart-define=GIT_COMMIT=${{ github.sha }}" + env: + KEYSTORE_PATH: ../../keystore.jks + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + + - name: Upload installer artifact + uses: actions/upload-artifact@v6 + with: + name: APK + path: build/app/outputs/flutter-apk/app-release.apk diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml index d280bc6..c255c8d 100644 --- a/.github/workflows/flatpak.yml +++ b/.github/workflows/flatpak.yml @@ -1,37 +1,73 @@ name: "Build Flatpaks" on: - push: - branches: ["main"] - tags: ["*"] - workflow_dispatch: + push: + branches: ["main"] + tags: ["*"] + workflow_dispatch: jobs: - build-flatpak: - strategy: - fail-fast: false - matrix: - include: - - arch: x86_64 - runner: ubuntu-latest - - arch: aarch64 - runner: ubuntu-24.04-arm - runs-on: ${{ matrix.runner }} + build-flatpak: + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: ubuntu-latest + - arch: aarch64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} - steps: - - name: Checkout repository - uses: actions/checkout@v6 + steps: + - name: Checkout repository + uses: actions/checkout@v6 - - name: Lix GHA Installer Action - uses: samueldr/lix-gha-installer-action@v2026-02-22 - with: - extra_nix_config: experimental-features = nix-command flakes flake-self-attrs + - name: Lix GHA Installer Action + uses: samueldr/lix-gha-installer-action@v2026-02-22 + with: + extra_nix_config: experimental-features = nix-command flakes flake-self-attrs - - name: Build app - run: nix build .#flatpak + - name: Build app + run: nix build .#flatpak - - name: Upload installer artifact - uses: actions/upload-artifact@v6 - with: - name: flatpak-${{ matrix.arch }} - path: result/nexus.federated.nexus.flatpak \ No newline at end of file + - name: Upload installer artifact + uses: actions/upload-artifact@v6 + with: + name: flatpak-${{ matrix.arch }} + path: result/nexus.federated.nexus.flatpak + + publish: + needs: build-flatpak + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Install flatpak tooling + run: sudo apt-get update && sudo apt-get install -y flatpak ostree + + - name: Download bundles + uses: actions/download-artifact@v7 + with: + pattern: flatpak-* + path: bundles + + - name: Build OSTree repo + run: | + ostree init --repo=repo --mode=archive-z2 + for bundle in bundles/*/nexus.federated.nexus.flatpak; do + flatpak build-import-bundle repo "$bundle" + done + flatpak build-update-repo --prune repo + + - uses: actions/upload-pages-artifact@v4 + with: + path: repo + + - name: Deploy to Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000..28344ea --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,48 @@ +name: "Build iOS App" + +on: + push: + branches: ["main"] + tags: ["*"] + workflow_dispatch: + +jobs: + build-app: + runs-on: macos-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: 3.47.4 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: gomuks/go.mod + + - name: Build app + run: | + flutter pub get + dart scripts/generate.dart + flutter pub run build_runner build + flutter build ios --no-codesign --dart-define=GIT_COMMIT=${{ github.sha }} + + # Unsigned IPA: SideStore/AltStore re-sign this on-device with the + # user's own Apple ID at install time, so no certificate is needed here. + - name: Package unsigned IPA + run: | + mkdir Payload + cp -r build/ios/iphoneos/Runner.app Payload/ + zip -r Nexus.ipa Payload + + - name: Upload IPA artifact + uses: actions/upload-artifact@v6 + with: + name: Nexus.ipa + path: Nexus.ipa diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml new file mode 100644 index 0000000..b56f16f --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,47 @@ +name: "Build MacOS App" + +on: + push: + branches: ["main"] + tags: ["*"] + workflow_dispatch: + +jobs: + build-app: + runs-on: macos-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: 3.47.4 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: gomuks/go.mod + + - name: Build App + run: | + flutter pub get + dart scripts/generate.dart + flutter pub run build_runner build + flutter build macos --dart-define=GIT_COMMIT=${{ github.sha }} + + - name: Create DMG + id: create-dmg + uses: L-Super/create-dmg-actions@28511e988b13ca34096d439159a66d468b8e724f + with: + dmg_name: nexus + src_dir: build/macos/Build/Products/Release/Nexus.app + + - name: Upload DMG + uses: actions/upload-artifact@v6 + with: + name: nexus.dmg + path: ${{ steps.create-dmg.outputs.dmg_path }} diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index d9c8dd5..db339b9 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -1,65 +1,74 @@ name: "Build EXE" on: - push: - branches: ["main"] - tags: ["*"] - workflow_dispatch: + push: + branches: ["main"] + tags: ["*"] + workflow_dispatch: jobs: - build-exe: - runs-on: windows-latest + build-exe: + runs-on: windows-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - submodules: recursive + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + submodules: recursive - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - flutter-version: 3.44.4 + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: 3.47.4 - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: gomuks/go.mod + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: gomuks/go.mod - - name: Setup MSYS2 - uses: msys2/setup-msys2@v2 - with: - msystem: MINGW64 - install: >- - mingw-w64-x86_64-gcc + - name: Setup MSYS2 + uses: msys2/setup-msys2@v2 + with: + msystem: CLANG64 + install: >- + mingw-w64-x86_64-gcc - - name: Go build - run: | - cd gomuks/pkg/ffi - go build -tags goolm,sqlite_fts5 -o ../../../libgomuks.dll -buildmode=c-shared + - name: Go build + run: | + cd gomuks/pkg/ffi + go build -tags goolm,sqlite_fts5 -o ../../../libgomuks.dll -buildmode=c-shared - - name: Build with Flutter - run: | - flutter pub get - dart scripts/generate.dart - flutter pub run build_runner build - flutter build windows --release + - name: Build with Flutter + run: | + flutter pub get + dart scripts/generate.dart + flutter pub run build_runner build + flutter build windows --dart-define=GIT_COMMIT=${{ github.sha }} - - name: Copy MinGW runtime DLLs - shell: msys2 {0} - run: | - cp /mingw64/bin/libgcc_s_seh-1.dll build/windows/x64/runner/Release/ - cp /mingw64/bin/libwinpthread-1.dll build/windows/x64/runner/Release/ - cp /mingw64/bin/libstdc++-6.dll build/windows/x64/runner/Release/ + - name: Copy MinGW runtime DLLs + shell: msys2 {0} + run: | + cp /mingw64/bin/libgcc_s_seh-1.dll build/windows/x64/runner/Release/ + cp /mingw64/bin/libwinpthread-1.dll build/windows/x64/runner/Release/ + cp /mingw64/bin/libstdc++-6.dll build/windows/x64/runner/Release/ - - name: Install Inno Setup - run: choco install innosetup -y + - name: Copy MSVC runtime DLLs + shell: powershell + run: | + $release = "build/windows/x64/runner/Release" - - name: Build Inno Setup installer - run: iscc windows/installer.iss + Copy-Item C:\Windows\System32\MSVCP140.dll $release -Force + Copy-Item C:\Windows\System32\VCRUNTIME140.dll $release -Force + Copy-Item C:\Windows\System32\VCRUNTIME140_1.dll $release -Force - - name: Upload installer artifact - uses: actions/upload-artifact@v6 - with: - name: windows-installer - path: windows/dist/Nexus-Setup.exe \ No newline at end of file + - name: Install Inno Setup + run: choco install innosetup -y + + - name: Build Inno Setup installer + run: iscc windows/installer.iss + + - name: Upload installer artifact + uses: actions/upload-artifact@v6 + with: + name: windows-installer + path: windows/dist/Nexus-Setup.exe diff --git a/.gitignore b/.gitignore index 2bec583..912ced3 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,6 @@ key.properties password.txt # Nix -/result \ No newline at end of file +/result +# Widget Preview related +.widget_preview/ diff --git a/.gitmodules b/.gitmodules index dc5e02e..06dd51a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,3 @@ [submodule "gomuks"] path = gomuks - url = https://github.com/henry-hiles/gomuks - branch = main + url = https://github.com/gomuks/gomuks diff --git a/.metadata b/.metadata index 1be1841..f12453b 100644 --- a/.metadata +++ b/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "nixpkgs000000000000000000000000000000000" + revision: "6b182d2c7585eba26d4edce0f97630effd256c33" channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: nixpkgs000000000000000000000000000000000 - base_revision: nixpkgs000000000000000000000000000000000 - - platform: macos - create_revision: nixpkgs000000000000000000000000000000000 - base_revision: nixpkgs000000000000000000000000000000000 + create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 + base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 + - platform: ios + create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 + base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 # User provided section diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..b83039c --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + "configurations": [ + { + "name": "Flutter", + "type": "dart", + "request": "launch", + "program": "lib/main.dart" + }, + { + "name": "Flutter (Headless)", + "type": "dart", + "request": "launch", + "program": "lib/main.dart", + "env": { "FLUTTER_HEADLESS": "1" } + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 2ff533e..a5cac8a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,7 +10,10 @@ "msgtype", "muks", "prefs", + "recents", "unban", - "unredact" + "unifiedpush", + "unredact", + "webpush" ] } diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index fb69a56..d9c7c9d 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -21,7 +21,7 @@ See [Effective Dart: Style](https://dart.dev/effective-dart/style) for general r Controllers live in `lib/controllers/` and provide a source that exposes data and logic via Riverpod providers, allowing other parts of the code to watch state changes with ref.watch (`ref.watch(MyController.provider)`), access the current value with ref.read (`ref.read(MyController.provider)`), and run helper methods on those classes using the notifier: ```dart -ref.watch(MyController.provider.notifier).helperMethod() +ref.read(MyController.provider.notifier).helperMethod() ``` We use an object oriented style for controllers, where `provider` is a static member on the controller class. E.g. diff --git a/README.md b/README.md index d191d6e..da4a6cb 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,22 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. ## Progress -- [ ] Platform Support +- [x] Platform Support - [x] Linux + - [x] Notifications through UnifiedPush - [x] Windows + - [ ] Background notifications - [x] Android - - [ ] MacOS ([#50](https://git.federated.nexus/Nexus/nexus/issues/50)) - - [ ] iOS + - [x] Notifications through UnifiedPush + - [x] Notifications through FCM + - [x] MacOS + - [x] Unsigned .ipa + - [ ] App Store + - [ ] Background notifications + - [x] iOS + - [x] Unsigned .ipa + - [ ] App Store + - [ ] Background notifications - [ ] Web (may not be possible) - [x] Login (via OAuth) - [x] Rooms / Spaces @@ -50,10 +60,10 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [x] Per message profiles - [x] Attachments - [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391) - - [x] Mentions + - [x] Tags - [x] Users - [x] Rooms - - [ ] Inline emoji picker (Putting this here since it'll be implemented the same way as mentions) + - [x] Emojis - [ ] Custom emojis/stickers - [ ] GIFs using Gomuks' GIF proxies - [x] Receiving @@ -63,9 +73,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [x] URL Previews - [x] Replies - [x] Viewing - - [ ] Jump to original message - - [x] In loaded timeline - - [ ] Out of loaded timeline + - [x] Jump to original message - [x] Edits - [x] Attachments - [x] Unencrypted @@ -78,12 +86,10 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [x] Users - [x] Clickable - [x] Rooms - - [ ] Clickable + - [x] Clickable - [x] Matrix URIs - [x] Matrix.to links - [x] Events - - [ ] Render more nicely - - [ ] Clickable - [x] Custom emojis/stickers - [x] History loading - [x] Editing @@ -105,7 +111,8 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [x] Member list - [x] Sort by power level - [ ] Colors based off of power level -- [ ] Notifications using UnifiedPush ([#35](https://git.federated.nexus/Nexus/nexus/issues/35)) +- [x] Notifications using UnifiedPush ([#35](https://git.federated.nexus/Nexus/nexus/issues/35)) +- [x] Notifications page - [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195) - [ ] Invites - [x] Settings @@ -115,10 +122,48 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. If you want to try out Nexus, grab one of the following artifacts from CI: - [Android APK](https://nightly.link/Henry-Hiles/nexus/workflows/android/main/APK.zip) +- [Unsigned iOS IPA](https://nightly.link/Henry-Hiles/nexus/workflows/ios/main/Nexus.ipa.zip) +- [Unsigned MacOS DMG](https://nightly.link/Henry-Hiles/nexus/workflows/macos/main/nexus.dmg.zip) - [Windows EXE](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-installer.zip) -- Flatpak - - [AArch64/Arm64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-aarch64.zip) - - [x86_64/AMD64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-x86_64.zip) + +Or, use one of the below methods: + +### Flatpak + +Make sure Flatpak is set up on your system, then run: + +```bash +flatpak install --user https://nexus.federated.nexus/flatpak/nexus.flatpakref +``` + +This adds the Nexus remote and installs the app for your user only, so no root access is needed (x86_64 and aarch64 are supported). To install system-wide instead, use `--system` in place of `--user`. Updates arrive through `flatpak update` or your software center. + +### NixOS Module + +Add Nexus as a flake input, following `nixpkgs` for less fetches: + +```nix +nexus = { + url = "git+https://git.federated.nexus/Nexus/nexus"; + inputs.nixpkgs.follows = "nixpkgs"; +}; +``` + +Then import the module and enable it in your NixOS configuration: + +```nix +{ inputs, ... }: +{ + imports = [ inputs.nexus.nixosModules.default ]; + + programs.nexus = { + enable = true; + enableNotifications = true; # optional: push notifications via UnifiedPush + }; +} +``` + +This installs Nexus system-wide. With `enableNotifications`, it also installs KUnifiedPush and registers its systemd and D-Bus services so notifications work. The `package` option lets you override the package if you need to. ## Build it yourself @@ -126,16 +171,17 @@ If you want to try out Nexus, grab one of the following artifacts from CI: #### Linux -- With Nix: Either use direnv and `direnv allow`, or `nix flake develop` -- Without Nix: Install Flutter, Go, Git, Libclang, and Glibc. Do not use any Snap packages, they cause various compilation issues. +- On NixOS: Either use direnv and `direnv allow`, or `nix develop` +- On other distros: Install Flutter, Go, Git, Libclang, Libass, MPV, and Glibc. Do not use any Snap packages, they cause various compilation issues. #### Windows You will need: - Flutter -- Android SDK + NDK - Git +- Libass +- MPV - Go - Visual Studio 2022 (Desktop development with C++) - [MSYS2/MinGW-w64 GCC](https://www.msys2.org/) (for CGO) @@ -186,6 +232,12 @@ dart scripts/generate.dart > export CPATH="$(clang -v 2>&1 | grep "Selected GCC installation" | rev | cut -d' ' -f1 | rev)/include" > ``` +Build webcrypto: + +```sh +flutter pub run webcrypto:setup +``` + Build generated files, and watch for new changes: ```sh @@ -202,7 +254,7 @@ Development instructions can be found in [DEVELOPMENT.md](./DEVELOPMENT.md). ## Community -Join the [Nexus Client Matrix Room](https://matrix.to/#/#nexus:federated.nexus) for questions or help with developing or using Nexus Client. +Join the [Nexus Client Matrix room](https://matrix.to/#/#nexus:federated.nexus) for questions or help with developing or using Nexus Client. # Credits diff --git a/altstore-source.json b/altstore-source.json new file mode 100644 index 0000000..4471fb6 --- /dev/null +++ b/altstore-source.json @@ -0,0 +1,22 @@ +{ + "name": "Nexus", + "identifier": "nexus.federated.nexus.source", + "subtitle": "Nexus for iOS", + "description": "Nexus iOS builds for AltStore and SideStore.", + "iconURL": "https://git.federated.nexus/Nexus/nexus/raw/branch/main/assets/icon.png", + "website": "https://git.federated.nexus/Nexus/nexus", + "apps": [ + { + "name": "Nexus", + "bundleIdentifier": "nexus.federated.nexus", + "developerName": "Nexus", + "subtitle": "A Matrix client", + "localizedDescription": "Nexus for iOS. Unsigned builds, resigned at install by AltStore/SideStore.", + "iconURL": "https://git.federated.nexus/Nexus/nexus/raw/branch/main/assets/icon.png", + "category": "social", + "screenshots": [], + "versions": [] + } + ], + "news": [] +} diff --git a/analysis_options.yaml b/analysis_options.yaml index a8b1078..00df0c4 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,12 +1,18 @@ analyzer: - errors: - invalid_annotation_target: ignore - avoid_print: ignore - exclude: - - "build/**" - - "**/*.g.dart" - - "**/*.freezed.dart" + errors: + invalid_annotation_target: ignore + avoid_print: ignore + exclude: + - "build/**" + - "**/*.g.dart" + - "**/*.freezed.dart" + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml linter: - rules: - prefer_double_quotes: true \ No newline at end of file + rules: + prefer_double_quotes: true diff --git a/android/app/build.gradle b/android/app/build.gradle deleted file mode 100644 index 2e7fb67..0000000 --- a/android/app/build.gradle +++ /dev/null @@ -1,77 +0,0 @@ -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" -} - -def localProperties = new Properties() -def localPropertiesFile = rootProject.file("local.properties") -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader("UTF-8") { reader -> - localProperties.load(reader) - } -} - -def flutterVersionCode = localProperties.getProperty("flutter.versionCode") -if (flutterVersionCode == null) { - flutterVersionCode = "1" -} - -def flutterVersionName = localProperties.getProperty("flutter.versionName") -if (flutterVersionName == null) { - flutterVersionName = "1.0" -} - -def keystoreProperties = new Properties() -def keystorePropertiesFile = rootProject.file('key.properties') -if (keystorePropertiesFile.exists()) { - keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) -} - -android { - namespace = "nexus.federated.nexus" - ndkVersion = flutter.ndkVersion - compileSdk = 34 - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = "17" - } - - defaultConfig { - applicationId = "nexus.federated.nexus" - minSdk = 29 - targetSdkVersion flutter.targetSdkVersion - versionCode = flutterVersionCode.toInteger() - versionName = flutterVersionName - } - - signingConfigs { - release { - keyAlias "key" - def storePath = keystoreProperties['path'] ?: System.getenv("KEYSTORE_PATH") - storeFile storePath ? file(storePath) : null - keyPassword keystoreProperties['password'] ?: System.getenv("KEYSTORE_PASSWORD") - storePassword keystoreProperties['password'] ?: System.getenv("KEYSTORE_PASSWORD") - } - } - - buildTypes { - release { - signingConfig signingConfigs.release - } - - debug { - applicationIdSuffix = ".debug" - } - } -} - -flutter { - source = "../.." -} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..9c0d96c --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,84 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("dev.flutter.flutter-gradle-plugin") +} + +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") + +if (keystorePropertiesFile.exists()) { + keystorePropertiesFile.inputStream().use { + keystoreProperties.load(it) + } +} + +android { + namespace = "nexus.federated.nexus" + compileSdk = 36 + ndkVersion = flutter.ndkVersion + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + defaultConfig { + applicationId = "nexus.federated.nexus" + + minSdk = 29 + targetSdk = flutter.targetSdkVersion + + versionCode = flutter.versionCode + versionName = flutter.versionName + + multiDexEnabled = true + } + + signingConfigs { + create("release") { + keyAlias = "key" + + val storePath = + keystoreProperties["path"]?.toString() + ?: System.getenv("KEYSTORE_PATH") + + storeFile = storePath?.let { file(it) } + + keyPassword = + keystoreProperties["password"]?.toString() + ?: System.getenv("KEYSTORE_PASSWORD") + + storePassword = + keystoreProperties["password"]?.toString() + ?: System.getenv("KEYSTORE_PASSWORD") + } + } + + buildTypes { + release { + signingConfig = signingConfigs.getByName("release") + } + + debug { + applicationIdSuffix = ".debug" + } + } +} + +dependencies { + implementation("org.unifiedpush.android:embedded-fcm-distributor:3.1.0") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21 + } +} + +flutter { + source = "../.." +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a0a0bcc..d4dbebe 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,7 +6,9 @@ + - if (project.hasProperty("android")) { - android { - compileSdkVersion 36 - buildToolsVersion '33.0.1' - } - } - } - - project.evaluationDependsOn(":app") -} - -tasks.register("clean", Delete) { - delete rootProject.buildDir -} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..441eb71 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,25 @@ +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) +} \ No newline at end of file diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 74b269f..5fadfe8 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-all.zip diff --git a/android/settings.gradle b/android/settings.gradle deleted file mode 100644 index 7cffcd0..0000000 --- a/android/settings.gradle +++ /dev/null @@ -1,25 +0,0 @@ -pluginManagement { - def flutterSdkPath = { - def properties = new Properties() - file("local.properties").withInputStream { properties.load(it) } - def flutterSdkPath = properties.getProperty("flutter.sdk") - assert flutterSdkPath != null, "flutter.sdk not set in local.properties" - return flutterSdkPath - }() - - 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.13.0" apply false - id "org.jetbrains.kotlin.android" version "2.2.20" apply false -} - -include ":app" diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..b59bc94 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +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 "9.3.1" apply false + id("org.jetbrains.kotlin.android") version "2.4.10" apply false +} + +include(":app") \ No newline at end of file diff --git a/assets/icon.svg b/assets/bundled/icon.svg similarity index 100% rename from assets/icon.svg rename to assets/bundled/icon.svg diff --git a/assets/fonts/AtkinsonHyperlegibleMono.ttf b/assets/fonts/AtkinsonHyperlegibleMono.ttf new file mode 100644 index 0000000..ac9c7ee Binary files /dev/null and b/assets/fonts/AtkinsonHyperlegibleMono.ttf differ diff --git a/assets/fonts/Inter.ttf b/assets/fonts/Inter.ttf new file mode 100644 index 0000000..4a1af5a Binary files /dev/null and b/assets/fonts/Inter.ttf differ diff --git a/assets/fonts/NotoColorEmoji.ttf b/assets/fonts/NotoColorEmoji.ttf deleted file mode 100644 index b652015..0000000 Binary files a/assets/fonts/NotoColorEmoji.ttf and /dev/null differ diff --git a/assets/fonts/Roboto.ttf b/assets/fonts/Roboto.ttf deleted file mode 100644 index 01656a3..0000000 Binary files a/assets/fonts/Roboto.ttf and /dev/null differ diff --git a/assets/fonts/Twemoji.ttf b/assets/fonts/Twemoji.ttf new file mode 100644 index 0000000..e389432 Binary files /dev/null and b/assets/fonts/Twemoji.ttf differ diff --git a/assets/twim/oauth.webp b/assets/twim/oauth.webp deleted file mode 100644 index 36fbedf..0000000 Binary files a/assets/twim/oauth.webp and /dev/null differ diff --git a/assets/twim/settings.png b/assets/twim/settings.png deleted file mode 100644 index 3037be1..0000000 Binary files a/assets/twim/settings.png and /dev/null differ diff --git a/assets/twim/summary.png b/assets/twim/summary.png new file mode 100644 index 0000000..70d9a50 Binary files /dev/null and b/assets/twim/summary.png differ diff --git a/flake.lock b/flake.lock index 41cfb9d..a48d846 100644 --- a/flake.lock +++ b/flake.lock @@ -5,11 +5,11 @@ "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1782949081, - "narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=", + "lastModified": 1788450739, + "narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e", + "rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993", "type": "github" }, "original": { @@ -42,15 +42,16 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1774860670, - "narHash": "sha256-YjJkQrvxrErXtfDi3obUn6rNmkA+CIAZ3f5NgL5xuYE=", - "owner": "neobrain", + "lastModified": 1790870263, + "narHash": "sha256-ZBblCzxsUBBCZJRJR0knnaOt2+Xl5JGfI+YkGPf2SI4=", + "owner": "Henry-Hiles", "repo": "nix2flatpak", - "rev": "61d68e21e3fbc2d57590051f48736bea271f4aba", + "rev": "2ef0e3cde2bdce0870f79a7568a72c2681ad20b5", "type": "github" }, "original": { - "owner": "neobrain", + "owner": "Henry-Hiles", + "ref": "quad/nexus", "repo": "nix2flatpak", "type": "github" } @@ -73,11 +74,11 @@ }, "nixpkgs-lib": { "locked": { - "lastModified": 1782614948, - "narHash": "sha256-ePjCwr1sNm9NYUqywL7QfK3JnlS015msC+eBu2zKlp8=", + "lastModified": 1788057806, + "narHash": "sha256-DTQSMxzDWmT0zhguthvegnVkn7CFqGCv4IHCzk5ZUpM=", "owner": "nix-community", "repo": "nixpkgs.lib", - "rev": "db3f255737b94216eb71cce308e2912cf6bc2d7c", + "rev": "596e2e3940e09b2abbeb03f75fa1828c57fcd72c", "type": "github" }, "original": { @@ -88,11 +89,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1784497964, - "narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=", + "lastModified": 1790689126, + "narHash": "sha256-ilerN1WLSvF+HMjziC/Wv99J5y02maDH+6hZPwsORKg=", "owner": "nixos", "repo": "nixpkgs", - "rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163", + "rev": "b4fd65b198c599cbe814fcb9f42d25d021595ec9", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 2b13a08..97d6068 100644 --- a/flake.nix +++ b/flake.nix @@ -5,7 +5,7 @@ self.submodules = true; nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; flake-parts.url = "github:hercules-ci/flake-parts"; - nix2flatpak.url = "github:neobrain/nix2flatpak"; + nix2flatpak.url = "github:Henry-Hiles/nix2flatpak/quad/nexus"; }; outputs = @@ -16,62 +16,12 @@ ... }@inputs: flake-parts.lib.mkFlake { inherit inputs; } { + imports = [ ./linux/nix ]; systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ]; - - perSystem = - { - lib, - pkgs, - system, - ... - }: - - { - _module.args.pkgs = import nixpkgs { - inherit system; - config = { - android_sdk.accept_license = true; - allowUnfree = true; - }; - }; - - packages = - let - default = pkgs.callPackage ./linux/nix/pkg { - src = self; - }; - in - { - inherit default; - - flatpak = inputs.nix2flatpak.lib.${system}.mkFlatpak { - appName = "Nexus"; - developer = "QuadRadical"; - appId = "nexus.federated.nexus"; - package = default; - runtime = "org.gnome.Platform/49"; - permissions = { - share = [ "network" ]; - sockets = [ - "pulseaudio" - "fallback-x11" - "wayland" - ]; - devices = [ "dri" ]; - }; - }; - - gomuks = pkgs.callPackage ./linux/nix/pkg/gomuks.nix { - src = self; - }; - }; - - devShells.default = pkgs.callPackage ./linux/nix/devshell.nix { }; - }; }; } diff --git a/gomuks b/gomuks index 1f11743..38e5033 160000 --- a/gomuks +++ b/gomuks @@ -1 +1 @@ -Subproject commit 1f11743884a64765b1444b250377a09ebcaba93c +Subproject commit 38e5033c3a91db0cbea6cbd9f0feef9ff6441038 diff --git a/hook/build.dart b/hook/build.dart index 625201d..9315e04 100644 --- a/hook/build.dart +++ b/hook/build.dart @@ -1,4 +1,5 @@ import "dart:io"; + import "package:collection/collection.dart"; import "package:hooks/hooks.dart"; import "package:code_assets/code_assets.dart"; @@ -13,22 +14,46 @@ Future main(List args) => build(args, (input, output) async { String libFileName; Map extraEnv = {}; + IOSSdk? iosSdk; + String? iosSdkPath; + String? iosTargetTriple; switch (targetOS) { case OS.linux: libFileName = "libgomuks.so"; break; + case OS.iOS: + // Go only supports buildmode=c-archive for ios, not c-shared, so the + // archive built below gets linked into a dylib via a separate clang + // invocation before being registered as a normal asset like macOS. + libFileName = "libgomuks.dylib"; + final iosConfig = codeConfig.iOS; + iosSdk = iosConfig.targetSdk; + final minVersion = iosConfig.targetVersion; + iosSdkPath = await getXCodeTool(sdkType: iosSdk.type); + + final archTriple = switch (targetArch) { + Architecture.arm64 => "arm64", + Architecture.x64 => "x86_64", + _ => throw UnsupportedError( + "Unsupported iOS architecture: $targetArch", + ), + }; + + iosTargetTriple = iosSdk == IOSSdk.iPhoneSimulator + ? "$archTriple-apple-ios$minVersion.0-simulator" + : "$archTriple-apple-ios$minVersion.0"; + extraEnv = { + "GOOS": "ios", + "CC": await getXCodeTool(sdkType: iosSdk.type, findTool: "clang"), + "CGO_CFLAGS": "-isysroot $iosSdkPath -target $iosTargetTriple", + "CGO_LDFLAGS": "-isysroot $iosSdkPath -target $iosTargetTriple", + }; + break; case OS.macOS: libFileName = "libgomuks.dylib"; extraEnv = { - "SDKROOT": await getXCodeSDK(), - "CGO_ENABLED": "1", - "GOARCH": switch (targetArch) { - Architecture.arm64 => "arm64", - Architecture.x64 => "amd64", - _ => throw UnsupportedError( - "Unsupported macOS architecture: $targetArch", - ), - }, + "SDKROOT": await getXCodeTool(), + "MACOSX_DEPLOYMENT_TARGET": codeConfig.macOS.targetVersion.toString(), }; break; case OS.windows: @@ -96,15 +121,28 @@ Future main(List args) => build(args, (input, output) async { final tags = [ "sqlite_fts5", "goolm", - // goheif/dav1d is not supported on Android, would need to be fixed upstream - if (targetOS == OS.android) "noheic", + // goheif/dav1d is not supported on Android or iOS, would need to be fixed upstream + if (targetOS == OS.android || targetOS == OS.iOS) "noheic", ].join(","); + + final archiveFile = targetOS == OS.iOS + ? buildDir.resolve("${targetArch.name}/libgomuks.a") + : libFile; print( - "Building Gomuks shared library $libFileName (${targetOS.name}/${targetArch.name}) to ${libFile.path}...", + "Building Gomuks shared library $libFileName (${targetOS.name}/${targetArch.name}) to ${archiveFile.path}...", ); final result = await Process.run( "go", - ["build", "-tags", tags, "-o", libFile.path, "-buildmode=c-shared"], + [ + "build", + "-trimpath", + "-ldflags=-s -w", + "-tags", + tags, + "-o", + archiveFile.path, + "-buildmode=${targetOS == OS.iOS ? "c-archive" : "c-shared"}", + ], workingDirectory: gomuksBuildDir.resolve("pkg/ffi/").toFilePath(), environment: { "CGO_ENABLED": "1", @@ -124,6 +162,35 @@ Future main(List args) => build(args, (input, output) async { "Failed to build Gomuks shared library\n${result.stderr}", ); } + + if (targetOS == OS.iOS) { + print("Linking $archiveFile into $libFile..."); + final linkResult = await Process.run("xcrun", [ + "--sdk", + iosSdk!.type, + "clang", + "-dynamiclib", + "-isysroot", + iosSdkPath!, + "-target", + iosTargetTriple!, + "-install_name", + "@rpath/libgomuks.dylib", + "-framework", + "Security", + "-framework", + "CoreFoundation", + "-framework", + "SystemConfiguration", + "-force_load", + archiveFile.path, + "-o", + libFile.path, + ]); + if (linkResult.exitCode != 0) { + throw Exception("Failed to link Gomuks dylib\n${linkResult.stderr}"); + } + } } final generatedFile = "src/third_party/gomuks.g.dart"; diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/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/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..b29d5cb --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,28 @@ +PODS: + - Flutter (1.0.0) + - media_kit_libs_ios_video (1.0.4): + - Flutter + - media_kit_video (0.0.1): + - Flutter + +DEPENDENCIES: + - Flutter (from `Flutter`) + - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) + - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + media_kit_libs_ios_video: + :path: ".symlinks/plugins/media_kit_libs_ios_video/ios" + media_kit_video: + :path: ".symlinks/plugins/media_kit_video/ios" + +SPEC CHECKSUMS: + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 + media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 + +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e + +COCOAPODS: 1.17.0 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..065d08b --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,759 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 2ADFEE6C745FFB2100CC9AFD /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8156A45F510382D558AED93A /* Pods_RunnerTests.framework */; }; + 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 */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 88D55A5F1C7625122EBEE29D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 82F9A3759658343C81A0EA4C /* Pods_Runner.framework */; }; + 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 = ""; }; + 22DE4310DAFBE211CDF700B1 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 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; }; + 363A89F6FA09BCCBDED440CE /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 63516CF65A79A5DDDE64B0D1 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 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 = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 8156A45F510382D558AED93A /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 82F9A3759658343C81A0EA4C /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 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 = ""; }; + AEB96D8976BD46D6860C7041 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + B8B599FDB72E4713FB7B5A44 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + FE5E4162842555A83F8A7FD7 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 8C0A36760DA8B873D8F6DDCE /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2ADFEE6C745FFB2100CC9AFD /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + 88D55A5F1C7625122EBEE29D /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1CFFAE23B502BCCEB2B191A3 /* Pods */ = { + isa = PBXGroup; + children = ( + AEB96D8976BD46D6860C7041 /* Pods-Runner.debug.xcconfig */, + 363A89F6FA09BCCBDED440CE /* Pods-Runner.release.xcconfig */, + 22DE4310DAFBE211CDF700B1 /* Pods-Runner.profile.xcconfig */, + B8B599FDB72E4713FB7B5A44 /* Pods-RunnerTests.debug.xcconfig */, + FE5E4162842555A83F8A7FD7 /* Pods-RunnerTests.release.xcconfig */, + 63516CF65A79A5DDDE64B0D1 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 594EB1F65336CF9C7958F6FA /* Frameworks */ = { + isa = PBXGroup; + children = ( + 82F9A3759658343C81A0EA4C /* Pods_Runner.framework */, + 8156A45F510382D558AED93A /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 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 */, + 1CFFAE23B502BCCEB2B191A3 /* Pods */, + 594EB1F65336CF9C7958F6FA /* Frameworks */, + ); + 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 */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.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 = ( + EE82EC529EB6C0F1FA480659 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 8C0A36760DA8B873D8F6DDCE /* Frameworks */, + ); + 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 = ( + 0830C6758DA3ECA81FB12386 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 1D165FDFB9D0D939B041966D /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + 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; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); + 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 */ + 0830C6758DA3ECA81FB12386 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 1D165FDFB9D0D939B041966D /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 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"; + }; + EE82EC529EB6C0F1FA480659 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* 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 */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift 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 = 13.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)"; + DEVELOPMENT_TEAM = PK66NJM372; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus; + 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; + baseConfigurationReference = B8B599FDB72E4713FB7B5A44 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus.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; + baseConfigurationReference = FE5E4162842555A83F8A7FD7 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus.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; + baseConfigurationReference = 63516CF65A79A5DDDE64B0D1 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus.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 = 13.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 = 13.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)"; + DEVELOPMENT_TEAM = PK66NJM372; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus; + 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)"; + DEVELOPMENT_TEAM = PK66NJM372; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus; + 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 */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c73d4ae --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,17 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d0d98aa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"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/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..1682af0 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..51f4a9b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..97b3b3f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..17d971e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..ca74554 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..6129702 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..16c3d1e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..97b3b3f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..f515ebd Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..2e49a13 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..66b9ef2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..53af9ff Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..455803d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..7a2fd0f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..2e49a13 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..c34ad9e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..5e21545 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..2140e17 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..a27d854 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8b4eb75 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..21c424b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# 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/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..7680b42 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,79 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Nexus + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + nexus + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + nexus.federated.nexus + + + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/controllers/account_data.dart b/lib/controllers/account_data.dart index 0926e08..c595565 100644 --- a/lib/controllers/account_data.dart +++ b/lib/controllers/account_data.dart @@ -1,16 +1,22 @@ +import "dart:convert"; + import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/models/account_data.dart"; -class AccountDataController extends Notifier> { +class AccountDataController extends Notifier { @override - IMap build() => .new(); + AccountData build() => .new(); - void update(IMap newData) => - state = .new({...state.unlock, ...newData.unlock}); + void update(IMap> newAccountData) => + state = .fromJson({ + ...json.decode(json.encode(state.toJson())), + ...newAccountData + .map((key, value) => MapEntry(key, value["content"])) + .unlock, + }); - static final provider = - NotifierProvider>( - AccountDataController.new, - ); + static final provider = NotifierProvider( + AccountDataController.new, + ); } diff --git a/lib/controllers/attachment.dart b/lib/controllers/attachment.dart index e33493e..d4a5e09 100644 --- a/lib/controllers/attachment.dart +++ b/lib/controllers/attachment.dart @@ -6,10 +6,8 @@ import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/message.dart"; import "package:path/path.dart"; -class AttachmentController extends Notifier<(String, MessageContent?)?> { - final String roomId; - AttachmentController(this.roomId); - +class AttachmentController(final String roomId) + extends Notifier<(String, MessageContent?)?> { @override Null build() => null; @@ -17,18 +15,16 @@ class AttachmentController extends Notifier<(String, MessageContent?)?> { final filename = basename(file.path); state = (filename, null); - final room = ref.watch( - RoomsController.provider.select((value) => value[roomId]), + final isEncrypted = ref.read( + RoomsController.provider.select( + (value) => + value[roomId]?.state[EventType.encryption.type]?.isNotEmpty == true, + ), ); final content = await ref .watch(ClientController.provider.notifier) - .uploadMedia( - .new( - path: file.path, - encrypt: room?.state[EventType.encryption.type]?.isNotEmpty == true, - ), - ); + .uploadMedia(.new(path: file.path, encrypt: isEncrypted)); state = (filename, content); } diff --git a/lib/controllers/auth_url.dart b/lib/controllers/auth_url.dart index 156eb81..963df86 100644 --- a/lib/controllers/auth_url.dart +++ b/lib/controllers/auth_url.dart @@ -4,10 +4,8 @@ import "package:nexus/controllers/client_id.dart"; import "package:nexus/models/oauth_auth_code_response.dart"; import "package:nexus/models/requests/oauth/get_auth_url.dart"; -class AuthUrlController extends AsyncNotifier { - final Uri homeserver; - AuthUrlController(this.homeserver); - +class AuthUrlController(final Uri homeserver) + extends AsyncNotifier { @override Future build() async => ref .watch(ClientController.provider.notifier) diff --git a/lib/controllers/author.dart b/lib/controllers/author.dart index bbbe068..f9c9167 100644 --- a/lib/controllers/author.dart +++ b/lib/controllers/author.dart @@ -1,19 +1,17 @@ import "dart:async"; + import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/user.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/event.dart"; -class AuthorController extends AsyncNotifier { - final Event event; - AuthorController(this.event); - +class AuthorController(final Event event) + extends AsyncNotifier { @override Future build() async { final member = await ref.watch( - UserController.provider( - .new(roomId: event.roomId, userId: event.sender), - ).future, + UserController.provider(.new(roomId: event.roomId, userId: event.sender)) + .future, ); return .new( diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 2cb1d66..7d9604b 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -1,42 +1,50 @@ +import "dart:async"; import "dart:ffi"; import "dart:io"; import "dart:isolate"; import "dart:math"; + import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:ffi/ffi.dart"; -import "package:flutter/foundation.dart"; -import "package:nexus/controllers/account_data.dart"; -import "package:nexus/controllers/client_state.dart"; -import "package:nexus/controllers/init_complete.dart"; -import "package:nexus/controllers/rooms.dart"; -import "package:nexus/controllers/space_edges.dart"; -import "package:nexus/controllers/sync_status.dart"; -import "package:nexus/controllers/top_level_spaces.dart"; +import "package:flutter/cupertino.dart"; +import "package:intl/intl.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart"; -import "package:nexus/main.dart"; +import "package:nexus/models/capabilities.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; +import "package:nexus/models/event_context.dart"; +import "package:nexus/models/gomuks_config.dart"; import "package:nexus/models/oauth_auth_code_response.dart"; +import "package:nexus/models/open_graph_data.dart"; import "package:nexus/models/paginate.dart"; +import "package:nexus/models/paginate_manual.dart"; +import "package:nexus/models/requests/deregister_pusher.dart"; import "package:nexus/models/requests/download_media.dart"; import "package:nexus/models/requests/get_event.dart"; +import "package:nexus/models/requests/get_event_context.dart"; +import "package:nexus/models/requests/get_mentions.dart"; import "package:nexus/models/requests/get_related_events.dart"; import "package:nexus/models/requests/get_room_state.dart"; import "package:nexus/models/requests/join_room.dart"; -import "package:nexus/models/profile.dart"; +import "package:nexus/models/profile_response.dart"; import "package:nexus/models/requests/oauth/exchange_token.dart"; import "package:nexus/models/requests/oauth/get_auth_url.dart"; import "package:nexus/models/requests/oauth/register_client.dart"; import "package:nexus/models/requests/paginate.dart"; +import "package:nexus/models/requests/paginate_manual.dart"; import "package:nexus/models/requests/redact_event.dart"; +import "package:nexus/models/requests/register_pusher.dart"; import "package:nexus/models/requests/report.dart"; import "package:nexus/models/requests/send_event.dart"; import "package:nexus/models/requests/send_message.dart"; +import "package:nexus/models/requests/set_account_data.dart"; import "package:nexus/models/requests/set_membership.dart"; import "package:nexus/models/requests/set_state.dart"; import "package:nexus/models/requests/upload_media.dart"; import "package:nexus/models/room.dart"; -import "package:nexus/models/sync_data.dart"; +import "package:nexus/models/room_metadata.dart"; +import "package:nexus/models/room_summary.dart"; +import "package:nexus/models/spec_versions_response.dart"; import "package:nexus/src/third_party/gomuks.g.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:path_provider/path_provider.dart"; @@ -44,134 +52,101 @@ import "package:path_provider/path_provider.dart"; class ClientController extends AsyncNotifier { @override Future build() async { - final Pointer root; - if (Platform.isAndroid) { - final dir = await getApplicationSupportDirectory(); - root = "${dir.path}/gomuks".toNativeUtf8().cast(); - } else { - root = nullptr.cast(); + debugPrint("Setting Gomuks env..."); + if (Platform.isAndroid || Platform.isIOS) { + final env = { + "GOMUKS_ROOT": (await getApplicationSupportDirectory()).path, + "GOMUKS_CACHE_HOME": (await getApplicationCacheDirectory()).path, + }; + for (final MapEntry(:key, :value) in env.entries) { + final keyPtr = key.toNativeUtf8().cast(); + final valuePtr = value.toNativeUtf8().cast(); + try { + GomuksSetEnv(keyPtr, valuePtr); + } finally { + calloc + ..free(keyPtr) + ..free(valuePtr); + } + } } - final handle = GomuksInit(root); + debugPrint("Initializing Gomuks..."); + final handle = await Isolate.run(() { + final bufferPointer = GomuksConfig( + matrix: .new( + initialDeviceDisplayName: + "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}", + ), + ).toJson().toGomuksBufferPtr(); - final callable = - NativeCallable< - Void Function(Pointer, Int64, GomuksOwnedBuffer) - >.listener(( - Pointer command, - int requestId, - GomuksOwnedBuffer data, - ) { - try { - final muksEventType = command.cast().toDartString(); - debugPrint("Handling $muksEventType..."); - final decodedMuksEvent = data.toJson(); - - switch (muksEventType) { - case "client_state": - ref - .watch(ClientStateController.provider.notifier) - .set(.fromJson(decodedMuksEvent)); - break; - case "sync_status": - ref - .watch(SyncStatusController.provider.notifier) - .set(.fromJson(decodedMuksEvent)); - break; - case "init_complete": - ref.watch(InitCompleteController.provider.notifier).complete(); - break; - case "send_complete": - final event = Event.fromJson(decodedMuksEvent["event"]); - ref - .watch(RoomsController.provider.notifier) - .update( - .new({ - event.roomId: .new(events: .new({event.rowId: event})), - }), - .new(), - ); - - break; - case "sync_complete": - final syncData = SyncData.fromJson(decodedMuksEvent); - final roomProvider = RoomsController.provider; - final accountDataProvider = AccountDataController.provider; - - if (syncData.clearState) { - ref.invalidate(roomProvider); - ref.invalidate(accountDataProvider); - } - - ref - .watch(roomProvider.notifier) - .update(syncData.rooms, syncData.leftRooms); - ref - .watch(accountDataProvider.notifier) - .update(syncData.accountData); - - if (syncData.topLevelSpaces != null) { - ref - .watch(TopLevelSpacesController.provider.notifier) - .set(syncData.topLevelSpaces!); - } - - if (syncData.spaceEdges != null) { - ref - .watch(SpaceEdgesController.provider.notifier) - .set(syncData.spaceEdges!); - } - - // ref - // .watch(SyncStatusController.provider.notifier) - // .set(SyncStatus.fromJson(decodedMuksEvent)); - break; - default: - debugPrint("Unhandled event: $muksEventType"); - } - debugPrint("Finished handling $muksEventType..."); - } catch (error, stackTrace) { - if (kDebugMode) { - debugPrintStack(stackTrace: stackTrace, label: error.toString()); - rethrow; - } else { - showError(error, stackTrace); - } - } - }); + try { + return GomuksInit(bufferPointer.ref); + } finally { + calloc + ..free(bufferPointer.ref.base) + ..free(bufferPointer); + } + }); ref.onDispose(() => GomuksDestroy(handle)); - ref.onDispose(callable.close); - final errorCode = GomuksStart(handle, callable.nativeFunction); + return handle; + } - if (errorCode == 0) return handle; - throw Exception("GomuksStart returned error code $errorCode"); + Future callGomuksMethod( + Map data, + FutureOr Function(int handle, GomuksBorrowedBuffer data) + callback, + ) async { + final bufferPointer = data.toGomuksBufferPtr(); + + try { + final handle = await future; + + final response = await Isolate.run( + () => callback(handle, bufferPointer.ref), + ); + + final json = response.buf.toJsonAndFree(); + + if (response.command.cast().toDartString() == "error") { + throw json; + } + + return json; + } finally { + calloc + ..free(bufferPointer.ref.base) + ..free(bufferPointer); + } + } + + Future<(Event, RoomMetadata)?> handlePush(Map data) async { + final response = await callGomuksMethod( + data, + (handle, data) async => GomuksHandlePush(handle, data), + ); + + return response == null + ? null + : ( + Event.fromJson(response["event"]), + RoomMetadata.fromJson(response["room"]), + ); } Future _sendCommand( String command, [ Map data = const {}, - ]) async { - final bufferPointer = data.toGomuksBufferPtr(); - final handle = await future; - final response = await Isolate.run( - () => GomuksSubmitCommand( - handle, - command.toNativeUtf8().cast(), - bufferPointer.ref, - ), - ); - - calloc.free(bufferPointer); - - final json = response.buf.toJson(); - if (response.command.cast().toDartString() == "error") { - throw json; + ]) => callGomuksMethod(data, (handle, data) { + final commandPointer = command.toNativeUtf8().cast(); + try { + return GomuksSubmitCommand(handle, commandPointer, data); + } finally { + calloc.free(commandPointer); } - - return json; - } + }); Future redactEvent(RedactEventRequest report) => _sendCommand("redact_event", report.toJson()); @@ -183,7 +158,7 @@ class ClientController extends AsyncNotifier { final json = request.toJson(); final content = request.content.toJson(); - return Event.fromJson( + return .fromJson( await _sendCommand("send_event", { ...json, "content": { @@ -210,26 +185,17 @@ class ClientController extends AsyncNotifier { } } - Future joinRoom(JoinRoomRequest request) async { - final response = await _sendCommand("join_room", request.toJson()); - return response["room_id"]; - } + Future joinRoom(JoinRoomRequest request) async => + (await _sendCommand("join_room", request.toJson()))["room_id"]; - Future getAccessToken() async { - final response = await _sendCommand("get_account_info", {}); - return response?["access_token"]; - } + Future getRoomSummary(JoinRoomRequest request) async => + .fromJson(await _sendCommand("get_room_summary", request.toJson())); Future leaveRoom(Room room) async { if (room.metadata == null) return; await _sendCommand("leave_room", {"room_id": room.metadata!.id}); } - // (await _sendCommand("get_event_context", { - // "room_id": request.roomId, - // "event_id": r"$OqZT4NuTj0J1-771IOEEWRI4XdumRNu6ighlvO3K3gc", - // })); - Future> getRoomState(GetRoomStateRequest request) async { Future getState(GetRoomStateRequest request) async => (await _sendCommand("get_room_state", request.toJson())) as List?; @@ -250,17 +216,35 @@ class ClientController extends AsyncNotifier { return .new(response?.map((event) => .fromJson(event))); } + Future> getMentions(GetMentionsRequest request) async => .new( + // TODO: Handle `related_events` + ((await _sendCommand("get_mentions", request.toJson()))["events"] as List) + .map((event) => .fromJson(event)), + ); + Future getEvent(GetEventRequest request) async { final json = await _sendCommand("get_event", request.toJson()); return json == null ? null : .fromJson(json); } + Future getUrlPreview(Uri url) async => + .fromJson(await _sendCommand("get_url_preview", {"url": url.toString()})); + Future paginate(PaginateRequest request) async => .fromJson(await _sendCommand("paginate", request.toJson())); - Future getProfile(String userId) async { - final json = await _sendCommand("get_profile", {"user_id": userId}); - return .fromJsonWithCatch({...json, "id": userId}); + Future paginateManual(PaginateManualRequest request) async => + .fromJson(await _sendCommand("paginate_manual", request.toJson())); + + Future getEventContext(GetEventContextRequest request) async => + .fromJson(await _sendCommand("get_event_context", request.toJson())); + + Future getProfile(String userId) async { + try { + return .fromJson(await _sendCommand("get_profile", {"user_id": userId})); + } catch (_) { + return .new(profile: .new(id: userId)); + } } Future reportEvent(ReportRequest request) => @@ -269,6 +253,18 @@ class ClientController extends AsyncNotifier { Future setMembership(SetMembershipRequest request) => _sendCommand("set_membership", request.toJson()); + Future setAccountData(SetAccountDataRequest request) => + _sendCommand("set_account_data", request.toJson()); + + Future registerPusher(RegisterPusherRequest request) => + _sendCommand("register_homeserver_push", request.toJson()); + + Future deregisterPusher(DeregisterPusherRequest request) => + _sendCommand("register_homeserver_push", { + ...request.toJson(), + "kind": null, + }); + Future uploadMedia(UploadMediaRequest request) async => .fromJson(await _sendCommand("upload_media", request.toJson())); @@ -278,9 +274,11 @@ class ClientController extends AsyncNotifier { Future logout() => _sendCommand("logout"); Future markRead(Room room) async { + if (room.timeline.isEmpty || room.metadata == null) return; final eventRowId = room.timeline[room.timeline.keys.reduce(max)]; final event = eventRowId == null ? null : room.events[eventRowId]; - if (event == null || room.metadata == null) return; + + if (event == null) return; await _sendCommand("mark_read", { "room_id": room.metadata!.id, @@ -303,10 +301,16 @@ class ClientController extends AsyncNotifier { Future exchangeToken(OAuthExchangeTokenRequest request) async => await _sendCommand("oauth_exchange_token", request.toJson()); + Future getSpecVersions() async => + .fromJson(await _sendCommand("get_versions")); + + Future getCapabilities() async => + .fromJson((await _sendCommand("get_capabilities"))["capabilities"]); + Future discoverHomeserver(Uri homeserver) async { try { final response = await _sendCommand("discover_homeserver", { - "user_id": "@fake-user:${homeserver.host}", + "user_id": "@fake-user:${homeserver.authority}", }); return Uri.parse(response["m.homeserver"]?["base_url"]); } catch (error) { diff --git a/lib/controllers/client_id.dart b/lib/controllers/client_id.dart index ac8f5cb..7748f97 100644 --- a/lib/controllers/client_id.dart +++ b/lib/controllers/client_id.dart @@ -1,10 +1,7 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client.dart"; -class ClientIdController extends AsyncNotifier { - final Uri homeserver; - ClientIdController(this.homeserver); - +class ClientIdController(final Uri homeserver) extends AsyncNotifier { @override Future build() => ref .watch(ClientController.provider.notifier) diff --git a/lib/controllers/emoji.dart b/lib/controllers/emoji.dart deleted file mode 100644 index caea3de..0000000 --- a/lib/controllers/emoji.dart +++ /dev/null @@ -1,84 +0,0 @@ -import "dart:convert"; -import "package:emoji_text_field/models/emoji_category.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:http/http.dart"; -import "package:nexus/models/emoji.dart"; - -typedef EmojiTuple = (IMap, IMap>); - -class EmojiController extends AsyncNotifier { - @override - Future build() async { - final response = await get( - .https("github.com", "github/gemoji/raw/refs/heads/master/db/emoji.json"), - ); - - if (response.statusCode != 200) { - throw Exception("Failed to load emoji data"); - } - - final data = json.decode(response.body); - - final entries = (data as List) - .cast>() - .map(Emoji.fromJson) - .toIList(); - - final categoryMap = entries.fold>>( - .new(), - (acc, entry) => acc.update( - entry.category, - (list) => list.add(entry.emoji), - ifAbsent: () => .new([entry.emoji]), - ), - ); - - final keywordMap = entries.fold>>( - .new(), - (acc, entry) => acc.add( - entry.emoji, - .new([...entry.tags, ...entry.aliases, entry.description]), - ), - ); - - final customCategories = IMap.fromEntries( - categoryMap.entries.map( - (entry) => MapEntry( - entry.key, - EmojiCategory( - name: entry.key, - icon: switch (entry.key) { - "Smileys & Emotion" => Icons.emoji_emotions, - "People & Body" => Icons.emoji_people, - "Animals & Nature" => Icons.emoji_nature, - "Food & Drink" => Icons.emoji_food_beverage, - "Travel & Places" => Icons.travel_explore, - "Activities" => Icons.sports_soccer, - "Objects" => Icons.emoji_objects, - "Symbols" => Icons.emoji_symbols, - "Flags" => Icons.emoji_flags, - _ => Icons.category, - }, - emojis: entry.value.toList(growable: false), - ), - ), - ), - ); - - final customKeywords = IMap( - .fromEntries( - keywordMap.entries.map( - (e) => .new(e.key, e.value.toList(growable: false)), - ), - ), - ); - - return (customCategories, customKeywords); - } - - static final provider = AsyncNotifierProvider( - EmojiController.new, - ); -} diff --git a/lib/controllers/event.dart b/lib/controllers/event.dart index b7b417a..5c6513b 100644 --- a/lib/controllers/event.dart +++ b/lib/controllers/event.dart @@ -5,10 +5,8 @@ import "package:nexus/controllers/rooms.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/requests/get_event.dart"; -class EventController extends AsyncNotifier { - final GetEventRequest request; - EventController(this.request); - +class EventController(final GetEventRequest request) + extends AsyncNotifier { @override Future build() async { final room = ref.watch( @@ -18,11 +16,28 @@ class EventController extends AsyncNotifier { (event) => event.eventId == request.eventId, ); - return event ?? - await ref - .watch(ClientController.provider.notifier) - .getEvent(request) - .onError((_, _) => null); + if (event != null) return event; + + final fetchedEvent = await ref + .watch(ClientController.provider.notifier) + .getEvent(request) + .onError((_, _) => null); + + if (fetchedEvent != null) { + ref + .watch(RoomsController.provider.notifier) + .update( + .new({ + request.roomId: .new( + events: .new({fetchedEvent.rowId: fetchedEvent}), + ), + }), + ); + + return fetchedEvent; + } + + return null; } static final provider = AsyncNotifierProvider.family diff --git a/lib/controllers/gomuks_listener.dart b/lib/controllers/gomuks_listener.dart new file mode 100644 index 0000000..2a43676 --- /dev/null +++ b/lib/controllers/gomuks_listener.dart @@ -0,0 +1,123 @@ +import "dart:async"; +import "dart:ffi"; + +import "package:flutter/foundation.dart"; +import "package:nexus/controllers/account_data.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/init_complete.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/space_edges.dart"; +import "package:nexus/controllers/sync_status.dart"; +import "package:nexus/controllers/top_level_spaces.dart"; +import "package:nexus/helpers/extensions/gomuks_buffer.dart"; +import "package:nexus/main.dart"; +import "package:ffi/ffi.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/sync_data.dart"; +import "package:nexus/src/third_party/gomuks.g.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; + +class GomuksListenerController extends AsyncNotifier { + @override + Future build() async { + debugPrint("Starting gomuks..."); + final handle = await ref.watch(ClientController.provider.future); + + final callable = + NativeCallable< + Void Function(Pointer, Int64, GomuksOwnedBuffer) + >.listener(( + Pointer command, + int requestId, + GomuksOwnedBuffer data, + ) { + try { + final muksEventType = command.cast().toDartString(); + debugPrint("Handling $muksEventType..."); + final decodedMuksEvent = data.toJsonAndFree(); + + switch (muksEventType) { + case "client_state": + ref + .watch(ClientStateController.provider.notifier) + .set(.fromJson(decodedMuksEvent)); + break; + case "sync_status": + ref + .watch(SyncStatusController.provider.notifier) + .set(.fromJson(decodedMuksEvent)); + break; + case "init_complete": + ref.watch(InitCompleteController.provider.notifier).complete(); + break; + case "send_complete": + final event = Event.fromJson(decodedMuksEvent["event"]); + ref + .watch(RoomsController.provider.notifier) + .update( + .new({ + event.roomId: .new(events: .new({event.rowId: event})), + }), + ); + + break; + case "sync_complete": + final syncData = SyncData.fromJson(decodedMuksEvent); + final roomProvider = RoomsController.provider; + final accountDataProvider = AccountDataController.provider; + + if (syncData.clearState) { + ref.invalidate(roomProvider); + ref.invalidate(accountDataProvider); + } + + ref + .watch(roomProvider.notifier) + .update(syncData.rooms, syncData.leftRooms); + ref + .watch(accountDataProvider.notifier) + .update(syncData.accountData); + + if (syncData.topLevelSpaces != null) { + ref + .watch(TopLevelSpacesController.provider.notifier) + .set(syncData.topLevelSpaces!); + } + + if (syncData.spaceEdges != null) { + ref + .watch(SpaceEdgesController.provider.notifier) + .set(syncData.spaceEdges!); + } + + // ref + // .watch(SyncStatusController.provider.notifier) + // .set(SyncStatus.fromJson(decodedMuksEvent)); + break; + default: + debugPrint("Unhandled event: $muksEventType"); + } + debugPrint("Finished handling $muksEventType..."); + } catch (error, stackTrace) { + if (kDebugMode) { + debugPrintStack(stackTrace: stackTrace, label: error.toString()); + rethrow; + } else { + showError(error, stackTrace); + } + } + }); + + ref.onDispose(callable.close); + + final errorCode = GomuksStart(handle, callable.nativeFunction); + if (errorCode != 0) { + throw Exception("GomuksStart returned error code $errorCode"); + } + } + + static final provider = AsyncNotifierProvider( + GomuksListenerController.new, + ); +} diff --git a/lib/controllers/header.dart b/lib/controllers/header.dart deleted file mode 100644 index 3e173ad..0000000 --- a/lib/controllers/header.dart +++ /dev/null @@ -1,20 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client.dart"; -import "package:nexus/controllers/client_state.dart"; - -class HeaderController extends AsyncNotifier> { - @override - Future> build() async { - if (ref.watch(ClientStateController.provider)?.isLoggedIn != true) { - return {}; - } - final client = ref.watch(ClientController.provider.notifier); - final accessToken = await client.getAccessToken(); - return {"authorization": "Bearer $accessToken"}; - } - - static final provider = - AsyncNotifierProvider>( - HeaderController.new, - ); -} diff --git a/lib/controllers/jump_request.dart b/lib/controllers/jump_request.dart new file mode 100644 index 0000000..fc21ab2 --- /dev/null +++ b/lib/controllers/jump_request.dart @@ -0,0 +1,17 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; + +typedef JumpRequest = ({String roomId, int rowId}); + +class JumpRequestController extends Notifier { + @override + JumpRequest? build() => null; + + void request(String roomId, int rowId) => + state = (roomId: roomId, rowId: rowId); + + void consume() => state = null; + + static final provider = NotifierProvider( + JumpRequestController.new, + ); +} diff --git a/lib/controllers/key.dart b/lib/controllers/key.dart index eff3ab2..77b4883 100644 --- a/lib/controllers/key.dart +++ b/lib/controllers/key.dart @@ -1,30 +1,28 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/shared_prefs.dart"; -class KeyController extends Notifier { - final String key; - KeyController(this.key); - +class KeyController(final String key) extends AsyncNotifier { static const String spaceKey = "space"; static const String roomKey = "room"; + static const String pushKeyKey = "pushKey"; @override - String? build() => - ref.watch(SharedPrefsController.provider).requireValue.getString(key); + Future build() => + ref.watch(SharedPrefsController.provider).getString(key); Future set(String? value) async { - final prefs = ref.watch(SharedPrefsController.provider).requireValue; - state = value; + final prefs = ref.watch(SharedPrefsController.provider); + state = .data(value); if (value == null) { - prefs.remove(key); + await prefs.remove(key); } else { - prefs.setString(key, value); + await prefs.setString(key, value); } } static final provider = - NotifierProvider.family( + AsyncNotifierProvider.family( KeyController.new, ); } diff --git a/lib/controllers/member_list_opened.dart b/lib/controllers/member_list_opened.dart new file mode 100644 index 0000000..f0295d8 --- /dev/null +++ b/lib/controllers/member_list_opened.dart @@ -0,0 +1,20 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/shared_prefs.dart"; + +class MemberListOpenedController extends AsyncNotifier { + static const String key = "memberListOpened"; + + @override + Future build() async => + await ref.watch(SharedPrefsController.provider).getBool(key) ?? true; + + Future set(bool value) async { + state = .data(value); + await ref.watch(SharedPrefsController.provider).setBool(key, value); + } + + static final provider = + AsyncNotifierProvider( + MemberListOpenedController.new, + ); +} diff --git a/lib/controllers/members.dart b/lib/controllers/members.dart index 8566b40..d8172b1 100644 --- a/lib/controllers/members.dart +++ b/lib/controllers/members.dart @@ -6,10 +6,8 @@ import "package:nexus/models/content/content.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/requests/get_room_state.dart"; -class MembersController extends AsyncNotifier> { - final String roomId; - MembersController(this.roomId); - +class MembersController(final String roomId) + extends AsyncNotifier> { @override Future> build() async { final room = ref.watch( @@ -29,7 +27,7 @@ class MembersController extends AsyncNotifier> { ), ); - await ref + ref .read(RoomsController.provider.notifier) .addState(roomId, fetchedState, isMembers: true); } diff --git a/lib/controllers/members_by_status.dart b/lib/controllers/members_by_status.dart index 613f7b1..82905aa 100644 --- a/lib/controllers/members_by_status.dart +++ b/lib/controllers/members_by_status.dart @@ -5,10 +5,8 @@ import "package:nexus/models/configs/members_by_status.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/event.dart"; -class MembersByStatusController extends AsyncNotifier> { - final MembersByStatusConfig config; - MembersByStatusController(this.config); - +class MembersByStatusController(final MembersByStatusConfig config) + extends AsyncNotifier> { @override Future> build() => ref.watch( MembersController.provider(config.roomId).selectAsync( diff --git a/lib/controllers/members_grouped.dart b/lib/controllers/members_grouped.dart index 6f41e9d..f903ec4 100644 --- a/lib/controllers/members_grouped.dart +++ b/lib/controllers/members_grouped.dart @@ -8,11 +8,8 @@ import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/event.dart"; -class MembersGroupedController +class MembersGroupedController(final MembersByStatusConfig config) extends AsyncNotifier>>> { - final MembersByStatusConfig config; - MembersGroupedController(this.config); - @override Future>>> build() async { final room = ref.watch( diff --git a/lib/controllers/multi_provider.dart b/lib/controllers/multi_provider.dart index 52dd8d9..ab37d07 100644 --- a/lib/controllers/multi_provider.dart +++ b/lib/controllers/multi_provider.dart @@ -1,14 +1,15 @@ import "dart:async"; + import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -class MultiProviderController extends AsyncNotifier { - MultiProviderController(this.providers); - final IList providers; - +class MultiProviderController(final IList providers) + extends AsyncNotifier { @override - Future build() => - .wait(providers.map((provider) => ref.watch(provider.future))); + Future build() => .wait( + providers.map((provider) => ref.watch(provider.future)), + eagerError: true, + ); static final provider = AsyncNotifierProvider.family< diff --git a/lib/controllers/notification.dart b/lib/controllers/notification.dart new file mode 100644 index 0000000..fe631d4 --- /dev/null +++ b/lib/controllers/notification.dart @@ -0,0 +1,173 @@ +import "dart:async"; +import "dart:io"; + +import "package:flutter/services.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/portal.dart"; +import "package:nexus/main.dart"; +import "package:flutter_local_notifications/flutter_local_notifications.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/widgets/notifications_list.dart"; +import "package:xdg_desktop_portal/xdg_desktop_portal.dart"; + +class NotificationController + extends AsyncNotifier { + @override + Future build() async { + final notifications = FlutterLocalNotificationsPlugin(); + + if (!Platform.isLinux) { + final darwin = DarwinInitializationSettings(); + + await notifications.initialize( + settings: .new( + windows: .new( + appName: "Nexus", + appUserModelId: "nexus.federated.nexus", + guid: "dde78daf-130f-4e46-a80a-e31deeab45d7", + ), + android: .new("ic_launcher_foreground"), + iOS: darwin, + macOS: darwin, + ), + onDidReceiveNotificationResponse: (details) { + if (details.payload == null) return; + + if (int.tryParse(details.payload!) case final rowId?) { + if (navigatorKey.currentContext case final context?) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => NotificationsList( + highlightedEvent: rowId, + defaultToAllNotifications: true, + ), + ), + ); + } + } + }, + ); + } + + if (Platform.isLinux) { + const notificationChannel = MethodChannel("nexus/notifications"); + + notificationChannel.setMethodCallHandler((call) async { + if (call.method != "notificationClicked") { + return; + } + + if (int.tryParse(call.arguments) case int rowId) { + if (navigatorKey.currentContext case final context?) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => NotificationsList( + highlightedEvent: rowId, + defaultToAllNotifications: true, + ), + ), + ); + } + } + }); + + ref.onDispose(() => notificationChannel.setMethodCallHandler(null)); + } + + return notifications; + } + + Future requestPermissions() async { + if (Platform.isLinux) { + return true; + } + + final controller = await future; + + if (Platform.isIOS) { + return await controller + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >() + ?.requestPermissions(alert: true, badge: true, sound: true) ?? + true; + } else if (Platform.isMacOS) { + return await controller + .resolvePlatformSpecificImplementation< + MacOSFlutterLocalNotificationsPlugin + >() + ?.requestPermissions(alert: true, badge: true, sound: true) ?? + true; + } else if (Platform.isAndroid) { + return await controller + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.requestNotificationsPermission() ?? + true; + } + + return true; + } + + Future send({ + required int id, + required String title, + String? body, + File? icon, + String? payload, + }) async { + debugPrint("Sending notification for $id"); + + if (Platform.isLinux) { + final portal = await ref.watch(PortalController.provider.future); + + await portal.notification.addNotification( + id.toString(), + title: title, + body: body, + defaultAction: "app.event", + defaultActionTarget: payload, + category: XdgNotificationCategory.imReceived, + priority: .normal, + buttons: [ + // TODO: Impl when notif daemons actualy support imReplyWithText + // .new( + // label: "Reply", + // action: "app.reply", + // purpose: XdgNotificationButtonPurpose.imReplyWithText, + // ), + ], + ); + } else { + final notificationDetails = NotificationDetails( + android: .new( + "messages", + "Messages", + largeIcon: icon == null ? null : FilePathAndroidBitmap(icon.path), + ), + // TODO: See if icons can be added to iOS, macOS, and Windows + // notifications (#68) + iOS: .new(), + macOS: .new(), + windows: .new(), + ); + + final controller = await future; + + await controller.show( + id: id, + title: title, + body: body, + notificationDetails: notificationDetails, + payload: payload, + ); + } + } + + static final provider = + AsyncNotifierProvider< + NotificationController, + FlutterLocalNotificationsPlugin + >(NotificationController.new); +} diff --git a/lib/controllers/notifications.dart b/lib/controllers/notifications.dart new file mode 100644 index 0000000..cd10dff --- /dev/null +++ b/lib/controllers/notifications.dart @@ -0,0 +1,93 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/models/event.dart"; + +typedef NotificationsRequest = (UnreadType? unreadType, String? roomId); + +class NotificationsController(final NotificationsRequest request) + extends AsyncNotifier> { + void storeEvents(Iterable events) => ref + .read(RoomsController.provider.notifier) + .update( + .new( + events + .groupListsBy((event) => event.roomId) + .map( + (roomId, roomEvents) => .new( + roomId, + .new( + events: IMap.fromIterable( + roomEvents, + keyMapper: (event) => event.rowId, + ), + ), + ), + ), + ), + ); + + @override + Future> build() async { + final client = ref.read(ClientController.provider.notifier); + + final (unreadType, roomId) = request; + + final mentions = await client.getMentions( + .new( + maxTimestamp: .now(), + unreadType: unreadType ?? .highlight, + roomId: roomId, + ), + ); + + storeEvents(mentions); + + return .new(mentions.map((event) => (event.rowId, event.roomId))); + } + + Future loadOlder() async { + final currentNotifications = await future; + state = .loading(); + state = await .guard(() async { + final lastNotification = currentNotifications.lastOrNull; + final lastTs = lastNotification == null + ? null + : ref.watch( + RoomsController.provider.select( + (rooms) => rooms[lastNotification.$2] + ?.events[lastNotification.$1] + ?.timestamp, + ), + ); + + if (lastTs == null) return currentNotifications; + + final client = ref.read(ClientController.provider.notifier); + final (unreadType, roomId) = request; + + final newNotifications = await client.getMentions( + .new( + maxTimestamp: lastTs, + unreadType: unreadType ?? .highlight, + roomId: roomId, + ), + ); + + storeEvents(newNotifications); + + return currentNotifications.addAll( + newNotifications.map((event) => (event.rowId, event.roomId)), + ); + }); + } + + static final provider = AsyncNotifierProvider.family + .autoDispose< + NotificationsController, + IList<(int, String)>, + NotificationsRequest + >(NotificationsController.new); +} diff --git a/lib/controllers/pinned_events.dart b/lib/controllers/pinned_events.dart index 914a301..d0d7bb5 100644 --- a/lib/controllers/pinned_events.dart +++ b/lib/controllers/pinned_events.dart @@ -2,29 +2,25 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/event.dart"; import "package:nexus/controllers/pinned_ids.dart"; -import "package:nexus/models/event.dart"; - -class PinnedEventsController extends AsyncNotifier> { - final String roomId; - PinnedEventsController(this.roomId); +class PinnedEventsController(final String roomId) + extends AsyncNotifier> { @override - Future> build() async { + Future> build() async { final pinIds = ref.watch(PinnedIdsController.provider(roomId)); return (await Future.wait( pinIds.map( (eventId) => ref.watch( - EventController.provider( - .new(eventId: eventId, roomId: roomId), - ).future, + EventController.provider(.new(eventId: eventId, roomId: roomId)) + .selectAsync((data) => data?.rowId), ), ), )).nonNulls.toIList(); } static final provider = AsyncNotifierProvider.family - .autoDispose, String>( + .autoDispose, String>( PinnedEventsController.new, ); } diff --git a/lib/controllers/pinned_ids.dart b/lib/controllers/pinned_ids.dart index 3d1e5da..01358a9 100644 --- a/lib/controllers/pinned_ids.dart +++ b/lib/controllers/pinned_ids.dart @@ -5,10 +5,7 @@ import "package:nexus/controllers/rooms.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/pinned_events.dart"; -class PinnedIdsController extends Notifier> { - final String roomId; - PinnedIdsController(this.roomId); - +class PinnedIdsController(final String roomId) extends Notifier> { @override IList build() { final room = ref.watch( diff --git a/lib/controllers/portal.dart b/lib/controllers/portal.dart new file mode 100644 index 0000000..daae24a --- /dev/null +++ b/lib/controllers/portal.dart @@ -0,0 +1,22 @@ +import "dart:io"; + +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:xdg_desktop_portal/xdg_desktop_portal.dart"; + +class PortalController extends AsyncNotifier { + @override + Future build() async { + final portal = XdgDesktopPortalClient(); + if (!await File("/.flatpak-info").exists()) { + await portal.registerApplication("nexus.federated.nexus"); + } + + ref.onDispose(portal.close); + return portal; + } + + static final provider = + AsyncNotifierProvider( + PortalController.new, + ); +} diff --git a/lib/controllers/power_level.dart b/lib/controllers/power_level.dart index 2f0c72e..7111e38 100644 --- a/lib/controllers/power_level.dart +++ b/lib/controllers/power_level.dart @@ -6,10 +6,8 @@ import "package:nexus/models/configs/power_level.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/power_levels.dart"; -class PowerLevelController extends Notifier { - final PowerLevelConfig config; - PowerLevelController(this.config); - +class PowerLevelController(final PowerLevelConfig config) + extends Notifier { @override bool build() { if (config case EventPowerLevelConfig(:final eventType)) { diff --git a/lib/controllers/profile.dart b/lib/controllers/profile.dart index 32525a3..7a1477c 100644 --- a/lib/controllers/profile.dart +++ b/lib/controllers/profile.dart @@ -1,17 +1,17 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client.dart"; -import "package:nexus/models/profile.dart"; - -class ProfileController extends AsyncNotifier { - final String userId; - ProfileController(this.userId); +import "package:nexus/models/profile_response.dart"; +class ProfileController(final String userId) + extends AsyncNotifier { @override - Future build() { - final client = ref.watch(ClientController.provider.notifier); + Future build() { + final client = ref.read(ClientController.provider.notifier); return client.getProfile(userId); } static final provider = AsyncNotifierProvider.family - .autoDispose(ProfileController.new); + .autoDispose( + ProfileController.new, + ); } diff --git a/lib/controllers/push_key.dart b/lib/controllers/push_key.dart new file mode 100644 index 0000000..e3f6b82 --- /dev/null +++ b/lib/controllers/push_key.dart @@ -0,0 +1,31 @@ +import "dart:convert"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/key.dart"; + +class PushKeyController(final String instance) extends AsyncNotifier { + @override + Future build() async => json.decode( + await ref.watch(KeyController.provider(KeyController.pushKeyKey).future) ?? + "{}", + )[instance]; + + Future set(String? value) async { + final provider = KeyController.provider(KeyController.pushKeyKey); + final notifier = ref.watch(provider.notifier); + final prefs = IMap(json.decode(await ref.watch(provider.future) ?? "{}")); + + state = .data(value); + notifier.set( + json.encode( + prefs.add(instance, value).where((_, value) => value != null).unlock, + ), + ); + } + + static final provider = + AsyncNotifierProvider.family( + PushKeyController.new, + ); +} diff --git a/lib/controllers/reactions.dart b/lib/controllers/reactions.dart index 615a59d..9a92843 100644 --- a/lib/controllers/reactions.dart +++ b/lib/controllers/reactions.dart @@ -5,10 +5,8 @@ import "package:nexus/controllers/rooms.dart"; import "package:nexus/models/configs/reactions.dart"; import "package:nexus/models/content/reaction.dart"; -class ReactionsController extends AsyncNotifier>> { - final ReactionsConfig config; - ReactionsController(this.config); - +class ReactionsController(final ReactionsConfig config) + extends AsyncNotifier>> { @override Future>> build() async { final eventInfo = ref.watch( diff --git a/lib/controllers/recent_emoji.dart b/lib/controllers/recent_emoji.dart new file mode 100644 index 0000000..d814a79 --- /dev/null +++ b/lib/controllers/recent_emoji.dart @@ -0,0 +1,57 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_emoji_picker/material_emoji_picker.dart"; +import "package:nexus/controllers/account_data.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/models/account_data.dart"; + +class RecentEmojiController extends Notifier> { + @override + IList build() { + final Set emoji = ref + .watch(EmojiController.provider) + .requireValue + .map((element) => element.emojis) + .flattened + .map((e) => e.value) + .toSet(); + + final IList recents = ref.watch( + AccountDataController.provider.select( + (value) => value.recentEmoji.recentEmoji, + ), + ); + + return .new(recents.where((recent) => emoji.contains(recent.emoji))); + } + + Future add(String emoji) => ref + .watch(ClientController.provider.notifier) + .setAccountData( + .new( + type: AccountData.recentEmojiKey, + content: RecentEmojiData( + recentEmoji: .new([ + .new( + emoji: emoji, + total: + (state + .firstWhereOrNull( + (element) => element.emoji == emoji, + ) + ?.total ?? + 0) + + 1, + ), + ...state.whereNot((element) => element.emoji == emoji), + ]), + ), + ), + ); + + static final provider = + NotifierProvider.autoDispose>( + RecentEmojiController.new, + ); +} diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 558817c..1576401 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -1,5 +1,6 @@ import "dart:async"; import "dart:math"; + import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; @@ -9,59 +10,81 @@ import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/reaction.dart"; +import "package:nexus/models/direction.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/requests/redact_event.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/models/requests/send_message.dart"; import "package:nexus/models/room.dart"; +import "package:nexus/models/room_chat.dart"; -class RoomChatController extends AsyncNotifier?> { - final String roomId; - RoomChatController(this.roomId); +typedef ChatParams = ({String roomId, int? contextualEvent}); +class RoomChatController(final ChatParams info) + extends AsyncNotifier { @override - Future?> build() async { - final client = ref.watch(ClientController.provider.notifier); - final room = ref.watch( - RoomsController.provider.select((rooms) => rooms[roomId]), + Future build() async { + final client = ref.read(ClientController.provider.notifier); + final room = ref.read( + RoomsController.provider.select((rooms) => rooms[info.roomId]), ); if (room == null) return null; if (!room.hasFetchedState) { - final state = await client.getRoomState(.new(roomId: roomId)); - - await ref.read(RoomsController.provider.notifier).addState(roomId, state); + final state = await client.getRoomState(.new(roomId: info.roomId)); + ref.read(RoomsController.provider.notifier).addState(info.roomId, state); } - // While there are under 20 events, try to load more - // until there's no more or the conditions are met. - if (room.hasMore && room.timeline.length < 20) { - loadOlder(); - } - - return room.timeline + final timeline = room.timeline .toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0)) - .map((element) => element.value) + .map((e) => e.value) .toIList() - .addAll(room.sticky) - .map((entry) { - final foundEvent = entry == null ? null : room.events[entry]; - - final editedEvent = - foundEvent == null || foundEvent.lastEditRowId == 0 - ? null - : room.events[foundEvent.lastEditRowId]; - - return editedEvent == null - ? foundEvent - : foundEvent?.copyWith( - content: editedEvent.content, - localContent: editedEvent.localContent, - ); - }) + .addAll(room.clientSticky) .nonNulls .toIList(); + if (info.contextualEvent == null || + timeline.contains(info.contextualEvent)) { + ref.watch( + RoomsController.provider.select( + (rooms) => + (rooms[info.roomId]?.timeline, rooms[info.roomId]?.clientSticky), + ), + ); + + return .new( + timeline: timeline, + hasMoreBackward: room.hasMore, + hasMoreForward: false, + ); + } else { + final context = await client.getEventContext( + .new( + roomId: info.roomId, + eventId: room.events[info.contextualEvent!]!.eventId, + ), + ); + + final events = context.before.add(context.event).addAll(context.after); + ref + .read(RoomsController.provider.notifier) + .update( + .new({ + info.roomId: Room( + events: IMap.fromIterable( + events, + keyMapper: (event) => event.rowId, + ), + ), + }), + ); + return .new( + timeline: .new(events.map((element) => element.rowId)), + hasMoreBackward: true, + hasMoreForward: true, + historicalData: .new(start: context.start, end: context.end), + ); + } } Future deleteMessage(Event event, {String? reason}) => ref @@ -69,49 +92,136 @@ class RoomChatController extends AsyncNotifier?> { .redactEvent( RedactEventRequest( eventId: event.eventId, - roomId: roomId, + roomId: info.roomId, reason: reason, ), ); - Future loadOlder() async { - final timelineKeys = ref - .read(RoomsController.provider.select((value) => value[roomId])) - ?.timeline - .keys; - final response = await ref - .watch(ClientController.provider.notifier) - .paginate( - .new( - roomId: roomId, - maxTimelineId: timelineKeys?.isNotEmpty == true - ? timelineKeys?.reduce(min) - : null, + void hideClientSticky(int eventRowId) => ref + .read(RoomsController.provider.notifier) + .update( + .new({ + info.roomId: Room( + resetClientSticky: true, + clientSticky: + ref + .read( + RoomsController.provider.select( + (rooms) => rooms[info.roomId]?.clientSticky, + ), + ) + ?.remove(eventRowId) ?? + .new(), ), - ); + }), + ); - ref - .watch(RoomsController.provider.notifier) - .update( - IMap({ - roomId: Room( - events: IMap.fromIterable( - response.events.addAll(response.relatedEvents), - keyMapper: (event) => event.rowId, - valueMapper: (event) => event, - ), - hasMore: response.hasMore, - timeline: IMap.fromIterable( - response.events, - keyMapper: (event) => event.timelineRowId, - valueMapper: (event) => event.rowId, - ), - ), - }), - .new(), - ); + Future paginate(Direction direction) async { + if (state.isLoading) return; - return response.hasMore; + final chat = await future; + + if (direction == .forward + ? chat?.hasMoreForward == false + : chat?.hasMoreBackward == false) { + return; + } + + state = .loading(); + + final client = ref.read(ClientController.provider.notifier); + + if (chat?.historicalData == null) { + final timelineKeys = ref + .read(RoomsController.provider.select((value) => value[info.roomId])) + ?.timeline + .keys; + final response = await client.paginate( + .new( + roomId: info.roomId, + maxTimelineId: timelineKeys?.isNotEmpty == true + ? timelineKeys?.reduce(min) + : null, + ), + ); + + if (response.events.isEmpty) { + state = .data(state.value); + } + + ref + .read(RoomsController.provider.notifier) + .update( + IMap({ + info.roomId: Room( + events: IMap.fromIterable( + response.events.addAll(response.relatedEvents), + keyMapper: (event) => event.rowId, + valueMapper: (event) => event, + ), + hasMore: response.hasMore, + timeline: IMap.fromIterable( + response.events, + keyMapper: (event) => event.timelineRowId, + valueMapper: (event) => event.rowId, + ), + ), + }), + ); + } else { + final paginationResponse = await client.paginateManual( + .new( + roomId: info.roomId, + direction: direction, + since: direction == .forward + ? chat!.historicalData!.end + : chat!.historicalData!.start, + ), + ); + + ref + .read(RoomsController.provider.notifier) + .update( + .new({ + info.roomId: Room( + events: IMap.fromIterable( + paginationResponse.events, + keyMapper: (event) => event.rowId, + ), + ), + }), + ); + + final eventRowIds = paginationResponse.events + .map((element) => element.rowId) + .toIList(); + + state = .data( + .new( + timeline: direction == .forward + ? chat.timeline.addAll(eventRowIds) + : eventRowIds.addAll(chat.timeline), + hasMoreForward: + direction == .forward && paginationResponse.nextBatch == null + ? false + : chat.hasMoreForward, + hasMoreBackward: + direction == .backward && paginationResponse.nextBatch == null + ? false + : chat.hasMoreBackward, + historicalData: chat.historicalData?.copyWith( + start: + (direction == .backward + ? paginationResponse.nextBatch + : null) ?? + chat.historicalData!.start, + end: + (direction == .forward ? paginationResponse.nextBatch : null) ?? + chat.historicalData!.end, + ), + ), + ); + } } Future send( @@ -119,17 +229,23 @@ class RoomChatController extends AsyncNotifier?> { bool shouldMention = true, required IList tags, required RelationType relationType, - Event? relation, + int? relatedEventRowId, }) async { Content? baseContent; - if (relationType == .edit) { - baseContent = relation?.content; - } else { - final attachment = ref.watch(AttachmentController.provider(roomId)); - if (attachment != null && attachment.$2 == null) return; + final relatedEvent = relatedEventRowId == null + ? null + : ref.watch( + RoomsController.provider.select( + (rooms) => rooms[info.roomId]?.events[relatedEventRowId], + ), + ); - baseContent = attachment?.$2; - ref.invalidate(AttachmentController.provider(roomId)); + if (relationType == .edit && relatedEventRowId != null) { + baseContent = relatedEvent?.content; + } else { + final provider = AttachmentController.provider(info.roomId); + baseContent = ref.read(provider)?.$2; + ref.invalidate(provider); } var taggedMessage = text; @@ -144,37 +260,36 @@ class RoomChatController extends AsyncNotifier?> { ); } - final client = ref.watch(ClientController.provider.notifier); + final client = ref.read(ClientController.provider.notifier); final event = await client.sendMessage( SendMessageRequest( - roomId: roomId, + roomId: info.roomId, baseContent: baseContent, mentions: Mentions( userIds: [ if (shouldMention == true && - relation != null && + relatedEvent != null && relationType == RelationType.reply) - relation.sender, + relatedEvent.sender, ].toIList(), room: taggedMessage.contains("@room"), ), text: taggedMessage, - relation: relation == null + relation: relatedEvent == null ? null - : .new(eventId: relation.eventId, relationType: relationType), + : .new(eventId: relatedEvent.eventId, relationType: relationType), ), ); ref - .watch(RoomsController.provider.notifier) + .read(RoomsController.provider.notifier) .update( .new({ - roomId: .new( + info.roomId: .new( events: .new({event.rowId: event}), - sticky: .new({event.rowId}), + clientSticky: .new({event.rowId}), ), }), - .new(), ); } @@ -183,10 +298,10 @@ class RoomChatController extends AsyncNotifier?> { Event event, String userId, ) async { - final client = ref.watch(ClientController.provider.notifier); + final client = ref.read(ClientController.provider.notifier); final allReactionEvents = await client.getRelatedEvents( .new( - roomId: roomId, + roomId: info.roomId, eventId: event.eventId, relationType: "m.annotation", ), @@ -207,16 +322,18 @@ class RoomChatController extends AsyncNotifier?> { if (reactionEvent != null) { await ref .watch(ClientController.provider.notifier) - .redactEvent(.new(eventId: reactionEvent.eventId, roomId: roomId)); + .redactEvent( + .new(eventId: reactionEvent.eventId, roomId: info.roomId), + ); } } Future sendReaction(String reaction, Event event) async { - final client = ref.watch(ClientController.provider.notifier); + final client = ref.read(ClientController.provider.notifier); await client.sendEvent( .new( - roomId: roomId, + roomId: info.roomId, type: EventType.reaction.type, content: ReactionContent(key: reaction), synchronous: true, @@ -228,7 +345,7 @@ class RoomChatController extends AsyncNotifier?> { } static final provider = AsyncNotifierProvider.family - .autoDispose?, String>( + .autoDispose( RoomChatController.new, ); } diff --git a/lib/controllers/room_creators.dart b/lib/controllers/room_creators.dart index 7db72c2..09acdd7 100644 --- a/lib/controllers/room_creators.dart +++ b/lib/controllers/room_creators.dart @@ -4,10 +4,7 @@ import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/create.dart"; import "package:nexus/models/room.dart"; -class RoomCreatorsController extends Notifier> { - final Room room; - RoomCreatorsController(this.room); - +class RoomCreatorsController(final Room room) extends Notifier> { @override IList build() { final createRowId = room.state[EventType.create.type]?[""]; diff --git a/lib/controllers/room_summary.dart b/lib/controllers/room_summary.dart new file mode 100644 index 0000000..e5b3174 --- /dev/null +++ b/lib/controllers/room_summary.dart @@ -0,0 +1,16 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/models/requests/join_room.dart"; +import "package:nexus/models/room_summary.dart"; + +class RoomSummaryController(final JoinRoomRequest request) + extends AsyncNotifier { + @override + Future build() => + ref.read(ClientController.provider.notifier).getRoomSummary(request); + + static final provider = AsyncNotifierProvider.family + .autoDispose( + RoomSummaryController.new, + ); +} diff --git a/lib/controllers/rooms.dart b/lib/controllers/rooms.dart index d0c6eb9..7b86f2a 100644 --- a/lib/controllers/rooms.dart +++ b/lib/controllers/rooms.dart @@ -1,4 +1,4 @@ -import "dart:isolate"; +import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/models/event.dart"; @@ -8,35 +8,35 @@ class RoomsController extends Notifier> { @override IMap build() => .new(); - Future addState( + void addState( String roomId, IList state, { bool isMembers = false, - }) async => update( + }) => update( .new({ roomId: Room( events: .fromEntries(state.map((event) => .new(event.rowId, event))), hasFetchedState: true, hasFetchedMembers: isMembers, - state: await Isolate.run(() { - final newState = state.fold>>( - .new(), - (previousValue, stateEvent) => previousValue.add( - stateEvent.type, - (previousValue[stateEvent.type] ?? .new()).add( - stateEvent.stateKey!, - stateEvent.rowId, + state: .new( + state + .groupListsBy((event) => event.type) + .map( + (type, group) => .new( + type, + .fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))), + ), ), - ), - ); - return newState; - }), + ), ), }), .new(), ); - void update(IMap rooms, ISet leftRooms) { + void update( + IMap rooms, [ + ISet leftRooms = const ISet.empty(), + ]) { final merged = rooms.entries.fold(state, (acc, entry) { final roomId = entry.key; final incoming = entry.value; @@ -46,13 +46,16 @@ class RoomsController extends Notifier> { roomId, existing?.copyWith( hasMore: incoming.hasMore, - sticky: - (incoming.sticky.isEmpty == true - ? existing.sticky - : existing.sticky.addAll(incoming.sticky)) - .removeWhere( - (rowId) => incoming.timeline.values.contains(rowId), - ), + clientSticky: incoming.resetClientSticky + ? incoming.clientSticky + : (incoming.clientSticky.isEmpty == true + ? existing.clientSticky + : existing.clientSticky.addAll( + incoming.clientSticky, + )) + .removeWhere( + (rowId) => incoming.timeline.values.contains(rowId), + ), metadata: incoming.metadata ?? existing.metadata, events: incoming.events.isEmpty ? existing.events @@ -64,6 +67,7 @@ class RoomsController extends Notifier> { (previousValue[event.key] ?? .new()).addAll(event.value), ), ), + resetClientSticky: false, reset: false, hasFetchedMembers: incoming.hasFetchedMembers || existing.hasFetchedMembers, diff --git a/lib/controllers/settings.dart b/lib/controllers/settings.dart index 572d2e9..8c107e3 100644 --- a/lib/controllers/settings.dart +++ b/lib/controllers/settings.dart @@ -9,14 +9,14 @@ class SettingsController extends AsyncNotifier { final file = await ref.watch(SettingsFileController.provider.future); try { - return Settings.fromJson(json.decode(await file.readAsString())); + return .fromJson(json.decode(await file.readAsString())); } catch (_) { - return Settings(); + return .new(); } } Future set(Settings settings) async { - state = AsyncData(settings); + state = .data(settings); final file = await ref.watch(SettingsFileController.provider.future); await file.writeAsString(json.encode(settings.toJson())); } diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 7c9796b..073019a 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -1,12 +1,21 @@ import "dart:io"; + import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; import "package:intl/intl.dart"; import "package:m3e_buttons/m3e_buttons.dart"; +import "package:nexus/controllers/account_data.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/init_complete.dart"; +import "package:nexus/controllers/notification.dart"; import "package:nexus/controllers/settings.dart"; +import "package:nexus/controllers/unified_push.dart"; +import "package:nexus/controllers/spec_versions.dart"; +import "package:nexus/controllers/unified_push_allowed.dart"; +import "package:nexus/models/account_data.dart"; import "package:nexus/models/settings_category.dart"; import "package:nexus/main.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart"; @@ -44,8 +53,7 @@ class SettingsSectionsController .new( title: "Use Dynamic Theme", icon: Icons.palette, - description: - "Toggle on or off Dynamic Theme. Only available on Android, Linux, Windows, or MacOS.", + description: "Toggle on or off Dynamic Theme. Only available on Android, Linux, Windows, or MacOS.", builder: (title, description, icon) => SwitchListTile( title: Text(title), subtitle: Text(description), @@ -69,10 +77,41 @@ class SettingsSectionsController title: "Behavior", icon: Icons.psychology, settings: .new([ + .new( + title: "All Rooms In Home", + description: "Show all rooms in home, instead of just ones that aren't in spaces", + icon: Icons.home, + builder: (title, description, icon) => SwitchListTile( + title: Text(title), + subtitle: Text(description), + secondary: Icon(icon), + value: settings.showAllInHome, + onChanged: (value) => ref + .watch(SettingsController.provider.notifier) + .set(settings.copyWith(showAllInHome: value)) + .onError(showError), + ), + ), + if (!Platform.isAndroid && !Platform.isIOS) + .new( + title: "Show Minimize Button", + description: + "Enable this to add a minimize button to the appbar", + icon: Icons.minimize, + builder: (title, description, icon) => SwitchListTile( + title: Text(title), + subtitle: Text(description), + secondary: Icon(icon), + value: settings.showMinimizeButton, + onChanged: (value) => ref + .watch(SettingsController.provider.notifier) + .set(settings.copyWith(showMinimizeButton: value)) + .onError(showError), + ), + ), .new( title: "Linux Mobile Mode", - description: - "Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window.", + description: "Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window.", icon: Icons.construction, builder: (title, description, icon) => SwitchListTile( title: Text(title), @@ -93,6 +132,146 @@ class SettingsSectionsController if (ref.watch(ClientStateController.provider)?.isLoggedIn == true) "Account": .new([ .new(title: "Profile", icon: Icons.person, settings: .new([])), + .new( + title: "Notifications", + icon: Icons.notifications, + settings: .new([ + .new( + title: "Push notifications", + description: "Enable push notifications using Web Push", + builder: (title, description, icon) => HookConsumer( + builder: (context, ref, _) { + final loading = useState(false); + final pusherRegistered = ref.watch( + UnifiedPushController.provider, + ); + final unifiedPushAllowed = ref.watch( + UnifiedPushAllowedController.provider, + ); + + return SwitchListTile( + title: Text(title), + subtitle: Text( + unifiedPushAllowed.maybeWhen( + data: (data) => data, + orElse: () => null, + ) ?? + description, + ), + secondary: Icon(icon), + value: pusherRegistered.maybeWhen( + data: (value) => value, + orElse: () => false, + ), + onChanged: loading.value + ? null + : unifiedPushAllowed.maybeWhen( + data: (data) => data == null, + orElse: () => false, + ) + ? pusherRegistered.maybeWhen( + data: (_) => (value) async { + try { + loading.value = true; + if (value) { + if (await ref + .watch( + NotificationController + .provider + .notifier, + ) + .requestPermissions()) { + await ref + .watch( + UnifiedPushController + .provider + .notifier, + ) + .register(); + } else { + // TODO: Handle not granted + } + } else { + await ref + .watch( + UnifiedPushController + .provider + .notifier, + ) + .deregister(); + } + } catch (error, stackTrace) { + showError(error, stackTrace); + } finally { + loading.value = false; + } + }, + orElse: () => null, + ) + : null, + ); + }, + ), + icon: Icons.notification_add, + ), + ]), + ), + .new( + title: "Safety", + icon: Icons.gpp_good, + settings: .new([ + .new( + title: "Invite Blocking", + description: "Block invites, either completely, or block only invites from users without shared private rooms (depends on server support).", + builder: (title, description, icon) => Consumer( + builder: (context, ref, _) { + final specVersionsResponse = ref.watch( + SpecVersionsController.provider, + ); + return DialogListTile( + icon: Icon(icon), + title: title, + subtitle: Text(description), + initialValue: ref + .watch(AccountDataController.provider) + .invitePermissionConfig + .defaultAction, + options: + specVersionsResponse.maybeWhen( + data: (data) => data.unstableFeatures.msc4494, + orElse: () => true, + ) + ? DefaultInviteAction.values + : IList(DefaultInviteAction.values) + .remove(.denyPublic) + .toList(), + getName: (option) => switch (option) { + .allow => "Allow", + .deny => "Deny", + .denyPublic => "Deny public", + }, + onChanged: specVersionsResponse.maybeWhen( + data: (_) => + (value) => ref + .watch(ClientController.provider.notifier) + .setAccountData( + .new( + type: AccountData.invitePermissionConfigKey, + content: InvitePermissionConfig( + defaultAction: value, + ), + ), + ) + .onError(showError), + orElse: () => null, + ), + ); + }, + ), + icon: Icons.person_off, + ), + ]), + ), .new( title: "Other", icon: Icons.key, @@ -106,15 +285,21 @@ class SettingsSectionsController final colorScheme = Theme.of(context).colorScheme; return M3EButton.icon( onPressed: () async { - Navigator.of( - context, - ).popUntil((route) => route.isFirst); + final container = ProviderScope.containerOf(context); - await WidgetsBinding.instance.endOfFrame; + final route = ModalRoute.of(context)!; + Navigator.of(context).popUntil((r) => r.isFirst); + await route.completed; - await ref - .watch(ClientController.provider.notifier) - .logout(); + await container + .read(UnifiedPushController.provider.notifier) + .deregister() + .onError(showError); + container.invalidate(InitCompleteController.provider); + await container + .read(ClientController.provider.notifier) + .logout() + .onError(showError); }, label: Text(title), icon: Icon(icon), diff --git a/lib/controllers/shared_prefs.dart b/lib/controllers/shared_prefs.dart index 876fc47..9ecb34a 100644 --- a/lib/controllers/shared_prefs.dart +++ b/lib/controllers/shared_prefs.dart @@ -1,12 +1,12 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:shared_preferences/shared_preferences.dart"; -class SharedPrefsController extends AsyncNotifier { +class SharedPrefsController extends Notifier { @override - Future build() async => .getInstance(); + SharedPreferencesAsync build() => SharedPreferencesAsync(); static final provider = - AsyncNotifierProvider( + NotifierProvider( SharedPrefsController.new, ); } diff --git a/lib/controllers/spaces.dart b/lib/controllers/spaces.dart index 24113ad..e86cfe1 100644 --- a/lib/controllers/spaces.dart +++ b/lib/controllers/spaces.dart @@ -1,18 +1,19 @@ import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/account_data.dart"; import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/top_level_spaces.dart"; import "package:nexus/controllers/space_edges.dart"; import "package:nexus/models/room.dart"; import "package:nexus/models/space.dart"; import "package:nexus/models/subspace.dart"; -class SpacesController extends Notifier> { +class SpacesController extends AsyncNotifier> { @override - IList build() { + Future> build() async { final rooms = ref.watch(RoomsController.provider); final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider); final spaceEdges = ref.watch(SpaceEdgesController.provider); @@ -77,21 +78,21 @@ class SpacesController extends Notifier> { final spaces = topLevelSpaceIds.map(buildSpace).toIList(); - final usedRoomIds = { + final usedRooms = { for (final space in spaces) ...[ - ...space.children.map((r) => r.metadata?.id), - ...space.subSpaces.expand((s) => s.children.map((r) => r.metadata?.id)), + ...space.children, + ...space.subSpaces.expand((s) => s.children), ], }.nonNulls.toISet(); - final directMessages = IMap( - accountData["m.direct"]?.content ?? {}, - ).values.expand((e) => e).toISet(); + final directMessages = accountData.directMessages.values.flattened; final otherRooms = rooms.entries .where( (e) => - !usedRoomIds.contains(e.key) && + !usedRooms + .map((element) => element.metadata?.id) + .contains(e.key) && !topLevelSpaceIds.contains(e.key) && !childrenById.containsKey(e.key), ) @@ -99,6 +100,15 @@ class SpacesController extends Notifier> { .toIList(); final homeRooms = otherRooms + .addAll( + await ref.watch( + SettingsController.provider.selectAsync( + (data) => data.showAllInHome, + ), + ) + ? usedRooms + : .empty(), + ) .where((r) => !directMessages.contains(r.metadata?.id)) .toIList(); @@ -145,7 +155,7 @@ class SpacesController extends Notifier> { .toIList(); } - static final provider = NotifierProvider>( + static final provider = AsyncNotifierProvider>( SpacesController.new, ); } diff --git a/lib/controllers/spec_versions.dart b/lib/controllers/spec_versions.dart new file mode 100644 index 0000000..d2316bd --- /dev/null +++ b/lib/controllers/spec_versions.dart @@ -0,0 +1,15 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/models/spec_versions_response.dart"; + +class SpecVersionsController extends AsyncNotifier { + @override + Future build() => + ref.read(ClientController.provider.notifier).getSpecVersions(); + + static final provider = + AsyncNotifierProvider.autoDispose< + SpecVersionsController, + SpecVersionsResponse + >(SpecVersionsController.new); +} diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart new file mode 100644 index 0000000..97849fe --- /dev/null +++ b/lib/controllers/unified_push.dart @@ -0,0 +1,222 @@ +import "dart:async"; +import "dart:convert"; +import "dart:io"; + +import "package:flutter/foundation.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:intl/intl.dart"; +import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/notification.dart"; +import "package:nexus/controllers/push_key.dart"; +import "package:nexus/main.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/content/sticker.dart"; +import "package:unifiedpush/unifiedpush.dart"; +import "package:unifiedpush_storage_shared_preferences/storage.dart"; +import "package:window_manager/window_manager.dart"; + +class UnifiedPushController extends AsyncNotifier { + Completer? _endpoint; + + @override + Future build() async { + if (!Platform.isLinux && !Platform.isAndroid) return false; + + final registered = await UnifiedPush.initialize( + linuxOptions: .new( + dbusName: "nexus.federated.nexus.UnifiedPush", + storage: UnifiedPushStorageSharedPreferences(), + background: isInBackground, + shouldWriteService: false, + ), + onNewEndpoint: (endpoint, instance) async { + final task = Future(() async { + final pushKey = endpoint.pubKeySet!.pubKey; + await ref + .read(PushKeyController.provider(instance).notifier) + .set(pushKey); + + await ref + .read(ClientController.provider.notifier) + .registerPusher( + .new( + appDisplayName: "Nexus", + appId: "nexus.federated.nexus", + data: .webPush( + url: .parse(endpoint.url), + auth: endpoint.pubKeySet!.auth, + ), + deviceDisplayName: + "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}", + kind: .webPush, + lang: "en", + pushKey: pushKey, + ), + ); + + state = .data(true); + }); + + if (_endpoint case final completer? when !completer.isCompleted) { + completer.complete(task); + } else { + await task; + } + }, + onRegistrationFailed: (reason, instance) { + if (_endpoint case final completer? when !completer.isCompleted) { + completer.completeError(reason); + } else { + throw reason; + } + }, + onMessage: (message, instance) async { + debugPrint("UP message received for $instance"); + if (message.decrypted == false) { + throw Exception( + "Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.", + ); + } + final pushResponse = await ref + .read(ClientController.provider.notifier) + .handlePush(json.decode(String.fromCharCodes(message.content))); + + if (pushResponse == null) return; + + final (event, roomMetadata) = pushResponse; + + if (event.unreadType?.has(.notify) != true || + (!isInBackground && + await windowManager.isFocused().onError((_, _) => true) && + await ref.read( + KeyController.provider(KeyController.roomKey).future, + ) == + event.roomId)) { + if (isInBackground) exit(0); + return; + } + + final icon = roomMetadata.avatar == null + ? null + : await ref + .read(ClientController.provider.notifier) + .downloadMedia( + .new(mxc: roomMetadata.avatar!, isAvatar: true), + ); + + await ref + .read(NotificationController.provider.notifier) + .send( + id: event.eventId.hashCode & 0x7fffffff, + title: roomMetadata.name ?? "New Event", + icon: icon, + payload: event.rowId.toString(), + body: switch (event.content) { + MessageContent(:final body?) || + StickerContent(:final body) => body, + _ => null, + }, + ); + + if (isInBackground) exit(0); + }, + onUnregistered: deregister, + ); + + if (!registered) return false; + + try { + return await register(); + } catch (error, stackTrace) { + showError(error, stackTrace); + return false; + } + } + + Future register() async { + state = .loading(); + try { + final deviceIdCompleter = Completer(); + final subscription = ref.listen( + ClientStateController.provider.select((value) => value?.deviceId), + (_, next) { + if (next != null && !deviceIdCompleter.isCompleted) { + deviceIdCompleter.complete(next); + } + }, + fireImmediately: true, + ); + final deviceId = await deviceIdCompleter.future.whenComplete( + subscription.close, + ); + + await Future(() async { + final capabilities = await ref + .read(ClientController.provider.notifier) + .getCapabilities(); + + if (capabilities.webpush?.vapid == null) { + throw UnsupportedError( + "Your homeserver does not support MSC4174 (Web Push), and therefore cannot send notifications to Nexus.", + ); + } + + if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) { + throw Exception("No UnifiedPush distributors found."); + } + + final endpoint = _endpoint = Completer(); + + try { + await Future.wait([ + UnifiedPush.register( + instance: deviceId, + vapid: capabilities.webpush?.vapid, + ), + endpoint.future, + ], eagerError: true); + } finally { + if (_endpoint == endpoint) _endpoint = null; + } + }).timeout( + .new(seconds: 10), + onTimeout: () => throw Exception("UnifiedPush registration timed out."), + ); + + state = .data(true); + return true; + } catch (_) { + state = .data(false); + rethrow; + } + } + + Future deregister([String? instance]) async { + final clientState = ref.read(ClientStateController.provider); + + final keyProvider = PushKeyController.provider( + instance ?? clientState!.deviceId!, + ); + final key = await ref.read(keyProvider.future); + + if (key != null) { + await ref + .read(ClientController.provider.notifier) + .deregisterPusher(.new(appId: "nexus.federated.nexus", pushKey: key)); + await ref.read(keyProvider.notifier).set(null); + } else { + debugPrint( + "No matching pushKey found. Skipping deregistration from homeserver.", + ); + } + + await UnifiedPush.unregister(instance ?? clientState!.deviceId!); + state = .data(false); + } + + static final provider = AsyncNotifierProvider( + UnifiedPushController.new, + ); +} diff --git a/lib/controllers/unified_push_allowed.dart b/lib/controllers/unified_push_allowed.dart new file mode 100644 index 0000000..604a060 --- /dev/null +++ b/lib/controllers/unified_push_allowed.dart @@ -0,0 +1,37 @@ +import "dart:io"; + +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:unifiedpush/unifiedpush.dart"; + +class UnifiedPushAllowedController extends AsyncNotifier { + @override + Future build() async { + if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor().timeout( + .new(seconds: 10), + onTimeout: () => + throw Exception("UnifiedPush distributor check timed out."), + )) { + return "No valid distributors found. ${Platform.isLinux + ? "Try installing KUnifiedPush" + : Platform.isAndroid + ? "Try installing Google Play Services or NTFY" + : "Your platform is not currently supported by UnifiedPush"}."; + } + + final capabilities = await ref + .watch(ClientController.provider.notifier) + .getCapabilities(); + + if (capabilities.webpush?.vapid == null) { + return "Your homeserver does not support MSC4174 (Web Push), and therefore cannot send notifications to Nexus."; + } + + return null; + } + + static final provider = + AsyncNotifierProvider.autoDispose( + UnifiedPushAllowedController.new, + ); +} diff --git a/lib/controllers/url_preview.dart b/lib/controllers/url_preview.dart index 2a81a3c..41c3ef4 100644 --- a/lib/controllers/url_preview.dart +++ b/lib/controllers/url_preview.dart @@ -1,50 +1,26 @@ -import "dart:convert"; +import "package:flutter/widgets.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:http/http.dart"; -import "package:nexus/controllers/client_state.dart"; -import "package:nexus/controllers/header.dart"; +import "package:nexus/controllers/client.dart"; import "package:nexus/models/open_graph_data.dart"; -class UrlPreviewController extends AsyncNotifier { - final String link; - UrlPreviewController(this.link); - +class UrlPreviewController(final Uri url) + extends AsyncNotifier { @override Future build() async { + if (url.host == "matrix.to") return null; + try { - final homeserver = ref.watch( - ClientStateController.provider.select((value) => value?.homeserverUrl), - ); - - if (homeserver != null && !link.contains("matrix.to")) { - { - final response = await get( - .parse(homeserver) - .resolve("/_matrix/client/v1/media/preview_url") - .replace(queryParameters: {"url": link}), - headers: await ref.watch(HeaderController.provider.future), - ); - - if (response.statusCode == 200) { - final decodedValue = json.decode(response.body); - if (decodedValue is! Map) return null; - - return .fromJson( - decodedValue, - ).copyWith(imageUrl: decodedValue["og:image"]); - } - } - } - } catch (_) { + return await ref + .watch(ClientController.provider.notifier) + .getUrlPreview(url); + } catch (error, stackTrace) { + debugPrintStack(label: error.toString(), stackTrace: stackTrace); return null; } - return null; } static final provider = - AsyncNotifierProvider.family< - UrlPreviewController, - OpenGraphData?, - String - >(UrlPreviewController.new); + AsyncNotifierProvider.family( + UrlPreviewController.new, + ); } diff --git a/lib/controllers/user.dart b/lib/controllers/user.dart index b366c88..c69ebd0 100644 --- a/lib/controllers/user.dart +++ b/lib/controllers/user.dart @@ -27,14 +27,15 @@ class UserController extends AsyncNotifier { return content; } - final profile = await ref.watch( + final profileResponse = await ref.watch( ProfileController.provider(config.userId).future, ); return .new( status: .leave, - avatarUrl: profile.avatarUrl, - displayName: profile.displayName ?? config.userId.localpart, + avatarUrl: profileResponse.profile.avatarUrl, + displayName: + profileResponse.profile.displayName ?? config.userId.localpart, ); } diff --git a/lib/controllers/via.dart b/lib/controllers/via.dart index d9227ba..39db682 100644 --- a/lib/controllers/via.dart +++ b/lib/controllers/via.dart @@ -7,10 +7,7 @@ import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/room.dart"; -class ViaController extends Notifier { - final Room room; - ViaController(this.room); - +class ViaController(final Room room) extends Notifier { @override String build() { final servers = {}; diff --git a/lib/helpers/extensions/build_event_options.dart b/lib/helpers/extensions/build_event_options.dart new file mode 100644 index 0000000..fdf3bdf --- /dev/null +++ b/lib/helpers/extensions/build_event_options.dart @@ -0,0 +1,283 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/pinned_ids.dart"; +import "package:nexus/controllers/power_level.dart"; +import "package:nexus/controllers/recent_emoji.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/controllers/via.dart"; +import "package:nexus/helpers/extensions/event_id_is_fake.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/relation_type.dart"; +import "package:nexus/widgets/emoji_picker.dart"; +import "package:nexus/main.dart"; + +extension BuildEventOptions on Event { + IList buildEventOptions({ + required BuildContext context, + required WidgetRef ref, + required String roomId, + required String userId, + required void Function(int, RelationType) onRelation, + }) { + final theme = Theme.of(context); + final danger = theme.colorScheme.error; + + final notifier = RoomChatController.provider(( + roomId: roomId, + contextualEvent: null, + )).notifier; + + final isPinned = ref + .watch(PinnedIdsController.provider(roomId)) + .contains(eventId); + + void showReasonDialog({ + required String title, + required String description, + required String action, + required Future Function(String reason) onConfirm, + }) { + showDialog( + context: context, + builder: (context) => HookBuilder( + builder: (context) { + final reasonController = useTextEditingController(); + + return AlertDialog( + title: Text(title), + content: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Text(description), + const SizedBox(height: 12), + TextField( + controller: reasonController, + textCapitalization: .sentences, + decoration: .new(labelText: "Reason (optional)"), + ), + ], + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Cancel"), + ), + TextButton( + onPressed: () { + final reason = reasonController.text; + Navigator.of(context).pop(); + + onConfirm(reason).onError(showError); + }, + child: Text(action), + ), + ], + ); + }, + ), + ); + } + + return .new([ + if (ref.watch( + PowerLevelController.provider( + .new(eventType: .reaction, roomId: roomId), + ), + )) + PopupMenuItem( + enabled: false, + child: IconTheme( + data: theme.iconTheme, + child: Row( + mainAxisAlignment: .center, + children: [ + for (final emoji in { + ...ref + .watch(RecentEmojiController.provider) + .map((entry) => entry.emoji), + "👍", + "🤣", + "😭", + "🤔", + }.take(3)) + IconButton( + icon: Text(emoji), + onPressed: () async { + Navigator.of(context).pop(); + await ref + .watch(notifier) + .sendReaction(emoji, this) + .onError(showError); + + await ref + .read(RecentEmojiController.provider.notifier) + .add(emoji) + .onError(showError); + }, + ), + IconButton( + icon: const Icon(Icons.emoji_emotions), + onPressed: () { + Navigator.of(context).pop(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => EmojiPicker( + allowFreeText: true, + onSelection: (emoji) { + Navigator.of(context).pop(); + ref + .watch(notifier) + .sendReaction(emoji, this) + .onError(showError); + }, + ), + ); + }, + ), + ], + ), + ), + ), + + if (ref.watch( + PowerLevelController.provider( + .new(eventType: .message, roomId: roomId), + ), + )) + PopupMenuItem( + onTap: () => onRelation(rowId, .reply), + child: const ListTile( + leading: Icon(Icons.reply), + title: Text("Reply"), + ), + ), + + if (content is MessageContent && sender == userId) + PopupMenuItem( + onTap: () => onRelation(rowId, .edit), + child: const ListTile(leading: Icon(Icons.edit), title: Text("Edit")), + ), + + if (ref.watch( + PowerLevelController.provider( + .state(eventType: .pinnedEvents, roomId: roomId), + ), + )) + PopupMenuItem( + onTap: () async { + try { + final pins = ref.read( + PinnedIdsController.provider(roomId).notifier, + ); + + if (isPinned) { + await pins.removePin(eventId); + } else { + await pins.addPin(eventId); + } + } catch (error, stackTrace) { + showError(error, stackTrace); + } + }, + child: ListTile( + leading: const Icon(Icons.push_pin), + title: Text(isPinned ? "Unpin Event" : "Pin Event"), + ), + ), + + PopupMenuItem( + onTap: () async { + final room = ref.read( + RoomsController.provider.select((rooms) => rooms[roomId]), + ); + if (room == null) return; + + final vias = ref.read(ViaController.provider(room)); + + await Clipboard.setData( + ClipboardData( + text: + "matrix:roomid/${room.metadata?.id.substring(1)}/e/${eventId.substring(1)}$vias", + ), + ); + }, + child: const ListTile( + leading: Icon(Icons.link), + title: Text("Copy Link"), + ), + ), + + if (content case MessageContent(:final body?)) + PopupMenuItem( + onTap: () => Clipboard.setData(ClipboardData(text: body)), + child: const ListTile( + leading: Icon(Icons.copy), + title: Text("Copy Text"), + ), + ), + + if (ref.watch( + PowerLevelController.provider( + .redaction(targetUser: sender, roomId: roomId), + ), + ) && + !eventId.isFake) + PopupMenuItem( + onTap: () => showReasonDialog( + title: "Delete Message", + description: + "Are you sure you want to delete this message? " + "This cannot be reversed.", + action: "Delete", + onConfirm: (reason) => + ref.watch(notifier).deleteMessage(this, reason: reason), + ), + child: ListTile( + leading: Icon(Icons.delete, color: danger), + title: Text("Delete", style: .new(color: danger)), + ), + ), + + if (eventId.isFake) + PopupMenuItem( + onTap: () => ref.watch(notifier).hideClientSticky(rowId), + child: ListTile( + leading: Icon(Icons.delete, color: danger), + title: Text("Hide", style: .new(color: danger)), + ), + ), + + PopupMenuItem( + onTap: () => showReasonDialog( + title: "Report", + description: + "Report this this to your server administrators, " + "who can take action like banning this server or room.", + action: "Report", + onConfirm: (reason) => ref + .read(ClientController.provider.notifier) + .reportEvent( + .new( + roomId: roomId, + eventId: eventId, + reason: reason.isEmpty ? null : reason, + ), + ), + ), + child: ListTile( + leading: Icon(Icons.report, color: danger), + title: Text("Report", style: .new(color: danger)), + ), + ), + ]); + } +} diff --git a/lib/helpers/extensions/event_id_is_fake.dart b/lib/helpers/extensions/event_id_is_fake.dart new file mode 100644 index 0000000..2e660af --- /dev/null +++ b/lib/helpers/extensions/event_id_is_fake.dart @@ -0,0 +1,3 @@ +extension EventIdIsFake on String { + bool get isFake => startsWith("~") || startsWith("\$gomuks"); +} diff --git a/lib/helpers/extensions/focus_room.dart b/lib/helpers/extensions/focus_room.dart new file mode 100644 index 0000000..33111ea --- /dev/null +++ b/lib/helpers/extensions/focus_room.dart @@ -0,0 +1,46 @@ +import "package:collection/collection.dart"; +import "package:flutter_riverpod/experimental/mutation.dart"; +import "package:nexus/controllers/jump_request.dart"; +import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/spaces.dart"; +import "package:nexus/models/space.dart"; + +extension FocusRoom on MutationTarget { + Future focusRoom(String roomId, [int? eventRowId]) async { + final spaces = await container.read(SpacesController.provider.future); + + if (spaces.firstWhereOrNull((space) => space.id == roomId) case Space _?) { + await container + .read(KeyController.provider(KeyController.spaceKey).notifier) + .set(roomId); + return true; + } + + final parent = spaces.firstWhereOrNull( + (space) => + space.children.any((room) => room.metadata?.id == roomId) || + space.subSpaces.any( + (sub) => + sub.room.metadata?.id == roomId || + sub.children.any((room) => room.metadata?.id == roomId), + ), + ); + if (parent == null) return false; + + await container + .read(KeyController.provider(KeyController.spaceKey).notifier) + .set(parent.id); + + if (eventRowId != null) { + container + .read(JumpRequestController.provider.notifier) + .request(roomId, eventRowId); + } + + await container + .read(KeyController.provider(KeyController.roomKey).notifier) + .set(roomId); + + return true; + } +} diff --git a/lib/helpers/extensions/get_all_child_rooms.dart b/lib/helpers/extensions/get_all_child_rooms.dart new file mode 100644 index 0000000..7a17eec --- /dev/null +++ b/lib/helpers/extensions/get_all_child_rooms.dart @@ -0,0 +1,8 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/models/space.dart"; + +extension GetAllChildRooms on Space { + IList get allChildRooms => + children.addAll(subSpaces.expand((s) => s.children)); +} diff --git a/lib/helpers/extensions/get_filename.dart b/lib/helpers/extensions/get_filename.dart new file mode 100644 index 0000000..c0c20e4 --- /dev/null +++ b/lib/helpers/extensions/get_filename.dart @@ -0,0 +1,11 @@ +import "package:nexus/models/content/message.dart"; + +extension GetFilename on MessageContent { + String? get filename => switch (this) { + ImageMessageContent(:final filename) || + VideoMessageContent(:final filename) || + AudioMessageContent(:final filename) || + FileMessageContent(:final filename) => filename, + _ => null, + }; +} diff --git a/lib/helpers/extensions/get_headers.dart b/lib/helpers/extensions/get_headers.dart deleted file mode 100644 index 0fcec48..0000000 --- a/lib/helpers/extensions/get_headers.dart +++ /dev/null @@ -1,7 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/header.dart"; - -extension GetHeaders on WidgetRef { - Map get headers => - watch(HeaderController.provider).requireValue; -} diff --git a/lib/helpers/extensions/get_link_info.dart b/lib/helpers/extensions/get_link_info.dart new file mode 100644 index 0000000..28e999b --- /dev/null +++ b/lib/helpers/extensions/get_link_info.dart @@ -0,0 +1,70 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; + +/// A parsed Matrix link. +/// +/// [identifier] is the room ID, room alias or user ID (e.g. "#room:matrix.org"), +/// [eventId] is set for event permalinks (e.g. "$abc123"), and [via] holds any +/// `via` servers from the link. +typedef LinkInfo = ({String identifier, String? eventId, IList via}); + +extension GetLinkInfo on String { + /// Parses this string as a Matrix link. + /// + /// Supports: + /// - https://matrix.to/#/{id}[/{eventId}][?via=...] + /// - matrix:roomid/{id}[/e/{eventId}][?via=...] + /// - matrix:r/{alias}[/e/{eventId}][?via=...] + /// - matrix:u/{userId} + /// + /// Returns null if this is not a Matrix link. + LinkInfo? get linkInfo { + final uri = Uri.tryParse(trim()); + if (uri == null) return null; + + if ((uri.scheme == "http" || uri.scheme == "https") && + uri.host == "matrix.to") { + // matrix.to keeps everything, including the query, in the fragment, + // so parse the fragment as its own URI. + final inner = Uri.tryParse(uri.fragment); + final segments = inner?.pathSegments.where((s) => s.isNotEmpty).toList(); + if (inner == null || segments == null || segments.isEmpty) return null; + + return ( + identifier: segments.first, + eventId: segments.length > 1 && segments[1].startsWith(r"$") + ? segments[1] + : null, + via: .new(inner.queryParametersAll["via"] ?? []), + ); + } + + if (uri.scheme == "matrix") { + final segments = uri.pathSegments; + if (segments.length < 2 || segments[1].isEmpty) return null; + + final sigil = switch (segments[0].toLowerCase()) { + "r" => "#", + "roomid" => "!", + "u" => "@", + _ => null, + }; + if (sigil == null) return null; + + // Event segment is "e/{eventId}" (without the "$" sigil) and is + // only valid on room links. + final hasEvent = + sigil != "@" && + segments.length >= 4 && + segments[2].toLowerCase() == "e" && + segments[3].isNotEmpty; + + return ( + identifier: "$sigil${segments[1]}", + eventId: hasEvent ? "\$${segments[3]}" : null, + via: .new(uri.queryParametersAll["via"] ?? []), + ); + } + + return null; + } +} diff --git a/lib/helpers/extensions/get_xcode_sdk.dart b/lib/helpers/extensions/get_xcode_sdk.dart index 4c09246..58920c8 100644 --- a/lib/helpers/extensions/get_xcode_sdk.dart +++ b/lib/helpers/extensions/get_xcode_sdk.dart @@ -1,11 +1,14 @@ import "dart:io"; -Future getXCodeSDK() async { - final result = await Process.run("xcrun", ["--show-sdk-path"]); +Future getXCodeTool({String? sdkType, String? findTool}) async { + final result = await Process.run("xcrun", [ + if (sdkType != null) ...["--sdk", sdkType], + if (findTool != null) ...["-f", findTool] else "--show-sdk-path", + ]); if (result.exitCode != 0) { - throw Exception("Failed to get XCode SDK\n${result.stderr}"); + throw Exception("Failed to get ${sdkType ?? "XCode"} ${findTool ?? "SDK"}"); } - return result.stdout.trim(); + return result.stdout.toString().trim(); } diff --git a/lib/helpers/extensions/gomuks_buffer.dart b/lib/helpers/extensions/gomuks_buffer.dart index cc16b46..10a273a 100644 --- a/lib/helpers/extensions/gomuks_buffer.dart +++ b/lib/helpers/extensions/gomuks_buffer.dart @@ -1,20 +1,21 @@ import "dart:convert"; import "dart:ffi"; import "dart:typed_data"; + import "package:ffi/ffi.dart"; import "package:nexus/src/third_party/gomuks.g.dart"; extension GomuksOwnedBufferToX on GomuksOwnedBuffer { - Uint8List toBytes() { + Uint8List toBytesAndFree() { try { if (base == nullptr || length <= 0) return .new(0); return .fromList(base.asTypedList(length)); } finally { - calloc.free(base); + GomuksFreeBuffer(this); } } - dynamic toJson() => jsonDecode(utf8.decode(toBytes())); + dynamic toJsonAndFree() => jsonDecode(utf8.decode(toBytesAndFree())); } extension JsonToGomuksBuffer on Map { diff --git a/lib/helpers/extensions/link_to_mention.dart b/lib/helpers/extensions/link_to_mention.dart deleted file mode 100644 index f4868d3..0000000 --- a/lib/helpers/extensions/link_to_mention.dart +++ /dev/null @@ -1,45 +0,0 @@ -extension LinkToMention on String { - /// Extracts a Matrix identifier from this string. - /// - /// Supports: - /// - https://matrix.to/#/... - /// - matrix:roomid/... - /// - matrix:r/... - /// - matrix:u/... - /// - /// Returns the decoded identifier (e.g. "#room:matrix.org") - /// or null if this is not a Matrix link. - String? get mention { - final trimmed = trim(); - - final matrixTo = RegExp( - r"^https?://matrix\.to/#/(.[^/?#]+)", - caseSensitive: false, - ); - - final matrixToMatch = matrixTo.firstMatch(trimmed); - if (matrixToMatch != null) { - return Uri.decodeComponent(matrixToMatch.group(1)!); - } - - if (trimmed.toLowerCase().startsWith("matrix:")) { - try { - final uri = Uri.parse(trimmed); - - if (uri.pathSegments.isNotEmpty) { - final identifier = uri.pathSegments.last; - if (identifier.isNotEmpty) { - return "${switch (uri.pathSegments.firstOrNull) { - "r" => "#", - "roomid" => "!", - "u" => "@", - _ => "", - }}${Uri.decodeComponent(identifier)}"; - } - } - } catch (_) {} - } - - return null; - } -} diff --git a/lib/helpers/extensions/scheme_to_theme.dart b/lib/helpers/extensions/scheme_to_theme.dart index bf48535..ac50a09 100644 --- a/lib/helpers/extensions/scheme_to_theme.dart +++ b/lib/helpers/extensions/scheme_to_theme.dart @@ -1,9 +1,10 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; extension SchemeToTheme on ColorScheme { ThemeData get theme { final textTheme = ThemeData( - fontFamilyFallback: ["sans", "emoji", "fallback-sans", "fallback-emoji"], + fontFamily: "bundled-sans", + fontFamilyFallback: ["bundled-emoji"], brightness: brightness, ).textTheme; return .from(colorScheme: this).copyWith( @@ -27,6 +28,7 @@ extension SchemeToTheme on ColorScheme { borderRadius: .circular(8), ), ), + iconButtonTheme: .new(variant: StyleVariant.material3Expressive), textTheme: textTheme, inputDecorationTheme: const InputDecorationTheme( border: OutlineInputBorder(), diff --git a/lib/helpers/extensions/show_about_dialog.dart b/lib/helpers/extensions/show_about_dialog.dart index 2f24a3e..4729e99 100644 --- a/lib/helpers/extensions/show_about_dialog.dart +++ b/lib/helpers/extensions/show_about_dialog.dart @@ -1,11 +1,11 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_svg/flutter_svg.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:m3e_card_list/m3e_card_list.dart"; import "package:nexus/helpers/launch_helper.dart"; import "package:package_info_plus/package_info_plus.dart"; -extension ShowContextMenu on BuildContext { +extension ShowAboutDialog on BuildContext { Future showAboutDialog(WidgetRef ref) async { final packageInfo = await PackageInfo.fromPlatform(); @@ -20,7 +20,7 @@ extension ShowContextMenu on BuildContext { Row( spacing: 12, children: [ - SvgPicture.asset("assets/icon.svg", width: 64), + SvgPicture.asset("assets/bundled/icon.svg", width: 64), Expanded( child: Column( crossAxisAlignment: .start, @@ -33,7 +33,9 @@ extension ShowContextMenu on BuildContext { "Nexus", style: Theme.of(context).textTheme.headlineMedium, ), - Text("(${packageInfo.version})"), + Text( + "(${packageInfo.version}+${const String.fromEnvironment("GIT_COMMIT", defaultValue: "dev")})", + ), ], ), @@ -50,8 +52,8 @@ extension ShowContextMenu on BuildContext { M3ECardColumn( onTap: (index) => ref.watch(LaunchHelper.provider).launchUrl(switch (index) { - 0 => Uri.https("git.federated.nexus", "nexus/nexus"), - _ => Uri.https("liberapay.com", "QuadRadical"), + 0 => .https("git.federated.nexus", "nexus/nexus"), + _ => .https("liberapay.com", "QuadRadical"), }), children: [ ListTile( diff --git a/lib/helpers/extensions/show_context_menu.dart b/lib/helpers/extensions/show_context_menu.dart index c860115..ccef3b5 100644 --- a/lib/helpers/extensions/show_context_menu.dart +++ b/lib/helpers/extensions/show_context_menu.dart @@ -1,4 +1,4 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; extension ShowContextMenu on BuildContext { void showContextMenu({ @@ -9,7 +9,7 @@ extension ShowContextMenu on BuildContext { showMenu( context: this, - constraints: .loose(Size.infinite), + constraints: .loose(.infinite), position: .fromLTRB( globalPosition.dx, globalPosition.dy, diff --git a/lib/helpers/extensions/show_user_popover.dart b/lib/helpers/extensions/show_user_popover.dart index 1ea3015..f42cd57 100644 --- a/lib/helpers/extensions/show_user_popover.dart +++ b/lib/helpers/extensions/show_user_popover.dart @@ -1,4 +1,4 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/widgets/user_bottom_sheet.dart"; diff --git a/lib/helpers/extensions/string_to_color.dart b/lib/helpers/extensions/string_to_color.dart index eaa7714..a21ca82 100644 --- a/lib/helpers/extensions/string_to_color.dart +++ b/lib/helpers/extensions/string_to_color.dart @@ -1,5 +1,5 @@ import "package:color_hash/color_hash.dart"; -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; extension ToColor on String { Color get colorHash => ColorHash(this, lightness: .5, saturation: .7).color; diff --git a/lib/helpers/font_licenses.dart b/lib/helpers/font_licenses.dart index 41567c2..4bd65d9 100644 --- a/lib/helpers/font_licenses.dart +++ b/lib/helpers/font_licenses.dart @@ -1,11 +1,6 @@ import "package:flutter/foundation.dart"; -const fontLicenses = [ - LicenseEntryWithLineBreaks( - ["Noto Color Emoji"], - """Copyright 2021 Google Inc. All Rights Reserved. - -This Font Software is licensed under the SIL Open Font License, Version 1.1. +const sil = """This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: https://openfontlicense.org @@ -95,102 +90,426 @@ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE.""", +OTHER DEALINGS IN THE FONT SOFTWARE."""; + +const List fontLicenses = [ + .new( + ["Twitter Color Emoji"], + """Twitter Color Emoji is built from Twemoji (https://github.com/jdecked/twemoji) +using the Noto Emoji as fallback. +Its parts are under different licenses: + +1) Emoji artwork: Copyright Twitter, Inc and other contributors. +Attribution 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More_considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution 4.0 International Public License ("Public License"). To the +extent this Public License may be interpreted as a contract, You are +granted the Licensed Rights in consideration of Your acceptance of +these terms and conditions, and the Licensor grants You such rights in +consideration of benefits the Licensor receives from making the +Licensed Material available under these terms and conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + d. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + e. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + f. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + g. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + h. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + i. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + j. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + k. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's + License You apply must not prevent recipients of the Adapted + Material from complying with this Public License. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public licenses. +Notwithstanding, Creative Commons may elect to apply one of its public +licenses to material it publishes and in those instances will be +considered the "Licensor." Except for the limited purpose of indicating +that material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the public +licenses. + +Creative Commons may be contacted at creativecommons.org. + +2) Font files and font tooling (Noto Emoji): SIL Open Font License, Version 1.1. +Copyright 2021 Google Inc. All Rights Reserved +$sil +""", ), - LicenseEntryWithLineBreaks( - ["Roboto"], - """Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic) + .new( + ["Inter"], + """Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) +Modified for use in this application by removing selected Unicode character mappings. -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -https://openfontlicense.org +$sil""", + ), + .new( + ["Atkinson Hyperlegible Mono"], + """Copyright 2020-2024 The Atkinson Hyperlegible Mono Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next-mono) - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE.""", +$sil""", ), ]; diff --git a/lib/helpers/hooks/chat_scroll.dart b/lib/helpers/hooks/chat_scroll.dart new file mode 100644 index 0000000..7845576 --- /dev/null +++ b/lib/helpers/hooks/chat_scroll.dart @@ -0,0 +1,207 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/direction.dart"; +import "package:nexus/models/room_chat.dart"; + +typedef JumpToEvent = Future Function(int rowId); + +final class ChatScroll({ + required final IList historyRows, + required final IList liveRows, + required final GlobalKey centerKey, + required final ScrollController scrollController, + required final bool atBottom, + required final JumpToEvent jumpToEvent, + required final Future Function() jumpToBottom, + required final GlobalKey Function(int eventRowId) keyFor, +}) { + factory use({ + required AsyncValue controllerData, + required Future Function(Direction direction) paginate, + required Future Function() markRead, + required ValueNotifier contextualEvent, + }) { + final anchorId = useState(null); + final atBottom = useState(true); + final pendingJump = useState(null); + final scrollController = useScrollController(); + final centerKey = useMemoized(GlobalKey.new); + + final itemKeys = useMemoized(() => {}, []); + GlobalKey keyFor(int eventRowId) => + itemKeys.putIfAbsent(eventRowId, GlobalKey.new); + + useEffect(() { + if (anchorId.value == null) { + if (controllerData case AsyncData(:final value?) + when value.timeline.isNotEmpty) { + anchorId.value = value.timeline.contains(contextualEvent.value) + ? contextualEvent.value + : value.timeline.last; + } + } + + return null; + }, [controllerData, contextualEvent.value]); + + useEffect(() { + final rowId = pendingJump.value; + if (rowId == null || anchorId.value != rowId) return null; + pendingJump.value = null; + + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!scrollController.hasClients) return; + + final position = scrollController.position; + scrollController.jumpTo( + (-position.viewportDimension) + .clamp(position.minScrollExtent, position.maxScrollExtent) + .toDouble(), + ); + await WidgetsBinding.instance.endOfFrame; + + final context = keyFor(rowId).currentContext; + if (context?.mounted == true) { + await Scrollable.ensureVisible( + context!, + alignment: 0.5, + duration: const .new(milliseconds: 700), + curve: Curves.easeInOut, + ); + } + }); + + return null; + }, [anchorId.value, pendingJump.value]); + + final ({IList history, IList live}) split = useMemoized(() { + final items = controllerData.value?.timeline; + final anchor = anchorId.value; + + if (items == null || anchor == null) { + return (history: const .empty(), live: const .empty()); + } + + final anchorIndex = items.indexOf(anchor); + + if (anchorIndex == -1) return (history: const .empty(), live: items); + + return ( + history: items.take(anchorIndex).toIList().reversed.toIList(), + live: items.skip(anchorIndex).toIList(), + ); + }, [controllerData, anchorId.value]); + + final lastRowId = split.live.lastOrNull; + final previousLastRowId = useRef(null); + + useEffect(() { + final previous = previousLastRowId.value; + previousLastRowId.value = lastRowId; + + if (previous == null || lastRowId == null || previous == lastRowId) { + return null; + } + if (!atBottom.value || contextualEvent.value != null) return null; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!scrollController.hasClients) return; + + scrollController.jumpTo(scrollController.position.minScrollExtent); + }); + + return null; + }, [lastRowId]); + + useEffect( + () { + const loadThreshold = 500.0; + + Future checkPosition() async { + if (!scrollController.hasClients) return; + + final position = scrollController.position; + + final isAtBottom = position.extentBefore <= 0; + if (isAtBottom != atBottom.value) atBottom.value = isAtBottom; + + if (position.extentAfter <= loadThreshold) { + await paginate(.backward); + } else if (contextualEvent.value != null && + position.extentBefore <= loadThreshold) { + await paginate(.forward); + } else if (position.extentBefore <= 0) { + await markRead(); + } + + if (controllerData.isLoading == false && + position.extentBefore <= 0 && + controllerData.value?.hasMoreForward == false && + contextualEvent.value != null) { + anchorId.value = null; + contextualEvent.value = null; + } + } + + scrollController.addListener(checkPosition); + WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition()); + + return () => scrollController.removeListener(checkPosition); + }, + [ + scrollController, + controllerData, + paginate, + markRead, + contextualEvent.value, + ], + ); + + return .new( + historyRows: split.history, + liveRows: split.live, + centerKey: centerKey, + scrollController: scrollController, + atBottom: atBottom.value, + keyFor: keyFor, + jumpToEvent: (int rowId) async { + if (!scrollController.hasClients) return; + + final context = keyFor(rowId).currentContext; + if (context != null) { + await Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const .new(milliseconds: 700), + curve: Curves.easeInOut, + ); + return; + } + + pendingJump.value = rowId; + if (controllerData.value?.timeline.contains(rowId) ?? false) { + anchorId.value = rowId; + } else { + anchorId.value = null; + contextualEvent.value = rowId; + } + }, + jumpToBottom: () async { + if (contextualEvent.value != null) { + anchorId.value = null; + contextualEvent.value = null; + } + + if (!scrollController.hasClients) return; + + await scrollController.animateTo( + scrollController.position.minScrollExtent, + duration: const .new(milliseconds: 700), + curve: Curves.easeInOut, + ); + }, + ); + } +} diff --git a/lib/helpers/launch_helper.dart b/lib/helpers/launch_helper.dart index 575395f..89c7015 100644 --- a/lib/helpers/launch_helper.dart +++ b/lib/helpers/launch_helper.dart @@ -2,10 +2,7 @@ import "package:flutter/services.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:url_launcher/url_launcher.dart" as ul; -class LaunchHelper { - final Ref ref; - LaunchHelper(this.ref); - +class LaunchHelper(Ref ref) { Future launchUrl(Uri url, {bool useWebview = false}) async { try { return await ul.launchUrl( diff --git a/lib/helpers/mxc_image.dart b/lib/helpers/mxc_image.dart index 3adb10e..83a345a 100644 --- a/lib/helpers/mxc_image.dart +++ b/lib/helpers/mxc_image.dart @@ -1,14 +1,12 @@ import "dart:ui"; + import "package:flutter/widgets.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/models/requests/download_media.dart"; -class MxcImage extends ImageProvider { - final WidgetRef ref; - final DownloadMediaRequest request; - const MxcImage(this.ref, this.request); - +class MxcImage(final WidgetRef ref, final DownloadMediaRequest request) + extends ImageProvider { @override Future obtainKey(ImageConfiguration configuration) => Future.value(this); diff --git a/lib/main.dart b/lib/main.dart index f50d22d..3501d5c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,27 +1,32 @@ import "dart:io"; + import "package:dynamic_color/dynamic_color.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/foundation.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_emoji_picker/material_emoji_picker.dart"; import "package:media_kit/media_kit.dart"; -import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; -import "package:nexus/controllers/header.dart"; +import "package:nexus/controllers/gomuks_listener.dart"; +import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/member_list_opened.dart"; import "package:nexus/controllers/multi_provider.dart"; +import "package:nexus/controllers/notification.dart"; import "package:nexus/controllers/settings.dart"; -import "package:nexus/controllers/shared_prefs.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/controllers/unified_push.dart"; import "package:nexus/helpers/extensions/scheme_to_theme.dart"; import "package:nexus/helpers/font_licenses.dart"; -import "package:nexus/pages/chat.dart"; -import "package:nexus/pages/select_server.dart"; -import "package:nexus/pages/verify.dart"; +import "package:nexus/widgets/pages/chat.dart"; +import "package:nexus/widgets/pages/select_server.dart"; +import "package:nexus/widgets/pages/settings.dart"; +import "package:nexus/widgets/pages/verify.dart"; +import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/loading.dart"; import "package:window_manager/window_manager.dart"; -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; final GlobalKey navigatorKey = GlobalKey(); +late final bool isInBackground; final class Logger extends ProviderObserver { @override @@ -38,14 +43,7 @@ New Value: ${newValue is AsyncData ? newValue.value : newValue} } void showError(Object error, [StackTrace? stackTrace]) { - if (error.toString().contains("DioException") || - error.toString().contains( - "setState() or markNeedsBuild() called during build.", - ) || - error.toString().contains("Invalid source") || - error.toString().contains("UTF-16") || - error.toString().contains("HTTP request failed") || - error.toString().contains("Invalid image data")) { + if (error.toString().contains("'_nextFrame != null': is not true.")) { return; } @@ -62,49 +60,59 @@ void showError(Object error, [StackTrace? stackTrace]) { } } -void main() async { +void main(List args) async { WidgetsFlutterBinding.ensureInitialized(); MediaKit.ensureInitialized(); - if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) { - await windowManager.ensureInitialized(); - await windowManager.waitUntilReadyToShow( - WindowOptions( - titleBarStyle: TitleBarStyle.hidden, - windowButtonVisibility: false, - ), - ); - await windowManager.setMinimumSize(Size.square(500)); - } - - LicenseRegistry.addLicense(() => Stream.fromIterable(fontLicenses)); + LicenseRegistry.addLicense(() => .fromIterable(fontLicenses)); FlutterError.onError = (FlutterErrorDetails details) => showError(details.exception.toString(), details.stack); - runApp( - ProviderScope( - retry: (_, _) => null, - observers: [ - // Change false to true if you want debug information on provider reloads - // ignore: dead_code - if (false && kDebugMode) Logger(), - ], - child: const App(), - ), - ); + isInBackground = + Platform.environment["FLUTTER_HEADLESS"] != null || + args.contains("--unifiedpush-bg"); + + if (isInBackground) { + await ProviderContainer() + .read(UnifiedPushController.provider.future) + .timeout(Duration(seconds: 5)); + + await Future.delayed(Duration(seconds: 10)); + exit(0); + } else { + if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) { + await windowManager.ensureInitialized(); + await windowManager.waitUntilReadyToShow( + WindowOptions( + titleBarStyle: TitleBarStyle.hidden, + windowButtonVisibility: false, + ), + ); + await windowManager.setMinimumSize(Size.square(500)); + } + + runApp( + ProviderScope( + retry: (_, _) => null, + observers: [ + // Change false to true if you want debug information on provider reloads + // ignore: dead_code + if (false && kDebugMode) Logger(), + ], + child: App(), + ), + ); + } } -class App extends StatelessWidget { - const App({super.key}); - +class const App({super.key}) extends StatelessWidget { @override Widget build(BuildContext context) => DynamicColorBuilder( builder: (lightDynamic, darkDynamic) => Consumer( builder: (context, ref, child) => MaterialApp( navigatorKey: navigatorKey, debugShowCheckedModeBanner: false, - // Use indigo to work around bugs in theme generation theme: (ref .watch(SettingsController.provider) @@ -113,7 +121,7 @@ class App extends StatelessWidget { data: (settings) => settings.useDynamicTheming ? lightDynamic : null, ) ?? - ColorScheme.fromSeed(seedColor: Colors.indigo)) + ThemeData.light().colorScheme) .theme, darkTheme: (ref @@ -123,10 +131,7 @@ class App extends StatelessWidget { data: (settings) => settings.useDynamicTheming ? darkDynamic : null, ) ?? - ColorScheme.fromSeed( - seedColor: Colors.indigo, - brightness: Brightness.dark, - )) + ThemeData.dark().colorScheme) .theme, themeMode: ref .watch(SettingsController.provider) @@ -136,39 +141,61 @@ class App extends StatelessWidget { ), home: child, ), - child: Scaffold( - body: Consumer( - builder: (_, ref, _) => ref - .watch( - MultiProviderController.provider( - IListConst([ - SharedPrefsController.provider, - ClientController.provider, - HeaderController.provider, + child: DefaultTextHeightBehavior( + textHeightBehavior: .new(leadingDistribution: .even), + child: Scaffold( + body: Consumer( + builder: (context, ref, _) => switch (ref.watch( + MultiProviderController.provider( + .new([ + GomuksListenerController.provider, + NotificationController.provider, + UnifiedPushController.provider, + MemberListOpenedController.provider, + KeyController.provider(KeyController.roomKey), + KeyController.provider(KeyController.spaceKey), + EmojiController.provider, + ]), + ), + )) { + AsyncData(value: _) || AsyncLoading(value: _?) => Consumer( + builder: (_, ref, _) { + final clientState = ref.watch(ClientStateController.provider); + + if (clientState == null || !clientState.isInitialized) { + return Loading(); + } + + if (!clientState.isLoggedIn) { + return SelectServerPage(); + } else if (!clientState.isVerified) { + return VerifyPage(); + } else { + return ChatPage(); + } + }, + ), + + AsyncLoading _ => Scaffold( + appBar: Appbar( + actions: .new([ + IconButton( + onPressed: () => showDialog( + context: context, + builder: (_) => SettingsPage(), + ), + icon: Icon(Icons.settings), + ), ]), ), - ) - .betterWhen( - data: (_) => Consumer( - builder: (_, ref, _) { - final clientState = ref.watch( - ClientStateController.provider, - ); - - if (clientState == null || !clientState.isInitialized) { - return Loading(); - } - - if (!clientState.isLoggedIn) { - return SelectServerPage(); - } else if (!clientState.isVerified) { - return VerifyPage(); - } else { - return ChatPage(); - } - }, - ), + body: Loading(), ), + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + }, + ), ), ), ), diff --git a/lib/models/account_data.dart b/lib/models/account_data.dart index a325ffe..955d8d1 100644 --- a/lib/models/account_data.dart +++ b/lib/models/account_data.dart @@ -1,16 +1,69 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; + part "account_data.freezed.dart"; part "account_data.g.dart"; -@freezed -abstract class AccountData with _$AccountData { - const factory AccountData({ - required String userId, - required String? roomId, - required String type, - required dynamic content, - }) = _AccountData; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const AccountData({ + @JsonKey(name: AccountData.invitePermissionConfigKey) + final InvitePermissionConfig invitePermissionConfig = + const InvitePermissionConfig(), + + @JsonKey(name: AccountData.directKey) + final IMap> directMessages = const IMap.empty(), + + @JsonKey(name: AccountData.recentEmojiKey) + final RecentEmojiData recentEmoji = const RecentEmojiData(), +}) with _$AccountData { + static const invitePermissionConfigKey = "m.invite_permission_config"; + static const directKey = "m.direct"; + static const recentEmojiKey = "m.recent_emoji"; + + Map toJson() => _$AccountDataToJson(this); factory AccountData.fromJson(Map json) => _$AccountDataFromJson(json); } + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RecentEmojiData({ + final IList recentEmoji = const IList.empty(), +}) with _$RecentEmojiData { + Map toJson() => _$RecentEmojiDataToJson(this); + + factory RecentEmojiData.fromJson(Map json) => + _$RecentEmojiDataFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const InvitePermissionConfig({ + @JsonKey(unknownEnumValue: DefaultInviteAction.allow) + final DefaultInviteAction defaultAction = DefaultInviteAction.allow, +}) with _$InvitePermissionConfig { + Map toJson() => _$InvitePermissionConfigToJson(this); + + factory InvitePermissionConfig.fromJson(Map json) => + _$InvitePermissionConfigFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RecentEmoji({required final String emoji, required final int total}) + with _$RecentEmoji { + Map toJson() => _$RecentEmojiToJson(this); + + factory RecentEmoji.fromJson(Map json) => + _$RecentEmojiFromJson(json); +} + +@JsonEnum(fieldRename: .snake) +enum DefaultInviteAction { + allow, + deny, + @JsonValue("uk.timedout.msc4494.deny_public") + denyPublic, +} diff --git a/lib/models/capabilities.dart b/lib/models/capabilities.dart new file mode 100644 index 0000000..6830170 --- /dev/null +++ b/lib/models/capabilities.dart @@ -0,0 +1,25 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "capabilities.freezed.dart"; +part "capabilities.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Capabilities({ + @JsonKey(name: "org.matrix.msc4174.webpush") final WebPush? webpush, +}) with _$Capabilities { + Map toJson() => _$CapabilitiesToJson(this); + + factory Capabilities.fromJson(Map json) => + _$CapabilitiesFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const WebPush({required final bool enabled, final String? vapid}) + with _$WebPush { + Map toJson() => _$WebPushToJson(this); + + factory WebPush.fromJson(Map json) => + _$WebPushFromJson(json); +} diff --git a/lib/models/client_state.dart b/lib/models/client_state.dart index 1e15136..262203d 100644 --- a/lib/models/client_state.dart +++ b/lib/models/client_state.dart @@ -1,16 +1,19 @@ import "package:freezed_annotation/freezed_annotation.dart"; + part "client_state.freezed.dart"; part "client_state.g.dart"; -@freezed -abstract class ClientState with _$ClientState { - const factory ClientState({ - required bool isInitialized, - required bool isLoggedIn, - required bool isVerified, - required String? userId, - required String? homeserverUrl, - }) = _ClientState; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const ClientState({ + required final bool isInitialized, + required final bool isLoggedIn, + required final bool isVerified, + required final String? userId, + required final String? deviceId, + required final String? homeserverUrl, +}) with _$ClientState { + Map toJson() => _$ClientStateToJson(this); factory ClientState.fromJson(Map json) => _$ClientStateFromJson(json); diff --git a/lib/models/configs/members_by_status.dart b/lib/models/configs/members_by_status.dart index 29fc471..6b4aec4 100644 --- a/lib/models/configs/members_by_status.dart +++ b/lib/models/configs/members_by_status.dart @@ -1,14 +1,16 @@ import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/membership_status.dart"; + part "members_by_status.freezed.dart"; part "members_by_status.g.dart"; -@freezed -abstract class MembersByStatusConfig with _$MembersByStatusConfig { - const factory MembersByStatusConfig({ - required String roomId, - required MembershipStatus status, - }) = _MembersByStatusConfig; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const MembersByStatusConfig({ + required final String roomId, + required final MembershipStatus status, +}) with _$MembersByStatusConfig { + Map toJson() => _$MembersByStatusConfigToJson(this); factory MembersByStatusConfig.fromJson(Map json) => _$MembersByStatusConfigFromJson(json); diff --git a/lib/models/configs/power_level.dart b/lib/models/configs/power_level.dart index ed9c5e8..5722db7 100644 --- a/lib/models/configs/power_level.dart +++ b/lib/models/configs/power_level.dart @@ -1,6 +1,7 @@ import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/membership_action.dart"; + part "power_level.freezed.dart"; @freezed diff --git a/lib/models/configs/reactions.dart b/lib/models/configs/reactions.dart index 787b28c..b9de793 100644 --- a/lib/models/configs/reactions.dart +++ b/lib/models/configs/reactions.dart @@ -1,13 +1,15 @@ import "package:freezed_annotation/freezed_annotation.dart"; + part "reactions.freezed.dart"; part "reactions.g.dart"; -@freezed -abstract class ReactionsConfig with _$ReactionsConfig { - const factory ReactionsConfig({ - required String roomId, - required int eventRowId, - }) = _ReactionsConfig; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const ReactionsConfig({ + required final String roomId, + required final int eventRowId, +}) with _$ReactionsConfig { + Map toJson() => _$ReactionsConfigToJson(this); factory ReactionsConfig.fromJson(Map json) => _$ReactionsConfigFromJson(json); diff --git a/lib/models/configs/user.dart b/lib/models/configs/user.dart index 0331597..bbbc83d 100644 --- a/lib/models/configs/user.dart +++ b/lib/models/configs/user.dart @@ -1,11 +1,13 @@ import "package:freezed_annotation/freezed_annotation.dart"; + part "user.freezed.dart"; part "user.g.dart"; -@freezed -abstract class UserConfig with _$UserConfig { - const factory UserConfig({required String? roomId, required String userId}) = - _UserConfig; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const UserConfig({final String? roomId, required final String userId}) + with _$UserConfig { + Map toJson() => _$UserConfigToJson(this); factory UserConfig.fromJson(Map json) => _$UserConfigFromJson(json); diff --git a/lib/models/content/avatar.dart b/lib/models/content/avatar.dart index 66d4c47..25167e9 100644 --- a/lib/models/content/avatar.dart +++ b/lib/models/content/avatar.dart @@ -5,7 +5,7 @@ part "avatar.freezed.dart"; part "avatar.g.dart"; @freezed -abstract class AvatarContent extends Content with _$AvatarContent { +sealed class AvatarContent extends Content with _$AvatarContent { AvatarContent._(); factory AvatarContent({ImageInfo? info, Uri? url}) = _AvatarContent; diff --git a/lib/models/content/canonical_alias.dart b/lib/models/content/canonical_alias.dart index 636be13..1a35d92 100644 --- a/lib/models/content/canonical_alias.dart +++ b/lib/models/content/canonical_alias.dart @@ -5,7 +5,7 @@ part "canonical_alias.freezed.dart"; part "canonical_alias.g.dart"; @freezed -abstract class CanonicalAliasContent extends Content +sealed class CanonicalAliasContent extends Content with _$CanonicalAliasContent { CanonicalAliasContent._(); factory CanonicalAliasContent({ diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart index e7b1141..e28ccbf 100644 --- a/lib/models/content/content.dart +++ b/lib/models/content/content.dart @@ -19,9 +19,11 @@ import "package:nexus/models/content/history_visibility.dart"; class Content { final Error? parseError; - Content({this.parseError}); - factory Content.fromJson(Map json) => Content(); + const Content({this.parseError}); + + factory Content.fromJson(Map json) => const Content(); + Map toJson() => {}; static Map readValue(Map json, _) => @@ -34,13 +36,16 @@ class Content { ?.contentFromJson ?? Content.fromJson)(json); } catch (error) { - if (error is Error) return .new(parseError: error); + if (error is Error) return Content(parseError: error); rethrow; } } } -enum EventType { +enum EventType( + final String type, + final Content Function(Map json) contentFromJson, +) { encrypted("m.room.encrypted", EncryptedContent.fromJson), redaction("m.room.redaction", RedactionContent.fromJson), encryption("m.room.encryption", EncryptionContent.fromJson), @@ -61,8 +66,4 @@ enum EventType { reaction("m.reaction", ReactionContent.fromJson), pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson), message("m.room.message", MessageContent.fromJson); - - final String type; - final Content Function(Map json) contentFromJson; - const EventType(this.type, this.contentFromJson); } diff --git a/lib/models/content/create.dart b/lib/models/content/create.dart index c534558..b937a94 100644 --- a/lib/models/content/create.dart +++ b/lib/models/content/create.dart @@ -5,7 +5,7 @@ part "create.freezed.dart"; part "create.g.dart"; @freezed -abstract class CreateContent extends Content with _$CreateContent { +sealed class CreateContent extends Content with _$CreateContent { CreateContent._(); factory CreateContent({ @JsonKey(name: "additional_creators") @@ -30,9 +30,10 @@ enum RoomType { space, } -@freezed -abstract class PreviousRoom with _$PreviousRoom { - const factory PreviousRoom({required String roomId}) = _PreviousRoom; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PreviousRoom({required final String roomId}) with _$PreviousRoom { + Map toJson() => _$PreviousRoomToJson(this); factory PreviousRoom.fromJson(Map json) => _$PreviousRoomFromJson(json); diff --git a/lib/models/content/encrypted.dart b/lib/models/content/encrypted.dart index b33a440..8658a71 100644 --- a/lib/models/content/encrypted.dart +++ b/lib/models/content/encrypted.dart @@ -4,7 +4,7 @@ part "encrypted.freezed.dart"; part "encrypted.g.dart"; @freezed -abstract class EncryptedContent extends Content with _$EncryptedContent { +sealed class EncryptedContent extends Content with _$EncryptedContent { EncryptedContent._(); factory EncryptedContent() = _EncryptedContent; diff --git a/lib/models/content/encryption.dart b/lib/models/content/encryption.dart index 3380632..52c7b34 100644 --- a/lib/models/content/encryption.dart +++ b/lib/models/content/encryption.dart @@ -4,7 +4,7 @@ part "encryption.freezed.dart"; part "encryption.g.dart"; @freezed -abstract class EncryptionContent extends Content with _$EncryptionContent { +sealed class EncryptionContent extends Content with _$EncryptionContent { EncryptionContent._(); factory EncryptionContent({ required String algorithm, diff --git a/lib/models/content/history_visibility.dart b/lib/models/content/history_visibility.dart index 707805c..3cfc011 100644 --- a/lib/models/content/history_visibility.dart +++ b/lib/models/content/history_visibility.dart @@ -4,7 +4,7 @@ part "history_visibility.freezed.dart"; part "history_visibility.g.dart"; @freezed -abstract class HistoryVisibilityContent extends Content +sealed class HistoryVisibilityContent extends Content with _$HistoryVisibilityContent { HistoryVisibilityContent._(); factory HistoryVisibilityContent({ diff --git a/lib/models/content/join_rules.dart b/lib/models/content/join_rules.dart index 1d14eee..3f1c17b 100644 --- a/lib/models/content/join_rules.dart +++ b/lib/models/content/join_rules.dart @@ -6,7 +6,7 @@ part "join_rules.freezed.dart"; part "join_rules.g.dart"; @freezed -abstract class JoinRulesContent extends Content with _$JoinRulesContent { +sealed class JoinRulesContent extends Content with _$JoinRulesContent { JoinRulesContent._(); factory JoinRulesContent({ required JoinRule joinRule, @@ -17,12 +17,13 @@ abstract class JoinRulesContent extends Content with _$JoinRulesContent { _$JoinRulesContentFromJson(json); } -@freezed -abstract class AllowCondition with _$AllowCondition { - const factory AllowCondition({ - String? roomId, - required AllowConditionType type, - }) = _AllowCondition; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const AllowCondition({ + final String? roomId, + required final AllowConditionType type, +}) with _$AllowCondition { + Map toJson() => _$AllowConditionToJson(this); factory AllowCondition.fromJson(Map json) => _$AllowConditionFromJson(json); diff --git a/lib/models/content/membership.dart b/lib/models/content/membership.dart index dbbd123..46be974 100644 --- a/lib/models/content/membership.dart +++ b/lib/models/content/membership.dart @@ -5,7 +5,7 @@ part "membership.freezed.dart"; part "membership.g.dart"; @freezed -abstract class MembershipContent extends Content with _$MembershipContent { +sealed class MembershipContent extends Content with _$MembershipContent { MembershipContent._(); static String? displaynameFromJson(String? displayName) => diff --git a/lib/models/content/message.dart b/lib/models/content/message.dart index e7e8923..36bd725 100644 --- a/lib/models/content/message.dart +++ b/lib/models/content/message.dart @@ -10,7 +10,7 @@ part "message.g.dart"; typedef EncryptedFile = Map; @Freezed(unionKey: "msgtype", fallbackUnion: "default") -abstract class MessageContent extends Content with _$MessageContent { +sealed class MessageContent extends Content with _$MessageContent { MessageContent._(); static String? mediaUrlFromJson(Map json, String key) => json[key] ?? json["file"]?[key]; diff --git a/lib/models/content/name.dart b/lib/models/content/name.dart index 205f6bb..c451d64 100644 --- a/lib/models/content/name.dart +++ b/lib/models/content/name.dart @@ -4,7 +4,7 @@ part "name.freezed.dart"; part "name.g.dart"; @freezed -abstract class NameContent extends Content with _$NameContent { +sealed class NameContent extends Content with _$NameContent { NameContent._(); factory NameContent({required String name}) = _NameContent; diff --git a/lib/models/content/pinned_events.dart b/lib/models/content/pinned_events.dart index 8aea838..9dac5de 100644 --- a/lib/models/content/pinned_events.dart +++ b/lib/models/content/pinned_events.dart @@ -5,7 +5,7 @@ part "pinned_events.freezed.dart"; part "pinned_events.g.dart"; @freezed -abstract class PinnedEventsContent extends Content with _$PinnedEventsContent { +sealed class PinnedEventsContent extends Content with _$PinnedEventsContent { PinnedEventsContent._(); factory PinnedEventsContent({ @Default(IList.empty()) @JsonKey(name: "pinned") IList pinnedEvents, diff --git a/lib/models/content/power_levels.dart b/lib/models/content/power_levels.dart index 3709c38..5ae6561 100644 --- a/lib/models/content/power_levels.dart +++ b/lib/models/content/power_levels.dart @@ -5,7 +5,7 @@ part "power_levels.freezed.dart"; part "power_levels.g.dart"; @freezed -abstract class PowerLevelsContent extends Content with _$PowerLevelsContent { +sealed class PowerLevelsContent extends Content with _$PowerLevelsContent { PowerLevelsContent._(); factory PowerLevelsContent({ @Default(IMap.empty()) IMap events, @@ -24,12 +24,13 @@ abstract class PowerLevelsContent extends Content with _$PowerLevelsContent { _$PowerLevelsContentFromJson(json); } -@freezed -abstract class Notifications with _$Notifications { - const factory Notifications({ - @Default(50) int room, - @Default(IMapConst({})) IMap other, - }) = _Notifications; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Notifications({ + final int room = 50, + final IMap other = const IMap.empty(), +}) with _$Notifications { + Map toJson() => _$NotificationsToJson(this); factory Notifications.fromJson(Map json) => _$NotificationsFromJson(json); diff --git a/lib/models/content/reaction.dart b/lib/models/content/reaction.dart index 0f81bc0..93c563d 100644 --- a/lib/models/content/reaction.dart +++ b/lib/models/content/reaction.dart @@ -4,7 +4,7 @@ part "reaction.freezed.dart"; part "reaction.g.dart"; @Freezed(toJson: false) -abstract class ReactionContent extends Content with _$ReactionContent { +sealed class ReactionContent extends Content with _$ReactionContent { ReactionContent._(); static String? keyJsonFromJson(Map json, String key) => json["m.relates_to"]?["key"]; diff --git a/lib/models/content/redaction.dart b/lib/models/content/redaction.dart index e9c1a90..2ed6436 100644 --- a/lib/models/content/redaction.dart +++ b/lib/models/content/redaction.dart @@ -4,7 +4,7 @@ part "redaction.freezed.dart"; part "redaction.g.dart"; @freezed -abstract class RedactionContent extends Content with _$RedactionContent { +sealed class RedactionContent extends Content with _$RedactionContent { RedactionContent._(); factory RedactionContent({String? reason, String? redacts}) = _RedactionContent; diff --git a/lib/models/content/server_acl.dart b/lib/models/content/server_acl.dart index 1e50988..3873bf3 100644 --- a/lib/models/content/server_acl.dart +++ b/lib/models/content/server_acl.dart @@ -5,7 +5,7 @@ part "server_acl.freezed.dart"; part "server_acl.g.dart"; @freezed -abstract class ServerACLContent extends Content with _$ServerACLContent { +sealed class ServerACLContent extends Content with _$ServerACLContent { ServerACLContent._(); factory ServerACLContent({ @Default(IList.empty()) IList allow, diff --git a/lib/models/content/sticker.dart b/lib/models/content/sticker.dart index 89d9332..c6fa893 100644 --- a/lib/models/content/sticker.dart +++ b/lib/models/content/sticker.dart @@ -5,7 +5,7 @@ part "sticker.freezed.dart"; part "sticker.g.dart"; @freezed -abstract class StickerContent extends Content with _$StickerContent { +sealed class StickerContent extends Content with _$StickerContent { StickerContent._(); factory StickerContent({ required String body, diff --git a/lib/models/content/topic.dart b/lib/models/content/topic.dart index 8fa5229..c8256e3 100644 --- a/lib/models/content/topic.dart +++ b/lib/models/content/topic.dart @@ -1,11 +1,12 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/content/content.dart"; + part "topic.freezed.dart"; part "topic.g.dart"; @freezed -abstract class TopicContent extends Content with _$TopicContent { +sealed class TopicContent extends Content with _$TopicContent { TopicContent._(); factory TopicContent({ required String topic, @@ -16,24 +17,24 @@ abstract class TopicContent extends Content with _$TopicContent { _$TopicContentFromJson(json); } -@freezed -abstract class TopicContentBlock with _$TopicContentBlock { - factory TopicContentBlock({ - @Default(IList.empty()) - @JsonKey(name: "m.text") - IList representations, - }) = _TopicContentBlock; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const TopicContentBlock({ + final IList representations = const IList.empty(), +}) with _$TopicContentBlock { + Map toJson() => _$TopicContentBlockToJson(this); factory TopicContentBlock.fromJson(Map json) => _$TopicContentBlockFromJson(json); } -@freezed -abstract class TextualRepresentation with _$TextualRepresentation { - factory TextualRepresentation({ - required String body, - @Default("text/plain") String mimetype, - }) = _TextualRepresentation; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const TextualRepresentation({ + required final String body, + final String mimetype = "text/plain", +}) with _$TextualRepresentation { + Map toJson() => _$TextualRepresentationToJson(this); factory TextualRepresentation.fromJson(Map json) => _$TextualRepresentationFromJson(json); diff --git a/lib/models/direction.dart b/lib/models/direction.dart new file mode 100644 index 0000000..8c4947e --- /dev/null +++ b/lib/models/direction.dart @@ -0,0 +1,8 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +enum Direction { + @JsonValue("f") + forward, + @JsonValue("b") + backward, +} diff --git a/lib/models/emoji.dart b/lib/models/emoji.dart index 8e4eac6..75faf08 100644 --- a/lib/models/emoji.dart +++ b/lib/models/emoji.dart @@ -1,17 +1,25 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; +import "package:flutter/widgets.dart"; part "emoji.freezed.dart"; part "emoji.g.dart"; -@freezed -abstract class Emoji with _$Emoji { - const factory Emoji({ - required String emoji, - required String category, - required IList aliases, - required String description, - required IList tags, - }) = _Emoji; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class Emoji({ + @JsonKey( + fromJson: Emoji.widgetFromJson, + readValue: Emoji.readWidgetValueFromJson, + ) + required final Widget widget, + @JsonKey(name: "emoji") required final String value, + required final IList aliases, + required final String description, + required final IList tags, +}) with _$Emoji { + static Widget widgetFromJson(String emoji) => Text(emoji); + static String readWidgetValueFromJson(Map json, _) => + json["emoji"]; factory Emoji.fromJson(Map json) => _$EmojiFromJson(json); } diff --git a/lib/models/emoji_category.dart b/lib/models/emoji_category.dart new file mode 100644 index 0000000..cc39bc3 --- /dev/null +++ b/lib/models/emoji_category.dart @@ -0,0 +1,12 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/emoji.dart"; +import "package:flutter/widgets.dart"; +part "emoji_category.freezed.dart"; + +@Freezed(toJson: false, fromJson: false) +class EmojiCategory({ + required final Widget icon, + required final String name, + required final IList emojis, +}) with _$EmojiCategory; diff --git a/lib/models/event.dart b/lib/models/event.dart index c54dbc5..de065fe 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -2,12 +2,40 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/epoch_date_time_converter.dart"; -import "package:nexus/models/profile.dart"; +import "package:nexus/models/profile_response.dart"; + part "event.freezed.dart"; part "event.g.dart"; -@freezed -abstract class Event with _$Event { +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Event({ + @JsonKey(name: "rowid") required final int rowId, + @JsonKey(name: "timeline_rowid") required final int timelineRowId, + final String? stateKey, + required final String roomId, + required final String eventId, + required final String sender, + @JsonKey(readValue: Event.typeJsonFromJson) required final String type, + @EpochDateTimeConverter() required final DateTime timestamp, + final IMap unsigned = const IMap.empty(), + final LocalContent? localContent, + final String? transactionId, + final String? redactedBy, + final String? relatesTo, + final String? relationType, + final String? replyTo, + final String? decryptionError, + final String? sendError, + final IMap reactions = const IMap.empty(), + @JsonKey(name: "last_edit_rowid") final int lastEditRowId = 0, + final UnreadType? unreadType, + final Profile? pmp, + required final Content content, + required final Content? previousContent, +}) with _$Event { + Map toJson() => _$EventToJson(this); + static String typeJsonFromJson(Map json, _) => json["decrypted_type"] ?? json["type"]; @@ -17,37 +45,17 @@ abstract class Event with _$Event { return content["m.new_content"] ?? content; } - const factory Event({ - @JsonKey(name: "rowid") required int rowId, - @JsonKey(name: "timeline_rowid") required int timelineRowId, - required String roomId, - required String eventId, - required String sender, - @JsonKey(readValue: Event.typeJsonFromJson) required String type, - String? stateKey, - @EpochDateTimeConverter() required DateTime timestamp, - @Default(IMap.empty()) IMap unsigned, - LocalContent? localContent, - String? transactionId, - String? redactedBy, - String? relatesTo, - String? relationType, - String? replyTo, - String? decryptionError, - String? sendError, - @Default(IMap.empty()) IMap reactions, - @JsonKey(name: "last_edit_rowid") @Default(0) int lastEditRowId, - @UnreadTypeConverter() UnreadType? unreadType, - Profile? pmp, - required Content content, - required Content? previousContent, - }) = _Event; + static String? replyToFromJson(Map json) { + try { + return json["m.relates_to"]?["m.in_reply_to"]?["event_id"]; + } catch (_) { + return null; + } + } factory Event.fromJson(Map json) => _$EventFromJson(json).copyWith( - replyTo: getContentFromJson( - json, - )["m.relates_to"]?["m.in_reply_to"]?["event_id"], + replyTo: replyToFromJson(getContentFromJson(json)), pmp: json["content"]?["com.beeper.per_message_profile"] == null ? null : Profile.fromJsonWithCatch( @@ -66,37 +74,27 @@ abstract class Event with _$Event { ); } -@freezed -abstract class LocalContent with _$LocalContent { - const factory LocalContent({ - String? sanitizedHtml, - String? editSource, - bool? wasPlaintext, - bool? bigEmoji, - bool? hasMath, - bool? replyFallbackRemoved, - }) = _LocalContent; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const LocalContent({ + final String? sanitizedHtml, + final String? editSource, + final bool? wasPlaintext, + final bool? bigEmoji, + final bool? hasMath, + final bool? replyFallbackRemoved, +}) with _$LocalContent { + Map toJson() => _$LocalContentToJson(this); + @override factory LocalContent.fromJson(Map json) => _$LocalContentFromJson(json); } -class UnreadTypeConverter implements JsonConverter { - const UnreadTypeConverter(); - - @override - UnreadType? fromJson(int? json) => json == null ? null : UnreadType(json); - - @override - int? toJson(UnreadType? object) => object?.value; -} - -// I think this is correct but I'm not sure, its some type of bitmask. -@immutable -class UnreadType { - final int value; - - const UnreadType(this.value); +@Freezed(toJson: false, fromJson: false) +class const UnreadType(final int value) with _$UnreadType { + factory UnreadType.fromJson(int json) => UnreadType(json); + int toJson() => value; static const none = UnreadType(0); static const normal = UnreadType(1); @@ -104,9 +102,6 @@ class UnreadType { static const highlight = UnreadType(4); static const sound = UnreadType(8); - bool get isNone => value == 0; - bool get isNormal => (value & 1) != 0; - bool get shouldNotify => (value & 2) != 0; - bool get isHighlighted => (value & 4) != 0; - bool get playsSound => (value & 8) != 0; + bool has(UnreadType flag) => value & flag.value != 0; + UnreadType operator |(UnreadType other) => UnreadType(value | other.value); } diff --git a/lib/models/event_context.dart b/lib/models/event_context.dart new file mode 100644 index 0000000..6103c7d --- /dev/null +++ b/lib/models/event_context.dart @@ -0,0 +1,22 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/event.dart"; + +part "event_context.freezed.dart"; +part "event_context.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const EventContext({ + required final Event event, + required final IList before, + required final IList after, + required final String start, + required final String end, + final IList relatedEvents = const IList.empty(), +}) with _$EventContext { + Map toJson() => _$EventContextToJson(this); + + factory EventContext.fromJson(Map json) => + _$EventContextFromJson(json); +} diff --git a/lib/models/gomuks_config.dart b/lib/models/gomuks_config.dart new file mode 100644 index 0000000..e6a8ce7 --- /dev/null +++ b/lib/models/gomuks_config.dart @@ -0,0 +1,55 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "gomuks_config.freezed.dart"; +part "gomuks_config.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const GomuksConfig({ + final MatrixConfig? matrix, + final PushConfig? push, + final MediaConfig? media, +}) with _$GomuksConfig { + Map toJson() => _$GomuksConfigToJson(this); + + factory GomuksConfig.fromJson(Map json) => + _$GomuksConfigFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const MatrixConfig({ + @JsonKey(name: "disable_http2") final bool disableHttp2 = false, + @JsonKey(name: "set_presence") final String? setPresence, + @JsonKey(name: "initial_device_display_name") + required final String initialDeviceDisplayName, +}) with _$MatrixConfig { + Map toJson() => _$MatrixConfigToJson(this); + + factory MatrixConfig.fromJson(Map json) => + _$MatrixConfigFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PushConfig({ + @JsonKey(name: "fcm_gateway") required final String fcmGateway, + @JsonKey(name: "vapid_private_key") required final String vapidPrivateKey, + @JsonKey(name: "vapid_public_key") required final String vapidPublicKey, +}) with _$PushConfig { + Map toJson() => _$PushConfigToJson(this); + + factory PushConfig.fromJson(Map json) => + _$PushConfigFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const MediaConfig({ + @JsonKey(name: "thumbnail_size") required final int thumbnailSize, +}) with _$MediaConfig { + Map toJson() => _$MediaConfigToJson(this); + + factory MediaConfig.fromJson(Map json) => + _$MediaConfigFromJson(json); +} diff --git a/lib/models/homeserver.dart b/lib/models/homeserver.dart index 903e23d..5504941 100644 --- a/lib/models/homeserver.dart +++ b/lib/models/homeserver.dart @@ -1,12 +1,11 @@ import "package:freezed_annotation/freezed_annotation.dart"; + part "homeserver.freezed.dart"; @freezed -abstract class Homeserver with _$Homeserver { - const factory Homeserver({ - required String name, - required String description, - required Uri url, - required String iconUrl, - }) = _Homeserver; -} +class const Homeserver({ + required final String name, + required final String description, + required final Uri url, + required final String iconUrl, +}) with _$Homeserver; diff --git a/lib/models/info/audio.dart b/lib/models/info/audio.dart index ccfcf7a..ac90282 100644 --- a/lib/models/info/audio.dart +++ b/lib/models/info/audio.dart @@ -1,16 +1,17 @@ import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/ms_duration.dart"; + part "audio.freezed.dart"; part "audio.g.dart"; -@freezed -abstract class AudioInfo with _$AudioInfo { - /// Information for images, [size] is in bytes. - const factory AudioInfo({ - @MSDuration() Duration? duration, - @JsonKey(name: "mimetype") String? mimeType, - int? size, - }) = _AudioInfo; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const AudioInfo({ + @MSDuration() final Duration? duration, + @JsonKey(name: "mimetype") final String? mimeType, + final int? size, +}) with _$AudioInfo { + Map toJson() => _$AudioInfoToJson(this); factory AudioInfo.fromJson(Map json) => _$AudioInfoFromJson(json); diff --git a/lib/models/info/file.dart b/lib/models/info/file.dart index 1509c99..ce0aa84 100644 --- a/lib/models/info/file.dart +++ b/lib/models/info/file.dart @@ -1,14 +1,15 @@ import "package:freezed_annotation/freezed_annotation.dart"; + part "file.freezed.dart"; part "file.g.dart"; -@freezed -abstract class FileInfo with _$FileInfo { - /// Information for images, [size] is in bytes. - const factory FileInfo({ - @JsonKey(name: "mimetype") String? mimeType, - int? size, - }) = _FileInfo; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const FileInfo({ + @JsonKey(name: "mimetype") final String? mimeType, + final int? size, +}) with _$FileInfo { + Map toJson() => _$FileInfoToJson(this); factory FileInfo.fromJson(Map json) => _$FileInfoFromJson(json); diff --git a/lib/models/info/image.dart b/lib/models/info/image.dart index 9833016..266fb01 100644 --- a/lib/models/info/image.dart +++ b/lib/models/info/image.dart @@ -1,17 +1,18 @@ import "package:freezed_annotation/freezed_annotation.dart"; + part "image.freezed.dart"; part "image.g.dart"; -@freezed -abstract class ImageInfo with _$ImageInfo { - /// Information for images, [size] is in bytes. - const factory ImageInfo({ - @JsonKey(name: "h") double? height, - @JsonKey(name: "w") double? width, - @JsonKey(name: "mimetype") String? mimeType, - @JsonKey(name: "xyz.amorgan.blurhash") String? blurHash, - int? size, - }) = _ImageInfo; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const ImageInfo({ + @JsonKey(name: "h") final double? height, + @JsonKey(name: "w") final double? width, + @JsonKey(name: "mimetype") final String? mimeType, + @JsonKey(name: "xyz.amorgan.blurhash") final String? blurHash, + final int? size, +}) with _$ImageInfo { + Map toJson() => _$ImageInfoToJson(this); factory ImageInfo.fromJson(Map json) => _$ImageInfoFromJson(json); diff --git a/lib/models/info/video.dart b/lib/models/info/video.dart index 6ff3547..31b1fb6 100644 --- a/lib/models/info/video.dart +++ b/lib/models/info/video.dart @@ -1,18 +1,19 @@ import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/ms_duration.dart"; + part "video.freezed.dart"; part "video.g.dart"; -@freezed -abstract class VideoInfo with _$VideoInfo { - /// Information for images, [size] is in bytes. - const factory VideoInfo({ - @JsonKey(name: "h") int? height, - @JsonKey(name: "w") int? width, - @JsonKey(name: "mimetype") String? mimeType, - @MSDuration() Duration? duration, - int? size, - }) = _VideoInfo; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const VideoInfo({ + @JsonKey(name: "h") final int? height, + @JsonKey(name: "w") final int? width, + @JsonKey(name: "mimetype") final String? mimeType, + @MSDuration() final Duration? duration, + final int? size, +}) with _$VideoInfo { + Map toJson() => _$VideoInfoToJson(this); factory VideoInfo.fromJson(Map json) => _$VideoInfoFromJson(json); diff --git a/lib/models/join_rule.dart b/lib/models/join_rule.dart index 3fade23..44fb9bb 100644 --- a/lib/models/join_rule.dart +++ b/lib/models/join_rule.dart @@ -1,4 +1,4 @@ import "package:freezed_annotation/freezed_annotation.dart"; -@JsonEnum(fieldRename: FieldRename.snake) +@JsonEnum(fieldRename: .snake) enum JoinRule { public, knock, invite, private, restricted, knockRestricted } diff --git a/lib/models/lazy_load_summary.dart b/lib/models/lazy_load_summary.dart index 0cd250f..fba1c98 100644 --- a/lib/models/lazy_load_summary.dart +++ b/lib/models/lazy_load_summary.dart @@ -1,15 +1,18 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; + part "lazy_load_summary.freezed.dart"; part "lazy_load_summary.g.dart"; -@freezed -abstract class LazyLoadSummary with _$LazyLoadSummary { - const factory LazyLoadSummary({ - required IList? heroes, - required int? joinedMemberCount, - required int? invitedMemberCount, - }) = _LazyLoadSummary; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const LazyLoadSummary({ + @JsonKey(name: "m.heroes") required final IList? heroes, + @JsonKey(name: "m.joined_member_count") required final int? joinedMemberCount, + @JsonKey(name: "m.invited_member_count") + required final int? invitedMemberCount, +}) with _$LazyLoadSummary { + Map toJson() => _$LazyLoadSummaryToJson(this); factory LazyLoadSummary.fromJson(Map json) => _$LazyLoadSummaryFromJson(json); diff --git a/lib/models/membership_action.dart b/lib/models/membership_action.dart index d852164..f03ca99 100644 --- a/lib/models/membership_action.dart +++ b/lib/models/membership_action.dart @@ -1,4 +1 @@ -import "package:freezed_annotation/freezed_annotation.dart"; - -@JsonEnum() enum MembershipAction { ban, kick, unban, invite } diff --git a/lib/models/membership_status.dart b/lib/models/membership_status.dart index ba7a241..7eb5f72 100644 --- a/lib/models/membership_status.dart +++ b/lib/models/membership_status.dart @@ -1,4 +1 @@ -import "package:freezed_annotation/freezed_annotation.dart"; - -@JsonEnum() enum MembershipStatus { leave, invite, ban, join, knock } diff --git a/lib/models/nav_page.dart b/lib/models/nav_page.dart new file mode 100644 index 0000000..2443c53 --- /dev/null +++ b/lib/models/nav_page.dart @@ -0,0 +1,8 @@ +import "package:flutter/widgets.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; + +abstract class const NavPage({super.key}) extends Widget { + String get title; + IconData get icon; + Future? badgeBuilder(WidgetRef ref) => null; +} diff --git a/lib/models/oauth_auth_code_response.dart b/lib/models/oauth_auth_code_response.dart index 5bb3f3f..3074335 100644 --- a/lib/models/oauth_auth_code_response.dart +++ b/lib/models/oauth_auth_code_response.dart @@ -1,14 +1,16 @@ import "package:freezed_annotation/freezed_annotation.dart"; + part "oauth_auth_code_response.freezed.dart"; part "oauth_auth_code_response.g.dart"; -@freezed -abstract class OAuthAuthCodeResponse with _$OAuthAuthCodeResponse { - const factory OAuthAuthCodeResponse({ - required String state, - required String codeVerifier, - required Uri url, - }) = _OAuthAuthCodeResponse; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const OAuthAuthCodeResponse({ + required final String state, + required final String codeVerifier, + required final Uri url, +}) with _$OAuthAuthCodeResponse { + Map toJson() => _$OAuthAuthCodeResponseToJson(this); factory OAuthAuthCodeResponse.fromJson(Map json) => _$OAuthAuthCodeResponseFromJson(json); diff --git a/lib/models/open_graph_data.dart b/lib/models/open_graph_data.dart index d7e840d..a183ef7 100644 --- a/lib/models/open_graph_data.dart +++ b/lib/models/open_graph_data.dart @@ -1,16 +1,18 @@ import "package:freezed_annotation/freezed_annotation.dart"; + part "open_graph_data.freezed.dart"; part "open_graph_data.g.dart"; -@freezed -abstract class OpenGraphData with _$OpenGraphData { - const factory OpenGraphData({ - @JsonKey(name: "og:title") required String? title, - @JsonKey(name: "og:description") required String? description, - @JsonKey(name: "og:image") required Uri? imageUrl, - @JsonKey(name: "og:image:width") required double? width, - @JsonKey(name: "og:image:height") required double? height, - }) = _OpenGraphData; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const OpenGraphData({ + @JsonKey(name: "og:title") required final String? title, + @JsonKey(name: "og:description") required final String? description, + @JsonKey(name: "og:image") required final Uri? imageUrl, + @JsonKey(name: "og:image:width") required final double? width, + @JsonKey(name: "og:image:height") required final double? height, +}) with _$OpenGraphData { + Map toJson() => _$OpenGraphDataToJson(this); factory OpenGraphData.fromJson(Map json) => _$OpenGraphDataFromJson(json); diff --git a/lib/models/paginate.dart b/lib/models/paginate.dart index df0a0f6..dc6eede 100644 --- a/lib/models/paginate.dart +++ b/lib/models/paginate.dart @@ -1,16 +1,18 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/event.dart"; + part "paginate.freezed.dart"; part "paginate.g.dart"; -@freezed -abstract class Paginate with _$Paginate { - const factory Paginate({ - required IList events, - required IList relatedEvents, - required bool hasMore, - }) = _Paginate; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Paginate({ + required final IList events, + required final IList relatedEvents, + required final bool hasMore, +}) with _$Paginate { + Map toJson() => _$PaginateToJson(this); factory Paginate.fromJson(Map json) => _$PaginateFromJson(json); diff --git a/lib/models/paginate_manual.dart b/lib/models/paginate_manual.dart new file mode 100644 index 0000000..9f45c1f --- /dev/null +++ b/lib/models/paginate_manual.dart @@ -0,0 +1,19 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/event.dart"; + +part "paginate_manual.freezed.dart"; +part "paginate_manual.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PaginateManual({ + required final IList events, + final IList relatedEvents = const IList.empty(), + required final String? nextBatch, +}) with _$PaginateManual { + Map toJson() => _$PaginateManualToJson(this); + + factory PaginateManual.fromJson(Map json) => + _$PaginateManualFromJson(json); +} diff --git a/lib/models/profile.dart b/lib/models/profile.dart deleted file mode 100644 index 6ae1e94..0000000 --- a/lib/models/profile.dart +++ /dev/null @@ -1,52 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/content/membership.dart"; -part "profile.freezed.dart"; -part "profile.g.dart"; - -@freezed -abstract class Profile with _$Profile { - static Object? readPronouns(Map map, _) => - map["m.pronouns"] ?? map["io.fsky.nyx.pronouns"]; - - static Object? readTimezone(Map map, _) => - map["m.tz"] ?? map["us.cloke.msc4175.tz"]; - - const factory Profile({ - required String id, - String? parseError, - Uri? avatarUrl, - - @JsonKey( - name: "displayname", - fromJson: MembershipContent.displaynameFromJson, - ) - String? displayName, - - @JsonKey(readValue: Profile.readTimezone, name: "m.tz") String? timezone, - - @Default(IList.empty()) - @JsonKey(readValue: Profile.readPronouns, name: "io.fsky.nyx.pronouns") - IList pronouns, - }) = _Profile; - - factory Profile.fromJson(Map json) => - _$ProfileFromJson(json); - - factory Profile.fromJsonWithCatch(Map json) { - try { - return Profile.fromJson(json); - } catch (error) { - return Profile(id: json["id"], parseError: error.toString()); - } - } -} - -@freezed -abstract class Pronoun with _$Pronoun { - const factory Pronoun({required String language, required String summary}) = - _Pronoun; - - factory Pronoun.fromJson(Map json) => - _$PronounFromJson(json); -} diff --git a/lib/models/profile_response.dart b/lib/models/profile_response.dart new file mode 100644 index 0000000..57f76c5 --- /dev/null +++ b/lib/models/profile_response.dart @@ -0,0 +1,75 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/membership.dart"; + +part "profile_response.freezed.dart"; +part "profile_response.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const ProfileResponse({ + @JsonKey(fromJson: Profile.fromJson) required final Profile profile, + final Bio? bio, +}) with _$ProfileResponse { + Map toJson() => _$ProfileResponseToJson(this); + + factory ProfileResponse.fromJson(Map json) => + _$ProfileResponseFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Bio({required final String html, final String? editSource}) + with _$Bio { + Map toJson() => _$BioToJson(this); + + factory Bio.fromJson(Map json) => _$BioFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Profile({ + final String? id, + final String? parseError, + final Uri? avatarUrl, + + @JsonKey(name: "displayname", fromJson: MembershipContent.displaynameFromJson) + final String? displayName, + + @JsonKey(readValue: Profile.readTimezone, name: "m.tz") + final String? timezone, + + @JsonKey(readValue: Profile.readPronouns, name: "m.pronouns") + final IList pronouns = const IList.empty(), +}) with _$Profile { + Map toJson() => _$ProfileToJson(this); + + static Object? readPronouns(Map map, String key) => + map[key] ?? map["io.fsky.nyx.pronouns"]; + + static Object? readTimezone(Map map, String key) => + map[key] ?? map["us.cloke.msc4175.tz"]; + + factory Profile.fromJson(Map json) => + _$ProfileFromJson(json); + + factory Profile.fromJsonWithCatch(Map json) { + try { + return Profile.fromJson(json); + } catch (error) { + return Profile(parseError: error.toString()); + } + } +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Pronoun({ + required final String language, + required final String summary, +}) with _$Pronoun { + Map toJson() => _$PronounToJson(this); + + factory Pronoun.fromJson(Map json) => + _$PronounFromJson(json); +} diff --git a/lib/models/read_receipt.dart b/lib/models/read_receipt.dart index d533e2d..ab4edd8 100644 --- a/lib/models/read_receipt.dart +++ b/lib/models/read_receipt.dart @@ -1,17 +1,19 @@ import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/epoch_date_time_converter.dart"; + part "read_receipt.freezed.dart"; part "read_receipt.g.dart"; -@freezed -abstract class ReadReceipt with _$ReadReceipt { - const factory ReadReceipt({ - String? roomId, - required String userId, - String? threadId, - required String eventId, - @EpochDateTimeConverter() required DateTime timestamp, - }) = _ReadReceipt; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const ReadReceipt({ + final String? roomId, + required final String userId, + final String? threadId, + required final String eventId, + @EpochDateTimeConverter() required final DateTime timestamp, +}) with _$ReadReceipt { + Map toJson() => _$ReadReceiptToJson(this); factory ReadReceipt.fromJson(Map json) => _$ReadReceiptFromJson(json); diff --git a/lib/models/requests/deregister_pusher.dart b/lib/models/requests/deregister_pusher.dart new file mode 100644 index 0000000..1416add --- /dev/null +++ b/lib/models/requests/deregister_pusher.dart @@ -0,0 +1,13 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "deregister_pusher.freezed.dart"; +part "deregister_pusher.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const DeregisterPusherRequest({ + required final String appId, + @JsonKey(name: "pushkey") required final String pushKey, +}) with _$DeregisterPusherRequest { + Map toJson() => _$DeregisterPusherRequestToJson(this); +} diff --git a/lib/models/requests/download_media.dart b/lib/models/requests/download_media.dart index b5d5771..c9751f8 100644 --- a/lib/models/requests/download_media.dart +++ b/lib/models/requests/download_media.dart @@ -1,15 +1,17 @@ import "package:freezed_annotation/freezed_annotation.dart"; + part "download_media.freezed.dart"; part "download_media.g.dart"; -@freezed -abstract class DownloadMediaRequest with _$DownloadMediaRequest { - const factory DownloadMediaRequest({ - required Uri mxc, - @Default(false) bool encrypted, - @Default(false) bool isAvatar, - @Default(false) bool thumbnailAvatar, - }) = _DownloadMediaRequest; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const DownloadMediaRequest({ + required final Uri mxc, + final bool encrypted = false, + final bool isAvatar = false, + final bool thumbnailAvatar = false, +}) with _$DownloadMediaRequest { + Map toJson() => _$DownloadMediaRequestToJson(this); factory DownloadMediaRequest.fromJson(Map json) => _$DownloadMediaRequestFromJson(json); diff --git a/lib/models/requests/get_event.dart b/lib/models/requests/get_event.dart index 2665a2a..b4692ee 100644 --- a/lib/models/requests/get_event.dart +++ b/lib/models/requests/get_event.dart @@ -1,14 +1,16 @@ import "package:freezed_annotation/freezed_annotation.dart"; + part "get_event.freezed.dart"; part "get_event.g.dart"; -@freezed -abstract class GetEventRequest with _$GetEventRequest { - const factory GetEventRequest({ - required String roomId, - required String eventId, - @Default(false) bool unredact, - }) = _GetEventRequest; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const GetEventRequest({ + required final String roomId, + required final String eventId, + final bool unredact = false, +}) with _$GetEventRequest { + Map toJson() => _$GetEventRequestToJson(this); factory GetEventRequest.fromJson(Map json) => _$GetEventRequestFromJson(json); diff --git a/lib/models/requests/get_event_context.dart b/lib/models/requests/get_event_context.dart new file mode 100644 index 0000000..68f23a0 --- /dev/null +++ b/lib/models/requests/get_event_context.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "get_event_context.freezed.dart"; +part "get_event_context.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const GetEventContextRequest({ + required final String roomId, + required final String eventId, + final int limit = 20, +}) with _$GetEventContextRequest { + Map toJson() => _$GetEventContextRequestToJson(this); + + factory GetEventContextRequest.fromJson(Map json) => + _$GetEventContextRequestFromJson(json); +} diff --git a/lib/models/requests/get_mentions.dart b/lib/models/requests/get_mentions.dart new file mode 100644 index 0000000..1512b6f --- /dev/null +++ b/lib/models/requests/get_mentions.dart @@ -0,0 +1,19 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/epoch_date_time_converter.dart"; +import "package:nexus/models/event.dart"; +part "get_mentions.freezed.dart"; +part "get_mentions.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class GetMentionsRequest({ + @EpochDateTimeConverter() required final DateTime maxTimestamp, + @JsonKey(name: "type") required final UnreadType unreadType, + final int limit = 20, + final String? roomId, +}) with _$GetMentionsRequest { + Map toJson() => _$GetMentionsRequestToJson(this); + + factory GetMentionsRequest.fromJson(Map json) => + _$GetMentionsRequestFromJson(json); +} diff --git a/lib/models/requests/get_related_events.dart b/lib/models/requests/get_related_events.dart index 52d2716..08be426 100644 --- a/lib/models/requests/get_related_events.dart +++ b/lib/models/requests/get_related_events.dart @@ -1,14 +1,16 @@ import "package:freezed_annotation/freezed_annotation.dart"; + part "get_related_events.freezed.dart"; part "get_related_events.g.dart"; -@freezed -abstract class GetRelatedEventsRequest with _$GetRelatedEventsRequest { - const factory GetRelatedEventsRequest({ - required String roomId, - required String eventId, - required String relationType, - }) = _GetRelatedEventsRequest; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const GetRelatedEventsRequest({ + required final String roomId, + required final String eventId, + required final String relationType, +}) with _$GetRelatedEventsRequest { + Map toJson() => _$GetRelatedEventsRequestToJson(this); factory GetRelatedEventsRequest.fromJson(Map json) => _$GetRelatedEventsRequestFromJson(json); diff --git a/lib/models/requests/get_room_state.dart b/lib/models/requests/get_room_state.dart index d3f52f7..66504da 100644 --- a/lib/models/requests/get_room_state.dart +++ b/lib/models/requests/get_room_state.dart @@ -1,15 +1,17 @@ import "package:freezed_annotation/freezed_annotation.dart"; + part "get_room_state.freezed.dart"; part "get_room_state.g.dart"; -@freezed -abstract class GetRoomStateRequest with _$GetRoomStateRequest { - const factory GetRoomStateRequest({ - required String roomId, - @Default(false) bool refetch, - @Default(false) bool fetchMembers, - @Default(false) bool includeMembers, - }) = _GetRoomStateRequest; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const GetRoomStateRequest({ + required final String roomId, + final bool refetch = false, + final bool fetchMembers = false, + final bool includeMembers = false, +}) with _$GetRoomStateRequest { + Map toJson() => _$GetRoomStateRequestToJson(this); factory GetRoomStateRequest.fromJson(Map json) => _$GetRoomStateRequestFromJson(json); diff --git a/lib/models/requests/join_room.dart b/lib/models/requests/join_room.dart index 8487298..350b762 100644 --- a/lib/models/requests/join_room.dart +++ b/lib/models/requests/join_room.dart @@ -1,14 +1,16 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; + part "join_room.freezed.dart"; part "join_room.g.dart"; -@freezed -abstract class JoinRoomRequest with _$JoinRoomRequest { - const factory JoinRoomRequest({ - required String roomIdOrAlias, - required IList via, - }) = _JoinRoomRequest; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const JoinRoomRequest({ + required final String roomIdOrAlias, + final IList via = const IList.empty(), +}) with _$JoinRoomRequest { + Map toJson() => _$JoinRoomRequestToJson(this); factory JoinRoomRequest.fromJson(Map json) => _$JoinRoomRequestFromJson(json); diff --git a/lib/models/requests/oauth/exchange_token.dart b/lib/models/requests/oauth/exchange_token.dart index 9d55c1f..fb47358 100644 --- a/lib/models/requests/oauth/exchange_token.dart +++ b/lib/models/requests/oauth/exchange_token.dart @@ -1,16 +1,18 @@ import "package:freezed_annotation/freezed_annotation.dart"; + part "exchange_token.freezed.dart"; part "exchange_token.g.dart"; -@freezed -abstract class OAuthExchangeTokenRequest with _$OAuthExchangeTokenRequest { - const factory OAuthExchangeTokenRequest({ - required Uri homeserverUrl, - required String codeVerifier, - required Uri redirectUri, - required String code, - required String clientId, - }) = _OAuthExchangeTokenRequest; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const OAuthExchangeTokenRequest({ + required final Uri homeserverUrl, + required final String codeVerifier, + required final Uri redirectUri, + required final String code, + required final String clientId, +}) with _$OAuthExchangeTokenRequest { + Map toJson() => _$OAuthExchangeTokenRequestToJson(this); factory OAuthExchangeTokenRequest.fromJson(Map json) => _$OAuthExchangeTokenRequestFromJson(json); diff --git a/lib/models/requests/oauth/get_auth_url.dart b/lib/models/requests/oauth/get_auth_url.dart index 5ca5b6f..1ff166a 100644 --- a/lib/models/requests/oauth/get_auth_url.dart +++ b/lib/models/requests/oauth/get_auth_url.dart @@ -2,25 +2,27 @@ import "dart:math"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; + part "get_auth_url.freezed.dart"; part "get_auth_url.g.dart"; -@freezed -abstract class OAuthGetAuthUrl with _$OAuthGetAuthUrl { - const factory OAuthGetAuthUrl({ - required ResponseMode responseMode, - required Uri homeserverUrl, - required Uri redirectUri, - required IList scopes, - required String clientId, - String? userIdHint, - }) = _OAuthGetAuthUrl; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const OAuthGetAuthUrl({ + required final ResponseMode responseMode, + required final Uri homeserverUrl, + required final Uri redirectUri, + required final IList scopes, + required final String clientId, + final String? userIdHint, +}) with _$OAuthGetAuthUrl { + Map toJson() => _$OAuthGetAuthUrlToJson(this); factory OAuthGetAuthUrl.fromJson(Map json) => _$OAuthGetAuthUrlFromJson(json); } -abstract class Scope { +sealed class Scope { static final openid = "openid"; static final email = "email"; static final clientApi = "urn:matrix:client:api:*"; diff --git a/lib/models/requests/oauth/register_client.dart b/lib/models/requests/oauth/register_client.dart index cf4f9e0..e5c1a70 100644 --- a/lib/models/requests/oauth/register_client.dart +++ b/lib/models/requests/oauth/register_client.dart @@ -1,26 +1,27 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; + part "register_client.freezed.dart"; part "register_client.g.dart"; -@freezed -abstract class OAuthRegisterClientRequest with _$OAuthRegisterClientRequest { - const factory OAuthRegisterClientRequest({ - required Uri homeserverUrl, - @Default(ApplicationType.web) ApplicationType applicationType, - String? clientName, - required Uri clientUri, - Uri? logoUri, - Uri? policyUri, - Uri? tosUri, - IList? grantTypes, - IList? redirectUris, - IList? responseTypes, +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const OAuthRegisterClientRequest({ + required final Uri homeserverUrl, + final ApplicationType applicationType = ApplicationType.web, + final String? clientName, + required final Uri clientUri, + final Uri? logoUri, + final Uri? policyUri, + final Uri? tosUri, + final IList? grantTypes, + final IList? redirectUris, + final IList? responseTypes, - @Default(AuthMethod.none) - @JsonKey(name: "token_endpoint_auth_method") - AuthMethod? authMethod, - }) = _OAuthRegisterClientRequest; + @JsonKey(name: "token_endpoint_auth_method") + final AuthMethod? authMethod = AuthMethod.none, +}) with _$OAuthRegisterClientRequest { + Map toJson() => _$OAuthRegisterClientRequestToJson(this); factory OAuthRegisterClientRequest.fromJson(Map json) => _$OAuthRegisterClientRequestFromJson(json); diff --git a/lib/models/requests/paginate.dart b/lib/models/requests/paginate.dart index ddc62f3..b3903c6 100644 --- a/lib/models/requests/paginate.dart +++ b/lib/models/requests/paginate.dart @@ -1,14 +1,16 @@ import "package:freezed_annotation/freezed_annotation.dart"; + part "paginate.freezed.dart"; part "paginate.g.dart"; -@freezed -abstract class PaginateRequest with _$PaginateRequest { - const factory PaginateRequest({ - required String roomId, - required int? maxTimelineId, - @Default(20) int limit, - }) = _PaginateRequest; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PaginateRequest({ + required final String roomId, + required final int? maxTimelineId, + final int limit = 20, +}) with _$PaginateRequest { + Map toJson() => _$PaginateRequestToJson(this); factory PaginateRequest.fromJson(Map json) => _$PaginateRequestFromJson(json); diff --git a/lib/models/requests/paginate_manual.dart b/lib/models/requests/paginate_manual.dart new file mode 100644 index 0000000..9f8a1d3 --- /dev/null +++ b/lib/models/requests/paginate_manual.dart @@ -0,0 +1,22 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/direction.dart"; + +part "paginate_manual.freezed.dart"; +part "paginate_manual.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PaginateManualRequest({ + required final String roomId, + // Root event ID of a thread to paginate + final String? threadRoot, + // Can be null for starting pagination of a thread + final String? since, + required final Direction direction, + final int limit = 20, +}) with _$PaginateManualRequest { + Map toJson() => _$PaginateManualRequestToJson(this); + + factory PaginateManualRequest.fromJson(Map json) => + _$PaginateManualRequestFromJson(json); +} diff --git a/lib/models/requests/register_pusher.dart b/lib/models/requests/register_pusher.dart new file mode 100644 index 0000000..dd34e62 --- /dev/null +++ b/lib/models/requests/register_pusher.dart @@ -0,0 +1,56 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "register_pusher.freezed.dart"; +part "register_pusher.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RegisterPusherRequest({ + required final String appDisplayName, + required final String appId, + final bool append = false, + required final PusherData data, + required final String deviceDisplayName, + required final PusherKind kind, + required final String lang, + + /// TODO: What does this do? + final String? profileTag, + + @JsonKey(name: "pushkey") required final String pushKey, +}) with _$RegisterPusherRequest { + Map toJson() => _$RegisterPusherRequestToJson(this); +} + +@freezed +sealed class PusherData with _$PusherData { + const factory PusherData.http({ + required Uri url, + @Default(PushFormat.eventIdOnly) PushFormat format, + }) = HttpPusherData; + + const factory PusherData.webPush({ + required Uri url, + @Default(PushFormat.eventIdOnly) PushFormat format, + + /// `data.auth`: RFC8291 authentication secret. + required String auth, + }) = WebPushPusherData; + + factory PusherData.fromJson(Map json) => + _$PusherDataFromJson(json); +} + +@JsonEnum(fieldRename: .snake) +enum PushFormat { + @JsonValue(null) + all, + eventIdOnly, +} + +enum PusherKind { + http, + email, + @JsonValue("org.matrix.msc4174.webpush") + webPush, +} diff --git a/lib/models/requests/report.dart b/lib/models/requests/report.dart index f87b1f1..ed05715 100644 --- a/lib/models/requests/report.dart +++ b/lib/models/requests/report.dart @@ -1,14 +1,16 @@ import "package:freezed_annotation/freezed_annotation.dart"; + part "report.freezed.dart"; part "report.g.dart"; -@freezed -abstract class ReportRequest with _$ReportRequest { - const factory ReportRequest({ - required String roomId, - required String eventId, - String? reason, - }) = _ReportRequest; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const ReportRequest({ + required final String roomId, + required final String eventId, + final String? reason, +}) with _$ReportRequest { + Map toJson() => _$ReportRequestToJson(this); factory ReportRequest.fromJson(Map json) => _$ReportRequestFromJson(json); diff --git a/lib/models/requests/send_event.dart b/lib/models/requests/send_event.dart index 196c0b5..acad57f 100644 --- a/lib/models/requests/send_event.dart +++ b/lib/models/requests/send_event.dart @@ -1,19 +1,21 @@ import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/content/content.dart"; + part "send_event.freezed.dart"; part "send_event.g.dart"; -@freezed -abstract class SendEventRequest with _$SendEventRequest { - const factory SendEventRequest({ - required String roomId, - required String type, - required Content content, - String? relatesTo, - String? relationType, - @Default(false) bool synchronous, - @Default(false) bool disableEncryption, - }) = _SendEventRequest; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const SendEventRequest({ + required final String roomId, + required final String type, + required final Content content, + final String? relatesTo, + final String? relationType, + final bool synchronous = false, + final bool disableEncryption = false, +}) with _$SendEventRequest { + Map toJson() => _$SendEventRequestToJson(this); factory SendEventRequest.fromJson(Map json) => _$SendEventRequestFromJson(json); diff --git a/lib/models/requests/send_message.dart b/lib/models/requests/send_message.dart index 951198e..9907439 100644 --- a/lib/models/requests/send_message.dart +++ b/lib/models/requests/send_message.dart @@ -2,43 +2,43 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/relation_type.dart"; + part "send_message.freezed.dart"; part "send_message.g.dart"; -@freezed -abstract class SendMessageRequest with _$SendMessageRequest { - const factory SendMessageRequest({ - required String roomId, - required String text, - Content? baseContent, - @Default(Mentions()) @JsonKey(name: "mentions") Mentions mentions, - @JsonKey(name: "relates_to") Relation? relation, - }) = _SendMessageRequest; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const SendMessageRequest({ + required final String roomId, + required final String text, + final Content? baseContent, + @JsonKey(name: "mentions") final Mentions mentions = const Mentions(), + @JsonKey(name: "relates_to") final Relation? relation, +}) with _$SendMessageRequest { + Map toJson() => _$SendMessageRequestToJson(this); factory SendMessageRequest.fromJson(Map json) => _$SendMessageRequestFromJson(json); } -@freezed -abstract class Mentions with _$Mentions { - const factory Mentions({ - @Default(false) bool room, - @Default(IList.empty()) IList userIds, - }) = _Mentions; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Mentions({ + final bool room = false, + final IList userIds = const IList.empty(), +}) with _$Mentions { + Map toJson() => _$MentionsToJson(this); factory Mentions.fromJson(Map json) => _$MentionsFromJson(json); } -@Freezed(toJson: false) -abstract class Relation with _$Relation { - const Relation._(); - - const factory Relation({ - required String eventId, - required RelationType relationType, - }) = _Relation; - +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const Relation({ + required final String eventId, + required final RelationType relationType, +}) with _$Relation { Map toJson() { switch (relationType) { case RelationType.reply: diff --git a/lib/models/requests/set_account_data.dart b/lib/models/requests/set_account_data.dart new file mode 100644 index 0000000..95ef75c --- /dev/null +++ b/lib/models/requests/set_account_data.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "set_account_data.freezed.dart"; +part "set_account_data.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const SetAccountDataRequest({ + required final String type, + required final dynamic content, + final String? roomId, +}) with _$SetAccountDataRequest { + Map toJson() => _$SetAccountDataRequestToJson(this); + + factory SetAccountDataRequest.fromJson(Map json) => + _$SetAccountDataRequestFromJson(json); +} diff --git a/lib/models/requests/set_membership.dart b/lib/models/requests/set_membership.dart index 4bbe8a3..9b8a03f 100644 --- a/lib/models/requests/set_membership.dart +++ b/lib/models/requests/set_membership.dart @@ -1,18 +1,19 @@ import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/membership_action.dart"; + part "set_membership.freezed.dart"; part "set_membership.g.dart"; -@freezed -abstract class SetMembershipRequest with _$SetMembershipRequest { - const factory SetMembershipRequest({ - required String userId, - required String roomId, - - String? reason, - @JsonKey(name: "action") required MembershipAction action, - @Default(false) @JsonKey(name: "msc4293_redact_events") bool redact, - }) = _SetMembershipRequest; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const SetMembershipRequest({ + required final String userId, + required final String roomId, + final String? reason, + required final MembershipAction action, + @JsonKey(name: "msc4293_redact_events") final bool redact = false, +}) with _$SetMembershipRequest { + Map toJson() => _$SetMembershipRequestToJson(this); factory SetMembershipRequest.fromJson(Map json) => _$SetMembershipRequestFromJson(json); diff --git a/lib/models/requests/set_state.dart b/lib/models/requests/set_state.dart index c92763d..d0e3e95 100644 --- a/lib/models/requests/set_state.dart +++ b/lib/models/requests/set_state.dart @@ -1,22 +1,23 @@ import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/ms_duration.dart"; + part "set_state.freezed.dart"; part "set_state.g.dart"; -@freezed -abstract class SetStateRequest with _$SetStateRequest { - const factory SetStateRequest({ - required String roomId, - required String type, - required String stateKey, - required Content content, +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const SetStateRequest({ + required final String roomId, + required final String type, + required final String stateKey, + required final Content content, - @JsonKey(name: "delay_ms", includeIfNull: false) - @MSDuration() - @Default(null) - Duration? delay, - }) = _SetStateRequest; + @JsonKey(name: "delay_ms", includeIfNull: false) + @MSDuration() + final Duration? delay, +}) with _$SetStateRequest { + Map toJson() => _$SetStateRequestToJson(this); factory SetStateRequest.fromJson(Map json) => _$SetStateRequestFromJson(json); diff --git a/lib/models/requests/upload_media.dart b/lib/models/requests/upload_media.dart index a640bea..d6e69ac 100644 --- a/lib/models/requests/upload_media.dart +++ b/lib/models/requests/upload_media.dart @@ -1,23 +1,26 @@ import "package:freezed_annotation/freezed_annotation.dart"; + part "upload_media.freezed.dart"; part "upload_media.g.dart"; -@freezed -abstract class UploadMediaRequest with _$UploadMediaRequest { - const factory UploadMediaRequest({ - required String path, - required bool encrypt, - String? filename, - @Default(false) @JsonKey(name: "voice_message") bool isVoiceMessage, - @Default(false) bool forceFile, - - // Below params only work if encodeTo is set - String? encodeTo, - int? resizeWidth, - int? resizeHeight, - int? resizePercent, - @Default(80) int quality, - }) = _UploadMediaRequest; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const UploadMediaRequest({ + required final String path, + required final bool encrypt, + final String? filename, + @JsonKey(name: "voice_message") final bool isVoiceMessage = false, + final bool forceFile = false, + final + // Below params only work if encodeTo is set + String? + encodeTo, + final int? resizeWidth, + final int? resizeHeight, + final int? resizePercent, + final int quality = 80, +}) with _$UploadMediaRequest { + Map toJson() => _$UploadMediaRequestToJson(this); factory UploadMediaRequest.fromJson(Map json) => _$UploadMediaRequestFromJson(json); diff --git a/lib/models/room.dart b/lib/models/room.dart index fb21a55..24b0c5e 100644 --- a/lib/models/room.dart +++ b/lib/models/room.dart @@ -3,11 +3,46 @@ import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/read_receipt.dart"; import "package:nexus/models/room_metadata.dart"; + part "room.freezed.dart"; part "room.g.dart"; -@freezed -abstract class Room with _$Room { +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Room({ + @JsonKey(name: "meta") final RoomMetadata? metadata, + + /// [timeline] is an IMap of timelineRowId to eventRowId + @JsonKey(fromJson: Room.timelineTupleJsonToIMap) + final IMap timeline = const IMap.empty(), + + /// [clientSticky] is an ISet of eventRowId + @JsonKey(includeFromJson: false, includeToJson: false) + final ISet clientSticky = const ISet.empty(), + + /// [events] is an IMap of eventRowId to event + @JsonKey(fromJson: Room.eventsJsonToIMap) + final IMap events = const IMap.empty(), + + final bool reset = false, + @JsonKey(includeFromJson: false, includeToJson: false) + final bool resetClientSticky = false, + + @JsonKey(includeFromJson: false, includeToJson: false) + final bool hasFetchedState = false, + + @JsonKey(includeFromJson: false, includeToJson: false) + final bool hasFetchedMembers = false, + + final IMap> state = const IMap.empty(), + + final IMap> receipts = const IMap.empty(), + final bool dismissNotifications = false, + final bool hasMore = true, + + // IMap accountData, + // IList notifications, +}) with _$Room { static IMap timelineTupleJsonToIMap(List json) => IMap.fromEntries( json.map( @@ -26,32 +61,7 @@ abstract class Room with _$Room { }), ); - /// [timeline] is an IMap of timelineRowId to eventRowId - /// [events] is an IMap of eventRowId to event - /// [sticky] is an ISet of eventRowId - const factory Room({ - @JsonKey(name: "meta") RoomMetadata? metadata, - @Default(IMap.empty()) - @JsonKey(fromJson: Room.timelineTupleJsonToIMap) - IMap timeline, - @Default(ISet.empty()) ISet sticky, - - @Default(IMap.empty()) - @JsonKey(fromJson: Room.eventsJsonToIMap) - IMap events, - - @Default(false) bool reset, - @Default(false) bool hasFetchedState, - @Default(false) bool hasFetchedMembers, - @Default(IMap.empty()) IMap> state, - - @Default(IMap.empty()) IMap> receipts, - @Default(false) bool dismissNotifications, - @Default(true) bool hasMore, - - // required IMap accountData, - // required IList notifications, - }) = _Room; + Map toJson() => _$RoomToJson(this); factory Room.fromJson(Map json) => _$RoomFromJson(json); } diff --git a/lib/models/room_chat.dart b/lib/models/room_chat.dart new file mode 100644 index 0000000..e784222 --- /dev/null +++ b/lib/models/room_chat.dart @@ -0,0 +1,32 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "room_chat.freezed.dart"; +part "room_chat.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RoomChat({ + /// List of eventRowId + required final IList timeline, + required final bool hasMoreForward, + required final bool hasMoreBackward, + final HistoricalData? historicalData, +}) with _$RoomChat { + Map toJson() => _$RoomChatToJson(this); + + factory RoomChat.fromJson(Map json) => + _$RoomChatFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const HistoricalData({ + required final String start, + required final String end, +}) with _$HistoricalData { + Map toJson() => _$HistoricalDataToJson(this); + + factory HistoricalData.fromJson(Map json) => + _$HistoricalDataFromJson(json); +} diff --git a/lib/models/room_metadata.dart b/lib/models/room_metadata.dart index 7c16cae..822fcda 100644 --- a/lib/models/room_metadata.dart +++ b/lib/models/room_metadata.dart @@ -1,29 +1,28 @@ import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/epoch_date_time_converter.dart"; import "package:nexus/models/lazy_load_summary.dart"; + part "room_metadata.freezed.dart"; part "room_metadata.g.dart"; -@freezed -abstract class RoomMetadata with _$RoomMetadata { - const factory RoomMetadata({ - @JsonKey(name: "room_id") required String id, - - // required CreateEventContent creationContent, - // required TombstoneEventContent tombstoneEventContent, - String? name, - Uri? avatar, - String? dmUserId, - String? topic, - String? canonicalAlias, - LazyLoadSummary? lazyLoadSummary, - required bool hasMemberList, - @JsonKey(name: "preview_event_rowid") required int previewEventRowID, - @EpochDateTimeConverter() required DateTime sortingTimestamp, - required int unreadHighlights, - required int unreadNotifications, - required int unreadMessages, - }) = _RoomMetadata; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RoomMetadata({ + @JsonKey(name: "room_id") required final String id, + final String? name, + final Uri? avatar, + final String? dmUserId, + final String? topic, + final String? canonicalAlias, + final LazyLoadSummary? lazyLoadSummary, + required final bool hasMemberList, + @JsonKey(name: "preview_event_rowid") required final int previewEventRowID, + @EpochDateTimeConverter() required final DateTime sortingTimestamp, + required final int unreadHighlights, + required final int unreadNotifications, + required final int unreadMessages, +}) with _$RoomMetadata { + Map toJson() => _$RoomMetadataToJson(this); factory RoomMetadata.fromJson(Map json) => _$RoomMetadataFromJson(json); diff --git a/lib/models/room_summary.dart b/lib/models/room_summary.dart new file mode 100644 index 0000000..d0a6bee --- /dev/null +++ b/lib/models/room_summary.dart @@ -0,0 +1,25 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/create.dart"; +import "package:nexus/models/join_rule.dart"; + +part "room_summary.freezed.dart"; +part "room_summary.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RoomSummary({ + required final String roomId, + @JsonKey(name: "num_joined_members") final int? joinedMembers, + final JoinRule? joinRule, + final String? name, + final Uri? avatarUrl, + final String? canonicalAlias, + final String? topic, + final String? roomVersion, + @JsonKey(unknownEnumValue: RoomType.room) final RoomType? roomType, +}) with _$RoomSummary { + Map toJson() => _$RoomSummaryToJson(this); + + factory RoomSummary.fromJson(Map json) => + _$RoomSummaryFromJson(json); +} diff --git a/lib/models/setting.dart b/lib/models/setting.dart index 6a49845..2ff541d 100644 --- a/lib/models/setting.dart +++ b/lib/models/setting.dart @@ -1,16 +1,13 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; -class Setting { - final String title; - final String description; - final IconData icon; - final Widget Function(String title, String description, IconData icon) - builder; - - Setting({ - required this.title, - required this.description, - required this.builder, - required this.icon, - }); -} +class Setting({ + required final String title, + required final String description, + required final IconData icon, + required final Widget Function( + String title, + String description, + IconData icon, + ) + builder, +}); diff --git a/lib/models/settings.dart b/lib/models/settings.dart index eddc2da..97cd6ca 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -1,15 +1,19 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:freezed_annotation/freezed_annotation.dart"; + part "settings.freezed.dart"; part "settings.g.dart"; -@freezed -abstract class Settings with _$Settings { - const factory Settings({ - @Default(ThemeMode.system) ThemeMode theme, - @Default(true) bool useDynamicTheming, - @Default(false) bool linuxMobileMode, - }) = _Settings; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Settings({ + final ThemeMode theme = ThemeMode.system, + final bool useDynamicTheming = true, + final bool linuxMobileMode = false, + final bool showAllInHome = false, + final bool showMinimizeButton = false, +}) with _$Settings { + Map toJson() => _$SettingsToJson(this); factory Settings.fromJson(Map json) => _$SettingsFromJson(json); diff --git a/lib/models/settings_category.dart b/lib/models/settings_category.dart index 88bfdd0..2dc7076 100644 --- a/lib/models/settings_category.dart +++ b/lib/models/settings_category.dart @@ -1,14 +1,13 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/setting.dart"; + part "settings_category.freezed.dart"; @freezed -abstract class SettingsCategory with _$SettingsCategory { - const factory SettingsCategory({ - required String title, - required IconData icon, - required IList settings, - }) = _SettingsCategory; -} +class const SettingsCategory({ + required final String title, + required final IconData icon, + required final IList settings, +}) with _$SettingsCategory; diff --git a/lib/models/space.dart b/lib/models/space.dart index 73fbbc6..97efe7a 100644 --- a/lib/models/space.dart +++ b/lib/models/space.dart @@ -3,16 +3,15 @@ import "package:flutter/widgets.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/room.dart"; import "package:nexus/models/subspace.dart"; + part "space.freezed.dart"; @freezed -abstract class Space with _$Space { - const factory Space({ - required String id, - required String title, - IconData? icon, - Room? room, - required IList children, - required IList subSpaces, - }) = _Space; -} +class const Space({ + required final String id, + required final String title, + final IconData? icon, + final Room? room, + required final IList children, + required final IList subSpaces, +}) with _$Space; diff --git a/lib/models/space_edge.dart b/lib/models/space_edge.dart index 192af31..ea74130 100644 --- a/lib/models/space_edge.dart +++ b/lib/models/space_edge.dart @@ -1,13 +1,15 @@ import "package:freezed_annotation/freezed_annotation.dart"; + part "space_edge.freezed.dart"; part "space_edge.g.dart"; -@freezed -abstract class SpaceEdge with _$SpaceEdge { - const factory SpaceEdge({ - required String childId, - @Default(false) bool suggested, - }) = _SpaceEdge; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const SpaceEdge({ + required final String childId, + final bool suggested = false, +}) with _$SpaceEdge { + Map toJson() => _$SpaceEdgeToJson(this); factory SpaceEdge.fromJson(Map json) => _$SpaceEdgeFromJson(json); diff --git a/lib/models/spec_versions_response.dart b/lib/models/spec_versions_response.dart new file mode 100644 index 0000000..1f414d2 --- /dev/null +++ b/lib/models/spec_versions_response.dart @@ -0,0 +1,28 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "spec_versions_response.freezed.dart"; +part "spec_versions_response.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const SpecVersionsResponse({ + required final IList versions, + required final UnstableFeatures unstableFeatures, +}) with _$SpecVersionsResponse { + Map toJson() => _$SpecVersionsResponseToJson(this); + + factory SpecVersionsResponse.fromJson(Map json) => + _$SpecVersionsResponseFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const UnstableFeatures({ + @JsonKey(name: "uk.timedout.msc4494") final bool msc4494 = false, +}) with _$UnstableFeatures { + Map toJson() => _$UnstableFeaturesToJson(this); + + factory UnstableFeatures.fromJson(Map json) => + _$UnstableFeaturesFromJson(json); +} diff --git a/lib/models/subspace.dart b/lib/models/subspace.dart index 1a1879c..97d1de2 100644 --- a/lib/models/subspace.dart +++ b/lib/models/subspace.dart @@ -1,10 +1,11 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/room.dart"; + part "subspace.freezed.dart"; @freezed -abstract class Subspace with _$Subspace { - const factory Subspace({required Room room, required IList children}) = - _Subspace; -} +class const Subspace({ + required final Room room, + required final IList children, +}) with _$Subspace; diff --git a/lib/models/sync_data.dart b/lib/models/sync_data.dart index 0f98bb2..a9c10bf 100644 --- a/lib/models/sync_data.dart +++ b/lib/models/sync_data.dart @@ -1,22 +1,22 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/account_data.dart"; import "package:nexus/models/room.dart"; import "package:nexus/models/space_edge.dart"; + part "sync_data.freezed.dart"; part "sync_data.g.dart"; -@freezed -abstract class SyncData with _$SyncData { - const factory SyncData({ - @Default(false) bool clearState, - @Default(IMap.empty()) IMap accountData, - @Default(IMap.empty()) IMap rooms, - @Default(ISet.empty()) ISet leftRooms, - // required IList invitedRooms, - IMap>? spaceEdges, - IList? topLevelSpaces, - }) = _SyncData; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const SyncData({ + final bool clearState = false, + final IMap> accountData = const IMap.empty(), + final IMap rooms = const IMap.empty(), + final ISet leftRooms = const ISet.empty(), + final IMap>? spaceEdges, + final IList? topLevelSpaces, +}) with _$SyncData { + Map toJson() => _$SyncDataToJson(this); factory SyncData.fromJson(Map json) => _$SyncDataFromJson(json); diff --git a/lib/models/sync_status.dart b/lib/models/sync_status.dart index 7848fbe..57bd882 100644 --- a/lib/models/sync_status.dart +++ b/lib/models/sync_status.dart @@ -1,14 +1,16 @@ import "package:freezed_annotation/freezed_annotation.dart"; + part "sync_status.freezed.dart"; part "sync_status.g.dart"; -@freezed -abstract class SyncStatus with _$SyncStatus { - const factory SyncStatus({ - required SyncStatusType type, - String? error, - required int errorCount, - }) = _SyncStatus; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const SyncStatus({ + required final SyncStatusType type, + required final String? error, + required final int errorCount, +}) with _$SyncStatus { + Map toJson() => _$SyncStatusToJson(this); factory SyncStatus.fromJson(Map json) => _$SyncStatusFromJson(json); diff --git a/lib/widgets/appbar.dart b/lib/widgets/appbar.dart index 9da03aa..a6489a2 100644 --- a/lib/widgets/appbar.dart +++ b/lib/widgets/appbar.dart @@ -1,28 +1,20 @@ import "dart:io"; + import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/settings.dart"; import "package:window_manager/window_manager.dart"; -class Appbar extends ConsumerWidget implements PreferredSizeWidget { - final Widget? leading; - final Widget? title; - final Color? backgroundColor; - final double? scrolledUnderElevation; - final IList actions; - final VoidCallback? onTap; - - const Appbar({ - super.key, - this.title, - this.onTap, - this.backgroundColor, - this.scrolledUnderElevation, - this.leading, - this.actions = const .empty(), - }); - +final class const Appbar({ + final Widget? leading, + final Widget? title, + final Color? backgroundColor, + final double? scrolledUnderElevation, + final IList actions = const .empty(), + final VoidCallback? onTap, + super.key, +}) extends ConsumerWidget implements PreferredSizeWidget { @override Size get preferredSize => const .fromHeight(kToolbarHeight); @@ -47,7 +39,7 @@ class Appbar extends ConsumerWidget implements PreferredSizeWidget { : (_) => windowManager.startDragging(), ), child: AppBar( - leading: InkWell(onTap: onTap, child: leading), + leading: leading == null ? null : InkWell(onTap: onTap, child: leading), backgroundColor: backgroundColor, scrolledUnderElevation: scrolledUnderElevation, actionsPadding: const .symmetric(horizontal: 8), @@ -59,6 +51,17 @@ class Appbar extends ConsumerWidget implements PreferredSizeWidget { actions: [ ...actions, if (!(Platform.isAndroid || Platform.isIOS)) ...[ + if (ref + .watch(SettingsController.provider) + .whenOrNull( + data: (settings) => settings.showMinimizeButton, + ) ?? + false) + IconButton( + tooltip: "Minimize window", + onPressed: windowManager.minimize, + icon: const Icon(Icons.minimize), + ), if (!Platform.isLinux) IconButton( tooltip: "Maximize window", diff --git a/lib/widgets/avatar_or_hash.dart b/lib/widgets/avatar_or_hash.dart index 75c08b9..bdaf657 100644 --- a/lib/widgets/avatar_or_hash.dart +++ b/lib/widgets/avatar_or_hash.dart @@ -1,37 +1,30 @@ import "package:color_hash/color_hash.dart"; -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/helpers/mxc_image.dart"; -class AvatarOrHash extends ConsumerWidget { - final Uri? avatar; - final String title; - final Widget? fallback; - final double height; - const AvatarOrHash( - this.avatar, - this.title, { - this.fallback, - this.height = 24, - super.key, - }); - +final class const AvatarOrHash( + final Uri? avatar, + final String title, { + final Widget? fallback, + final double dimension = 24, + super.key, +}) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final box = ColoredBox( color: ColorHash(title).color, - child: Center(child: Icon(Icons.person, size: height / 2)), + child: Center(child: Icon(Icons.person, size: dimension / 2)), ); return SizedBox( - width: height, - height: height, + width: dimension, + height: dimension, child: Center( child: ClipRRect( - borderRadius: .all(.circular((height - 8) / 2.5)), - child: SizedBox( - width: height, - height: height, + borderRadius: .all(.circular((dimension - 8) / 2.5)), + child: SizedBox.square( + dimension: dimension, child: avatar == null ? fallback ?? box : Image( diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index c63d939..cc3a49a 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -1,7 +1,10 @@ import "dart:io"; + import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:file_selector/file_selector.dart"; -import "package:flutter/material.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/widgets/emoji_picker.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter/services.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:fluttertagger/fluttertagger.dart"; @@ -10,64 +13,95 @@ import "package:nexus/controllers/attachment.dart"; import "package:nexus/controllers/image_picker.dart"; import "package:nexus/controllers/power_level.dart"; import "package:nexus/models/content/message.dart"; -import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; -import "package:nexus/widgets/composer/mention_overlay.dart"; +import "package:nexus/widgets/composer/overlays/tagger_overlay.dart"; import "package:nexus/widgets/composer/relation_preview.dart"; -import "package:nexus/widgets/emoji_picker_button.dart"; import "package:nexus/main.dart"; -class Composer extends HookConsumerWidget { - final String roomId; - final Event? relatedEvent; - final RelationType relationType; - final VoidCallback onDismiss; - final FocusNode? node; - final Future Function( +class const Composer( + final String roomId, { + required final int? relatedEvent, + required final RelationType relationType, + required final VoidCallback onDismiss, + required final Future Function( String text, { required bool shouldMention, required IList tags, }) - onSend; - const Composer( - this.roomId, { - required this.relatedEvent, - required this.relationType, - required this.onDismiss, - required this.onSend, - this.node, - super.key, - }); - + onSend, + final FocusNode? node, + super.key, +}) extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); - final controller = useRef(FlutterTaggerController()); + final controller = useMemoized(FlutterTaggerController.new); final triggerCharacter = useState(""); final shouldMention = useState(true); final query = useState(""); + final relatedEvent = this.relatedEvent == null + ? null + : ref.watch( + RoomsController.provider.select((rooms) { + final event = rooms[roomId]?.events[this.relatedEvent!]; + if (event == null) return null; - if (relationType == .edit && controller.value.text.isEmpty) { - controller.value.text = - relatedEvent?.localContent?.editSource ?? - switch (relatedEvent?.content) { - TextMessageContent(:final body) => body, - _ => "", - }; - } + final edited = event.lastEditRowId == 0 + ? null + : rooms[roomId]?.events[event.lastEditRowId]; + + return edited == null + ? event + : event.copyWith( + content: edited.content, + localContent: edited.localContent, + ); + }), + ); + + final textBefore = useState(null); + + useEffect(() { + if (relationType == .edit && relatedEvent != null) { + textBefore.value ??= controller.text; + + final text = + relatedEvent.localContent?.editSource ?? + switch (relatedEvent.content) { + TextMessageContent(:final body) => body, + _ => "", + }; + + controller.value = .new( + text: text, + selection: .collapsed(offset: text.length), + ); + } else if (textBefore.value case final draft?) { + controller.value = .new( + text: draft, + selection: .collapsed(offset: draft.length), + ); + textBefore.value = null; + } + + return null; + }, [relationType, relatedEvent == null]); final attachment = ref.watch(AttachmentController.provider(roomId)); void send() { - if (controller.value.text.isEmpty && attachment == null) return; + if (controller.value.text.isEmpty && attachment == null || + attachment != null && attachment.$2 == null) { + return; + } onSend( - controller.value.formattedText, + controller.formattedText, shouldMention: shouldMention.value, - tags: .new(controller.value.tags), + tags: .new(controller.tags), ); onDismiss(); - controller.value.text = ""; + controller.text = ""; } final style = TextStyle( @@ -112,9 +146,7 @@ class Composer extends HookConsumerWidget { ), Container( color: theme.colorScheme.surfaceContainerHighest, - padding: .symmetric(horizontal: 8), child: Row( - spacing: 8, mainAxisAlignment: .center, children: ref.watch( @@ -123,10 +155,19 @@ class Composer extends HookConsumerWidget { ), ) ? [ - EmojiPickerButton( - context: context, - onSelection: (_) => node?.requestFocus(), - controller: controller.value, + IconButton( + onPressed: () => showModalBottomSheet( + isScrollControlled: true, + context: context, + builder: (context) => EmojiPicker( + onSelection: (value) { + Navigator.of(context).pop(); + controller.text += value; + node?.requestFocus(); + }, + ), + ), + icon: Icon(Icons.emoji_emotions), ), PopupMenuButton( tooltip: "Add media", @@ -139,10 +180,9 @@ class Composer extends HookConsumerWidget { leading: Icon(Icons.add_a_photo), ), onTap: () async => ref - .watch( - AttachmentController.provider( - roomId, - ).notifier, + .read( + AttachmentController.provider(roomId) + .notifier, ) .add( (await ref @@ -159,10 +199,9 @@ class Composer extends HookConsumerWidget { leading: Icon(Icons.add_photo_alternate), ), onTap: () async => ref - .watch( - AttachmentController.provider( - roomId, - ).notifier, + .read( + AttachmentController.provider(roomId) + .notifier, ) .add( (await ref @@ -176,9 +215,8 @@ class Composer extends HookConsumerWidget { PopupMenuItem( onTap: () async => ref .watch( - AttachmentController.provider( - roomId, - ).notifier, + AttachmentController.provider(roomId) + .notifier, ) .add((await openFile())!) .onError(showError), @@ -190,19 +228,20 @@ class Composer extends HookConsumerWidget { ], icon: Icon(Icons.add), ), + SizedBox(width: 8), Expanded( child: FlutterTagger( triggerStrategy: .eager, - overlay: MentionOverlay( - roomId, - query: query.value, + overlay: TaggerOverlay( + query.value, + roomId: roomId, triggerCharacter: triggerCharacter.value, addTag: ({required id, required name}) { - controller.value.addTag(id: id, name: name); + controller.addTag(id: id, name: name); node?.requestFocus(); }, ), - controller: controller.value, + controller: controller, onSearch: (newQuery, newTriggerCharacter) { triggerCharacter.value = newTriggerCharacter; query.value = newQuery; @@ -210,6 +249,7 @@ class Composer extends HookConsumerWidget { triggerCharacterAndStyles: { "@": style, "#": style, + ":": style, }, builder: (context, key) => Focus( onKeyEvent: (_, event) { @@ -231,12 +271,16 @@ class Composer extends HookConsumerWidget { child: TextField( maxLines: 12, minLines: 1, - autofocus: true, + textCapitalization: .sentences, + autofocus: + (Platform.isLinux || + Platform.isMacOS || + Platform.isWindows), decoration: .new( hintText: "Your message here...", border: .none, ), - controller: controller.value, + controller: controller, key: key, focusNode: node, ), diff --git a/lib/widgets/composer/mention_overlay.dart b/lib/widgets/composer/mention_overlay.dart deleted file mode 100644 index ca4f95a..0000000 --- a/lib/widgets/composer/mention_overlay.dart +++ /dev/null @@ -1,158 +0,0 @@ -import "package:flutter/material.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/members_by_status.dart"; -import "package:nexus/controllers/rooms.dart"; -import "package:nexus/controllers/via.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/helpers/extensions/get_localpart.dart"; -import "package:nexus/models/content/membership.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/loading.dart"; - -class MentionOverlay extends ConsumerWidget { - final String? triggerCharacter; - final String query; - final String roomId; - final void Function({required String id, required String name}) addTag; - const MentionOverlay( - this.roomId, { - required this.query, - required this.addTag, - required this.triggerCharacter, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final rooms = ref.watch(RoomsController.provider); - - return Padding( - padding: .all(8), - child: ClipRRect( - borderRadius: .all(.circular(12)), - child: Container( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - padding: .all(8), - child: switch (triggerCharacter) { - "@" => - ref - .watch( - MembersByStatusController.provider( - .new(roomId: roomId, status: .join), - ), - ) - .betterWhen( - data: (members) => ListView( - children: - (query.isEmpty - ? members - : members.where( - (member) => - member.stateKey - ?.toLowerCase() - .contains( - query.toLowerCase(), - ) == - true || - switch (member.content) { - MembershipContent( - :final displayName, - ) => - displayName - ?.toLowerCase() - .contains( - query.toLowerCase(), - ) == - true, - _ => false, - }, - )) - .map( - (member) => switch (member.content) { - MembershipContent( - :final displayName, - :final avatarUrl, - ) => - Material( - color: Colors.transparent, - child: ListTile( - leading: AvatarOrHash( - avatarUrl, - displayName ?? - member.stateKey!.localpart, - ), - title: Text( - displayName ?? - member.stateKey!.localpart, - ), - subtitle: Text(member.stateKey!), - onTap: () => addTag( - id: "[@$displayName](matrix:u/${member.stateKey!.substring(1)})", - name: member.stateKey!.localpart, - ), - ), - ), - _ => SizedBox.shrink(), - }, - ) - .toList(), - ), - ), - "#" => ListView( - children: - (query.isEmpty - ? rooms.values - : rooms.values.where( - (room) => - (room.metadata?.name ?? - room.metadata?.id ?? - "") - .toLowerCase() - .contains(query.toLowerCase()), - )) - .map((room) { - final name = - room.metadata?.name ?? - room.metadata?.canonicalAlias ?? - room.metadata?.id ?? - "Unknown Room"; - return Material( - color: Colors.transparent, - child: ListTile( - leading: AvatarOrHash( - room.metadata?.avatar, - name, - fallback: Icon(Icons.numbers), - ), - title: Text(name), - subtitle: room.metadata?.topic == null - ? null - : Text(room.metadata!.topic!, maxLines: 1), - onTap: () { - final vias = ref.watch( - ViaController.provider(room), - ); - addTag( - id: "[#$name](matrix:roomid/${room.metadata?.id.substring(1)}$vias)", - name: - (room.metadata?.canonicalAlias ?? - room.metadata?.id) - ?.substring(1) - .split(":") - .first ?? - "", - ); - }, - ), - ); - }) - .toList(), - ), - - _ => Loading(), - }, - ), - ), - ); - } -} diff --git a/lib/widgets/composer/overlays/emoji_overlay.dart b/lib/widgets/composer/overlays/emoji_overlay.dart new file mode 100644 index 0000000..0e762c0 --- /dev/null +++ b/lib/widgets/composer/overlays/emoji_overlay.dart @@ -0,0 +1,47 @@ +import "package:collection/collection.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_emoji_picker/material_emoji_picker.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; + +class const EmojiOverlay( + final String query, { + required final String roomId, + required final void Function({required String id, required String name}) + addTag, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => ref + .watch(EmojiController.provider) + .betterWhen( + data: (emojis) => ListView( + children: emojis + .map((element) => element.emojis) + .flattened + .where( + (emoji) => + emoji.aliases.join().contains(query) || + emoji.description.contains(query) || + emoji.tags.join().contains(query), + ) + .map( + (emoji) => ListTile( + leading: SizedBox.square( + dimension: 28, + child: FittedBox(child: emoji.widget), + ), + title: Text(emoji.aliases.first), + subtitle: Text(emoji.description), + onTap: () => addTag( + id: Uri.tryParse(emoji.value)?.scheme == "mxc" + ? "" // TODO: Handle custom emotes + : emoji.value, + name: emoji.aliases.first, + ), + ), + ) + .toList(), + ), + ); +} diff --git a/lib/widgets/composer/overlays/room_overlay.dart b/lib/widgets/composer/overlays/room_overlay.dart new file mode 100644 index 0000000..b5af177 --- /dev/null +++ b/lib/widgets/composer/overlays/room_overlay.dart @@ -0,0 +1,58 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/via.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; + +class const RoomOverlay( + final String query, { + required final void Function({required String id, required String name}) + addTag, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final rooms = ref.watch(RoomsController.provider); + return ListView( + children: + (query.isEmpty + ? rooms.values + : rooms.values.where( + (room) => (room.metadata?.name ?? room.metadata?.id ?? "") + .toLowerCase() + .contains(query.toLowerCase()), + )) + .map((room) { + final name = + room.metadata?.name ?? + room.metadata?.canonicalAlias ?? + room.metadata?.id ?? + "Unknown Room"; + return ListTile( + leading: AvatarOrHash( + room.metadata?.avatar, + name, + fallback: Icon(Icons.numbers), + ), + title: Text(name), + subtitle: room.metadata?.topic == null + ? null + : Text(room.metadata!.topic!, maxLines: 1), + onTap: () { + final vias = ref.watch(ViaController.provider(room)); + addTag( + id: "[#$name](matrix:roomid/${room.metadata?.id.substring(1)}$vias)", + name: + (room.metadata?.canonicalAlias ?? room.metadata?.id) + ?.substring(1) + .split(":") + .first ?? + "", + ); + }, + ); + }) + .toList(), + ); + } +} diff --git a/lib/widgets/composer/overlays/tagger_overlay.dart b/lib/widgets/composer/overlays/tagger_overlay.dart new file mode 100644 index 0000000..7d973dc --- /dev/null +++ b/lib/widgets/composer/overlays/tagger_overlay.dart @@ -0,0 +1,39 @@ +import "package:material_ui/material_ui.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/widgets/composer/overlays/room_overlay.dart"; +import "package:nexus/widgets/composer/overlays/user_overlay.dart"; +import "package:nexus/widgets/composer/overlays/emoji_overlay.dart"; +import "package:nexus/widgets/loading.dart"; + +class const TaggerOverlay( + final String query, { + required final String roomId, + required final void Function({required String id, required String name}) + addTag, + required final String? triggerCharacter, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + return Padding( + padding: .all(8), + child: ClipRRect( + borderRadius: .all(.circular(12)), + child: Container( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + padding: .all(8), + child: Material( + color: Colors.transparent, + child: switch (triggerCharacter) { + "@" => UserOverlay(query, roomId: roomId, addTag: addTag), + "#" => RoomOverlay(query, addTag: addTag), + ":" => EmojiOverlay(query, roomId: roomId, addTag: addTag), + + _ => Loading(), + }, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/composer/overlays/user_overlay.dart b/lib/widgets/composer/overlays/user_overlay.dart new file mode 100644 index 0000000..fe4f70f --- /dev/null +++ b/lib/widgets/composer/overlays/user_overlay.dart @@ -0,0 +1,64 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/members_by_status.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; + +class const UserOverlay( + final String query, { + required final String roomId, + required final void Function({required String id, required String name}) + addTag, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => ref + .watch( + MembersByStatusController.provider(.new(roomId: roomId, status: .join)), + ) + .betterWhen( + data: (members) => ListView( + children: + (query.isEmpty + ? members + : members.where( + (member) => + member.stateKey?.toLowerCase().contains( + query.toLowerCase(), + ) == + true || + switch (member.content) { + MembershipContent(:final displayName) => + displayName?.toLowerCase().contains( + query.toLowerCase(), + ) == + true, + _ => false, + }, + )) + .map( + (member) => switch (member.content) { + MembershipContent(:final displayName, :final avatarUrl) => + ListTile( + leading: AvatarOrHash( + avatarUrl, + displayName ?? member.stateKey!.localpart, + ), + title: Text( + displayName ?? member.stateKey!.localpart, + ), + subtitle: Text(member.stateKey!), + onTap: () => addTag( + id: "[@$displayName](matrix:u/${member.stateKey!.substring(1)})", + name: member.stateKey!.localpart, + ), + ), + _ => SizedBox.shrink(), + }, + ) + .toList(), + ), + ); +} diff --git a/lib/widgets/composer/relation_preview.dart b/lib/widgets/composer/relation_preview.dart index c9cc271..fca9de6 100644 --- a/lib/widgets/composer/relation_preview.dart +++ b/lib/widgets/composer/relation_preview.dart @@ -1,25 +1,17 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/event_preview.dart"; -class RelationPreview extends ConsumerWidget { - final Event? relatedEvent; - final RelationType relationType; - final VoidCallback onDismiss; - final bool shouldMention; - final VoidCallback toggleShouldMention; - - const RelationPreview( - this.relatedEvent, { - required this.relationType, - required this.onDismiss, - required this.shouldMention, - required this.toggleShouldMention, - super.key, - }); - +class const RelationPreview( + final Event? relatedEvent, { + required final RelationType relationType, + required final VoidCallback onDismiss, + required final bool shouldMention, + required final VoidCallback toggleShouldMention, + super.key, +}) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { if (relatedEvent == null) return SizedBox.shrink(); diff --git a/lib/widgets/divider_text.dart b/lib/widgets/divider_text.dart index 2b0f9bd..c44ae11 100644 --- a/lib/widgets/divider_text.dart +++ b/lib/widgets/divider_text.dart @@ -1,11 +1,8 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:nexus/widgets/divider_widget.dart"; -class DividerText extends StatelessWidget { - final String text; - - const DividerText(this.text, {super.key}); - +final class const DividerText(final String text, {super.key}) + extends StatelessWidget { @override Widget build(BuildContext context) => DividerWidget(Text(text, style: Theme.of(context).textTheme.labelLarge)); diff --git a/lib/widgets/divider_widget.dart b/lib/widgets/divider_widget.dart index 6f13bd4..5e4972c 100644 --- a/lib/widgets/divider_widget.dart +++ b/lib/widgets/divider_widget.dart @@ -1,9 +1,7 @@ -import "package:flutter/material.dart"; - -class DividerWidget extends StatelessWidget { - final Widget widget; - const DividerWidget(this.widget, {super.key}); +import "package:material_ui/material_ui.dart"; +final class const DividerWidget(final Widget widget, {super.key}) + extends StatelessWidget { @override Widget build(BuildContext context) => LayoutBuilder( builder: (_, constraints) => Row( diff --git a/lib/widgets/emoji_picker.dart b/lib/widgets/emoji_picker.dart new file mode 100644 index 0000000..453213a --- /dev/null +++ b/lib/widgets/emoji_picker.dart @@ -0,0 +1,56 @@ +import "dart:async"; + +import "package:collection/collection.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_emoji_picker/material_emoji_picker.dart" as upstream; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/recent_emoji.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/main.dart"; + +class const EmojiPicker({ + required final FutureOr Function(String value) onSelection, + final bool allowFreeText = false, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final recentEmoji = ref.watch(RecentEmojiController.provider); + + return ref + .watch(upstream.EmojiController.provider) + .betterWhen( + data: (categories) => upstream.EmojiPicker( + onSelection: (emoji, isFreeText) async { + await onSelection(emoji); + + if (!isFreeText) { + await ref + .watch(RecentEmojiController.provider.notifier) + .add(emoji) + .onError(showError); + } + }, + allowFreeText: allowFreeText, + prependCategories: .new([ + .new( + icon: Icon(Icons.history), + name: "Recent", + emojis: .new( + recentEmoji + .map( + (recent) => categories + .map((element) => element.emojis) + .flattened + .firstWhereOrNull( + (element) => element.value == recent.emoji, + ), + ) + .nonNulls, + ), + ), + ]), + ), + ); + } +} diff --git a/lib/widgets/emoji_picker_button.dart b/lib/widgets/emoji_picker_button.dart deleted file mode 100644 index 2ac906a..0000000 --- a/lib/widgets/emoji_picker_button.dart +++ /dev/null @@ -1,52 +0,0 @@ -import "package:emoji_text_field/emoji_text_field.dart"; -import "package:flutter/material.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/emoji.dart"; - -class EmojiPickerButton extends HookConsumerWidget { - final TextEditingController? controller; - final void Function(String emoji)? onSelection; - final VoidCallback? onPressed; - final BuildContext context; - const EmojiPickerButton({ - this.controller, - this.onPressed, - this.onSelection, - required this.context, - super.key, - }); - - @override - Widget build(_, WidgetRef ref) => IconButton( - onPressed: () async { - onPressed?.call(); - final controller = this.controller ?? .new(); - - final emojis = await ref.watch(EmojiController.provider.future); - if (context.mounted) { - showModalBottomSheet( - context: context, - builder: (context) => EmojiKeyboardView( - config: .new( - showRecentTab: false, - customCategories: emojis.$1.unlock, - customKeywords: emojis.$2.unlock, - backgroundColor: Theme.of(context).colorScheme.surfaceContainer, - height: 600, - ), - textController: controller - ..addListener(() { - // Without this, there will sometimes be a debugLocked is not true error sometimes - // It might be preferable to use a microtask instead of a `Future.delayed`. - Future.delayed(.zero, () { - if (context.mounted) Navigator.of(context).pop(); - }); - onSelection?.call(controller.text); - }), - ), - ); - } - }, - icon: Icon(Icons.emoji_emotions), - ); -} diff --git a/lib/widgets/error_dialog.dart b/lib/widgets/error_dialog.dart index 9b62200..ddc4c98 100644 --- a/lib/widgets/error_dialog.dart +++ b/lib/widgets/error_dialog.dart @@ -1,13 +1,13 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/misc.dart"; -class ErrorDialog extends ConsumerWidget { - final Object error; - final StackTrace? stackTrace; - final ProviderOrFamily? provider; - const ErrorDialog(this.error, this.stackTrace, {this.provider, super.key}); - +final class const ErrorDialog( + final Object error, + final StackTrace? stackTrace, { + final ProviderOrFamily? provider, + super.key, +}) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return AlertDialog( diff --git a/lib/widgets/event_preview.dart b/lib/widgets/event_preview.dart index 7a40a75..1b88fa8 100644 --- a/lib/widgets/event_preview.dart +++ b/lib/widgets/event_preview.dart @@ -1,32 +1,35 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/renderers/event.dart"; -class EventPreview extends StatelessWidget { - final Event event; - const EventPreview(this.event, {super.key}); - +class const EventPreview(final Event event, {super.key}) + extends StatelessWidget { @override Widget build(BuildContext context) => IgnorePointer( child: Padding( padding: .symmetric(vertical: 4), child: Row( mainAxisSize: .min, - spacing: 12, + spacing: 8, children: [ - if (event.content is MessageContent) MessageAvatar(event), + if (event.content is MessageContent) MessageAvatar(event, height: 36), Flexible( - child: Wrap( - crossAxisAlignment: .center, - spacing: 8, - runSpacing: 2, + child: Column( + mainAxisAlignment: .center, + crossAxisAlignment: .start, children: [ - if (event.content is MessageContent) MessageDisplayname(event), - EventRenderer(event, textOnly: true, maxLines: 1), + if (event.content is MessageContent) + MessageDisplayname(event, shouldWrap: false), + EventRenderer( + event.rowId, + roomId: event.roomId, + textOnly: true, + maxLines: 1, + ), ], ), ), diff --git a/lib/widgets/expandable_image.dart b/lib/widgets/expandable_image.dart index ee4f900..0639417 100644 --- a/lib/widgets/expandable_image.dart +++ b/lib/widgets/expandable_image.dart @@ -1,15 +1,15 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:m3e_buttons/m3e_buttons.dart"; import "package:nexus/helpers/mxc_image.dart"; import "package:nexus/models/requests/download_media.dart"; import "package:nexus/widgets/error_dialog.dart"; -class ExpandableImage extends ConsumerWidget { - final Widget child; - final DownloadMediaRequest? request; - const ExpandableImage(this.request, {required this.child, super.key}); - +final class const ExpandableImage( + final DownloadMediaRequest? request, { + required final Widget child, + super.key, +}) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) => InkWell( onTap: request == null diff --git a/lib/widgets/file_card.dart b/lib/widgets/file_card.dart index afdad89..4c0fdde 100644 --- a/lib/widgets/file_card.dart +++ b/lib/widgets/file_card.dart @@ -1,13 +1,13 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:nexus/helpers/extensions/size_to_string.dart"; import "package:nexus/models/info/file.dart"; -class FileCard extends StatelessWidget { - final Uri uri; - final FileInfo? info; - final String? filename; - const FileCard(this.uri, this.info, {this.filename, super.key}); - +final class const FileCard( + final Uri uri, + final FileInfo? info, { + final String? filename, + super.key, +}) extends StatelessWidget { @override Widget build(BuildContext context) => SizedBox( width: 320, diff --git a/lib/widgets/highlight_wrapper.dart b/lib/widgets/highlight_wrapper.dart index c7e568e..b10e3ff 100644 --- a/lib/widgets/highlight_wrapper.dart +++ b/lib/widgets/highlight_wrapper.dart @@ -1,10 +1,10 @@ -import "package:flutter/material.dart"; - -class HighlightWrapper extends StatelessWidget { - final Widget child; - final bool isHighlighted; - const HighlightWrapper(this.child, {this.isHighlighted = false, super.key}); +import "package:material_ui/material_ui.dart"; +final class const HighlightWrapper( + final Widget child, { + final bool isHighlighted = false, + super.key, +}) extends StatelessWidget { @override Widget build(BuildContext context) => ClipRRect( borderRadius: .all(.circular(12)), diff --git a/lib/widgets/html/code_block.dart b/lib/widgets/html/code_block.dart index a5c3dee..1881bc6 100644 --- a/lib/widgets/html/code_block.dart +++ b/lib/widgets/html/code_block.dart @@ -1,12 +1,13 @@ import "dart:math"; -import "package:flutter/material.dart"; - -class CodeBlock extends StatelessWidget { - final String code; - final String lang; - const CodeBlock(this.code, {required this.lang, super.key}); +import "package:flutter/services.dart"; +import "package:material_ui/material_ui.dart"; +class const CodeBlock( + final String code, { + required final String lang, + super.key, +}) extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -24,11 +25,11 @@ class CodeBlock extends StatelessWidget { padding: .symmetric(horizontal: 8), child: Text( lang.substring(0, min(lang.length, 15)), - style: .new(fontFamily: "monospace"), + style: .new(fontFamily: "bundled-mono"), ), ), TextButton.icon( - onPressed: () {}, + onPressed: () => Clipboard.setData(.new(text: code)), icon: Icon(Icons.copy), label: Text("Copy"), ), @@ -43,7 +44,7 @@ class CodeBlock extends StatelessWidget { code, minLines: 1, maxLines: 99, - style: .new(fontFamily: "monospace"), + style: .new(fontFamily: "bundled-mono"), ), ), ), diff --git a/lib/widgets/html/html.dart b/lib/widgets/html/html.dart index 5437285..7c99471 100644 --- a/lib/widgets/html/html.dart +++ b/lib/widgets/html/html.dart @@ -1,8 +1,8 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart"; -import "package:nexus/helpers/extensions/link_to_mention.dart"; +import "package:nexus/helpers/extensions/get_link_info.dart"; import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/helpers/mxc_image.dart"; import "package:nexus/widgets/expandable_image.dart"; @@ -11,130 +11,154 @@ import "package:nexus/widgets/html/spoiler_text.dart"; import "package:nexus/widgets/html/code_block.dart"; import "package:nexus/widgets/html/quoted.dart"; -class Html extends ConsumerWidget { - final String html; - final String? roomId; - final TextStyle? textStyle; - const Html(this.html, {this.roomId, this.textStyle, super.key}); - +class const Html( + final String html, { + final String? roomId, + final TextStyle? textStyle, + super.key, +}) extends ConsumerWidget { @override - Widget build(BuildContext context, WidgetRef ref) => HtmlWidget( - html, - buildAsync: false, - textStyle: textStyle, - customWidgetBuilder: (element) { - if (element.attributes.keys.contains("data-mx-profile-fallback")) { - return SizedBox.shrink(); - } + Widget + build(BuildContext context, WidgetRef ref) + // needed until https://github.com/daohoangson/flutter_widget_from_html/issues/1618 is resolved + => MaterialUiCompatibilityBridge( + child: HtmlWidget( + html, + buildAsync: false, + textStyle: textStyle, + customWidgetBuilder: (element) { + if (element.attributes.keys.contains("data-mx-profile-fallback")) { + return SizedBox.shrink(); + } - if (element.attributes.keys.contains("data-mx-spoiler")) { - return InlineCustomWidget(child: SpoilerText(text: element.text)); - } + if (element.attributes.keys.contains("data-mx-spoiler")) { + return InlineCustomWidget(child: SpoilerText(element.text)); + } - final height = - int.tryParse(element.attributes["height"] ?? "") ?? - (element.attributes.keys.contains("data-mx-emoticon") ? 32 : null) ?? - 300; - final width = int.tryParse(element.attributes["width"] ?? ""); - final src = Uri.tryParse(element.attributes["src"] ?? ""); + final height = + int.tryParse(element.attributes["height"] ?? "") ?? + (element.attributes.keys.contains("data-mx-emoticon") + ? 32 + : null) ?? + 300; + final width = int.tryParse(element.attributes["width"] ?? ""); + final src = Uri.tryParse(element.attributes["src"] ?? ""); - return switch (element.localName) { - "code" => - element.parent?.localName == "pre" - ? CodeBlock( - element.text, - lang: element.className.replaceAll("language-", ""), - ) - : null, - - "blockquote" => Quoted( - Html(element.innerHtml, textStyle: textStyle, roomId: roomId), - ), - - "a" => - element.attributes["href"]?.mention == null - ? null - : InlineCustomWidget( - child: MentionChip(element.attributes["href"]!, roomId), - ), - - "img" => - src == null - ? SizedBox.shrink() - : InlineCustomWidget( - alignment: PlaceholderAlignment.middle, - child: ExpandableImage( - .new(mxc: src), - child: Image( - image: MxcImage(ref, .new(mxc: src)), - errorBuilder: (_, error, _) => Text( - "Image Failed to Load", - style: .new(color: Theme.of(context).colorScheme.error), + return switch (element.localName) { + "code" => + element.parent?.localName == "pre" + ? CodeBlock( + element.text, + lang: element.className.replaceAll("language-", ""), + ) + : InlineCustomWidget( + child: ClipRRect( + borderRadius: .all(.circular(4)), + child: Container( + padding: .all(8), + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + child: SelectableText( + element.text, + style: .new(fontFamily: "bundled-mono"), + ), ), - height: height.toDouble(), - width: width?.toDouble(), - loadingBuilder: (_, child, loadingProgress) => - loadingProgress == null - ? child - : CircularProgressIndicator(), ), ), - ), - // Allowed elements list - ("del" || - "h1" || - "h2" || - "h3" || - "h4" || - "h5" || - "h6" || - "p" || - "ul" || - "ol" || - "sup" || - "sub" || - "li" || - "b" || - "i" || - "u" || - "strong" || - "em" || - "s" || - "code" || - "hr" || - "br" || - "div" || - "table" || - "thead" || - "tbody" || - "tr" || - "th" || - "td" || - "caption" || - "pre" || - "span" || - "details" || - "summary") => - null, + "blockquote" => Quoted( + Html(element.innerHtml, textStyle: textStyle, roomId: roomId), + ), - _ => SizedBox.shrink(), - }; - }, - customStylesBuilder: (element) => { - "width": "auto", - ...Map.fromEntries( - element.attributes - .mapTo?>( - (key, value) => switch (key) { - "data-mx-color" => .new("color", value), - "data-mx-bg-color" => .new("background-color", value), - _ => null, - }, - ) - .nonNulls, - ), - }, - onTapUrl: (url) => ref.watch(LaunchHelper.provider).launchUrl(.parse(url)), + "a" => + element.attributes["href"]?.linkInfo == null + ? null + : InlineCustomWidget( + child: MentionChip(element.attributes["href"]!, roomId), + ), + + "img" => + src == null + ? SizedBox.shrink() + : InlineCustomWidget( + alignment: PlaceholderAlignment.middle, + child: ExpandableImage( + .new(mxc: src), + child: Image( + image: MxcImage(ref, .new(mxc: src)), + errorBuilder: (_, error, _) => Text( + "Image Failed to Load", + style: .new( + color: Theme.of(context).colorScheme.error, + ), + ), + height: height.toDouble(), + width: width?.toDouble(), + loadingBuilder: (_, child, loadingProgress) => + loadingProgress == null + ? child + : CircularProgressIndicator(), + ), + ), + ), + + // Allowed elements list + ("del" || + "h1" || + "h2" || + "h3" || + "h4" || + "h5" || + "h6" || + "p" || + "ul" || + "ol" || + "sup" || + "sub" || + "li" || + "b" || + "i" || + "u" || + "strong" || + "em" || + "s" || + "code" || + "hr" || + "br" || + "div" || + "table" || + "thead" || + "tbody" || + "tr" || + "th" || + "td" || + "caption" || + "pre" || + "span" || + "details" || + "summary") => + null, + + _ => SizedBox.shrink(), + }; + }, + customStylesBuilder: (element) => { + "width": "auto", + ...Map.fromEntries( + element.attributes + .mapTo?>( + (key, value) => switch (key) { + "data-mx-color" => .new("color", value), + "data-mx-bg-color" => .new("background-color", value), + _ => null, + }, + ) + .nonNulls, + ), + }, + onTapUrl: (url) => + ref.watch(LaunchHelper.provider).launchUrl(.parse(url)), + ), ); } diff --git a/lib/widgets/html/mention_chip.dart b/lib/widgets/html/mention_chip.dart index f6105e3..29e2310 100644 --- a/lib/widgets/html/mention_chip.dart +++ b/lib/widgets/html/mention_chip.dart @@ -1,46 +1,95 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/event.dart"; +import "package:nexus/controllers/room_summary.dart"; import "package:nexus/controllers/user.dart"; -import "package:nexus/helpers/extensions/link_to_mention.dart"; +import "package:nexus/helpers/extensions/focus_room.dart"; +import "package:nexus/helpers/extensions/get_link_info.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/room_summary.dart"; +import "package:nexus/widgets/room_summary_dialog.dart"; -class MentionChip extends ConsumerWidget { - final String? roomId; - final String content; - const MentionChip(this.content, this.roomId, {super.key}); - +class const MentionChip(final String content, final String? roomId, {super.key}) + extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final mention = content.mention; - final membership = mention?.startsWith("@") == true - ? ref - .watch( - UserController.provider(.new(roomId: roomId, userId: mention!)), - ) - .whenOrNull(data: (data) => data) - : null; + final linkInfo = content.linkInfo; + final mention = linkInfo?.identifier; + final data = switch (mention?.characters.firstOrNull) { + "@" => + ref + .watch( + UserController.provider(.new(roomId: roomId, userId: mention!)), + ) + .whenOrNull(data: (data) => data), + + "#" || "!" => + ref + .watch( + RoomSummaryController.provider(.new(roomIdOrAlias: mention!)), + ) + .whenOrNull(data: (data) => data) ?? + RoomSummary(roomId: mention), + + _ => null, + }; return mention == null ? SizedBox.shrink() : InkWell( - onTap: () { - if (membership != null) { + onTap: () async { + if (data case MembershipContent membership) { context.showUserPopover(membership, mention, roomId: roomId); + } else if (data case RoomSummary summary) { + // TODO: This is an extra call that isn't truly needed, get rid of it + final eventRowId = linkInfo?.eventId == null + ? null + : await ref + .read( + EventController.provider( + .new( + roomId: summary.roomId, + eventId: linkInfo!.eventId!, + ), + ).selectAsync((data) => data?.rowId), + ) + .onError((_, _) => null); + + if (!await ref.focusRoom(summary.roomId, eventRowId) && + context.mounted) { + showDialog( + context: context, + builder: (context) => Consumer( + builder: (context, ref, _) => RoomSummaryDialog( + ref + .watch( + RoomSummaryController.provider( + .new(roomIdOrAlias: mention), + ), + ) + .whenOrNull(data: (data) => data) ?? + summary, + via: linkInfo?.via, + ), + ), + ); + } } }, - child: IgnorePointer( - child: Chip( - label: Text( - (membership?.displayName == null - ? null - : "@${membership!.displayName}") ?? - mention, - style: .new( - fontWeight: .bold, - color: Theme.of(context).colorScheme.onPrimary, - ), - ), - backgroundColor: Theme.of(context).colorScheme.primary, + child: Text( + switch (data) { + RoomSummary summary => + "${(summary.name == null ? null : "#${summary.name}") ?? summary.canonicalAlias ?? summary.roomId}${linkInfo?.eventId == null ? "" : " > ${linkInfo?.eventId}"}", + MembershipContent membership => + membership.displayName == null + ? mention + : "@${membership.displayName}", + _ => mention, + }, + style: .new( + fontWeight: .bold, + color: Theme.of(context).colorScheme.primary, ), ), ); diff --git a/lib/widgets/html/quoted.dart b/lib/widgets/html/quoted.dart index e582b06..3157263 100644 --- a/lib/widgets/html/quoted.dart +++ b/lib/widgets/html/quoted.dart @@ -1,9 +1,6 @@ -import "package:flutter/material.dart"; - -class Quoted extends StatelessWidget { - final Widget child; - const Quoted(this.child, {super.key}); +import "package:material_ui/material_ui.dart"; +class const Quoted(final Widget child, {super.key}) extends StatelessWidget { @override Widget build(BuildContext context) => Container( decoration: BoxDecoration( diff --git a/lib/widgets/html/spoiler_text.dart b/lib/widgets/html/spoiler_text.dart index a7a457b..92a5538 100644 --- a/lib/widgets/html/spoiler_text.dart +++ b/lib/widgets/html/spoiler_text.dart @@ -1,11 +1,7 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; -class SpoilerText extends HookWidget { - final String text; - - const SpoilerText({super.key, required this.text}); - +class const SpoilerText(final String text, {super.key}) extends HookWidget { @override Widget build(BuildContext context) { final revealed = useState(false); diff --git a/lib/widgets/invites_list.dart b/lib/widgets/invites_list.dart new file mode 100644 index 0000000..35dd26f --- /dev/null +++ b/lib/widgets/invites_list.dart @@ -0,0 +1,22 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/nav_page.dart"; + +class const InvitesList({super.key}) + extends StatelessWidget + implements NavPage { + @override + String get title => "Invites"; + + @override + IconData get icon => Icons.inbox; + + // TODO: Show notification badge here + @override + Future? badgeBuilder(WidgetRef ref) => null; + + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} diff --git a/lib/widgets/join_dialog.dart b/lib/widgets/join_dialog.dart index 50b36f2..b559023 100644 --- a/lib/widgets/join_dialog.dart +++ b/lib/widgets/join_dialog.dart @@ -1,19 +1,40 @@ -import "package:collection/collection.dart"; -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/client.dart"; -import "package:nexus/controllers/key.dart"; -import "package:nexus/controllers/spaces.dart"; -import "package:nexus/helpers/extensions/link_to_mention.dart"; - -class JoinDialog extends HookWidget { - final WidgetRef ref; - const JoinDialog(this.ref, {super.key}); +import "package:nexus/controllers/room_summary.dart"; +import "package:nexus/helpers/extensions/get_link_info.dart"; +import "package:nexus/widgets/room_summary_dialog.dart"; +class const JoinDialog(final WidgetRef ref, {super.key}) extends HookWidget { @override Widget build(BuildContext context) { final roomAlias = useTextEditingController(); + + Future onJoin() async { + Navigator.of(context).pop(); + + final linkInfo = roomAlias.text.linkInfo; + final via = linkInfo?.via ?? const .empty(); + final roomIdOrAlias = linkInfo?.identifier ?? roomAlias.text; + + showDialog( + context: context, + builder: (context) => Consumer( + builder: (context, ref, _) => switch (ref.watch( + RoomSummaryController.provider( + .new(roomIdOrAlias: roomIdOrAlias, via: via), + ), + )) { + AsyncData(:final value) => RoomSummaryDialog(value, via: via), + AsyncError _ || AsyncLoading _ => RoomSummaryDialog( + .new(roomId: roomIdOrAlias), + via: via, + ), + }, + ), + ); + } + return AlertDialog( title: Text("Join a Room"), content: Column( @@ -24,114 +45,14 @@ class JoinDialog extends HookWidget { SizedBox(height: 12), TextField( controller: roomAlias, - decoration: .new(hintText: "#room:server"), + decoration: .new(hintText: "#room:server.name"), + onEditingComplete: onJoin, ), ], ), actions: [ TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), - TextButton( - onPressed: () async { - Navigator.of(context).pop(); - - if (context.mounted) { - final roomIdOrAlias = roomAlias.text.mention ?? roomAlias.text; - - final scaffoldMessenger = ScaffoldMessenger.of(context); - - final snackbar = scaffoldMessenger.showSnackBar( - .new( - content: Text("Joining room $roomIdOrAlias."), - duration: Duration(days: 999), - ), - ); - - try { - final id = await ref - .watch(ClientController.provider.notifier) - .joinRoom( - .new( - roomIdOrAlias: roomIdOrAlias, - via: .new( - Uri.tryParse( - roomAlias.text.replaceAll("/#", ""), - )?.queryParametersAll["via"] ?? - [], - ), - ), - ); - - snackbar.close(); - - scaffoldMessenger.showSnackBar( - .new( - content: Text("Room $roomIdOrAlias successfully joined."), - action: .new( - label: "Open", - onPressed: () async { - final spaces = ref.watch(SpacesController.provider); - final space = spaces.firstWhereOrNull( - (space) => space.id == id, - ); - - await ref - .watch( - KeyController.provider( - KeyController.spaceKey, - ).notifier, - ) - .set( - space?.id ?? - spaces - .firstWhere( - (space) => - space.children.any( - (child) => - child.metadata?.id == id, - ) || - space.subSpaces.any( - (child) => - child.room.metadata?.id == id, - ), - ) - .id, - ); - - if (space == null) { - await ref - .watch( - KeyController.provider( - KeyController.roomKey, - ).notifier, - ) - .set(id); - } - }, - ), - ), - ); - } catch (error) { - snackbar.close(); - if (context.mounted) { - scaffoldMessenger.showSnackBar( - .new( - backgroundColor: Theme.of( - context, - ).colorScheme.errorContainer, - content: Text( - error.toString(), - style: .new( - color: Theme.of(context).colorScheme.onErrorContainer, - ), - ), - ), - ); - } - } - } - }, - child: Text("Join"), - ), + TextButton(onPressed: onJoin, child: Text("Join")), ], ); } diff --git a/lib/widgets/lazy_loading/message_avatar.dart b/lib/widgets/lazy_loading/message_avatar.dart index 93c2554..50b612a 100644 --- a/lib/widgets/lazy_loading/message_avatar.dart +++ b/lib/widgets/lazy_loading/message_avatar.dart @@ -1,4 +1,4 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/author.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; @@ -6,11 +6,11 @@ import "package:nexus/helpers/extensions/show_user_popover.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; -class MessageAvatar extends ConsumerWidget { - final Event event; - final double height; - const MessageAvatar(this.event, {this.height = 24, super.key}); - +class const MessageAvatar( + final Event event, { + final double height = 24, + super.key, +}) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch( AuthorController.provider(event), @@ -21,9 +21,9 @@ class MessageAvatar extends ConsumerWidget { child: AvatarOrHash( value.avatarUrl, value.displayName ?? event.sender.localpart, - height: height, + dimension: height, ), ), - _ => AvatarOrHash(null, event.sender.localpart, height: height), + _ => AvatarOrHash(null, event.sender.localpart, dimension: height), }; } diff --git a/lib/widgets/lazy_loading/message_displayname.dart b/lib/widgets/lazy_loading/message_displayname.dart index bd5733c..593e755 100644 --- a/lib/widgets/lazy_loading/message_displayname.dart +++ b/lib/widgets/lazy_loading/message_displayname.dart @@ -1,62 +1,61 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/author.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; import "package:nexus/helpers/extensions/string_to_color.dart"; import "package:nexus/models/event.dart"; +import "package:nexus/models/content/membership.dart"; -class MessageDisplayname extends ConsumerWidget { - final Event event; - final TextStyle? style; - final bool clickable; - const MessageDisplayname( - this.event, { - this.clickable = true, - this.style, - super.key, - }); - +class const MessageDisplayname( + final Event event, { + final TextStyle? style, + final bool clickable = true, + final bool shouldWrap = true, + super.key, +}) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch( AuthorController.provider(event), )) { - AsyncData(:final value) || AsyncLoading(:final value?) => InkWell( - onTap: clickable + AsyncData(:final MembershipContent? value) || + AsyncLoading(:final value) || + AsyncError(:final value) => InkWell( + onTap: clickable && value != null ? () => context.showUserPopover( value, event.sender, roomId: event.roomId, ) : null, - child: Wrap( - spacing: 4, - crossAxisAlignment: .center, - children: [ + child: switch ([ + Text( + value?.displayName ?? event.sender.localpart, + style: + style ?? .new(color: event.sender.colorHash, fontWeight: .bold), + maxLines: 1, + overflow: .ellipsis, + ), + if (event.pmp != null) Text( - value.displayName ?? event.sender.localpart, - style: - style ?? .new(color: event.sender.colorHash, fontWeight: .bold), + "(via ${event.sender})", + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: event.sender.colorHash, fontWeight: .bold), maxLines: 1, overflow: .ellipsis, ), - - if (event.pmp != null) - Text( - "(via ${event.sender})", - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: event.sender.colorHash, - fontWeight: .bold, - ), - maxLines: 1, - overflow: .ellipsis, - ), - ], - ), - ), - _ => Text( - event.sender.localpart, - style: .new(color: event.sender.colorHash, fontWeight: .bold), + ]) { + final children when shouldWrap => Wrap( + spacing: 4, + crossAxisAlignment: .center, + children: children, + ), + final children => Row( + spacing: 4, + mainAxisSize: .min, + children: [for (final child in children) Flexible(child: child)], + ), + }, ), }; } diff --git a/lib/widgets/linkified_text.dart b/lib/widgets/linkified_text.dart index 653248c..77ba7a4 100644 --- a/lib/widgets/linkified_text.dart +++ b/lib/widgets/linkified_text.dart @@ -1,14 +1,14 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_linkify/flutter_linkify.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/helpers/launch_helper.dart"; -class LinkifiedText extends ConsumerWidget { - final String text; - final int? maxLines; - final TextStyle? style; - const LinkifiedText(this.text, {this.maxLines, this.style, super.key}); - +final class const LinkifiedText( + final String text, { + final int? maxLines, + final TextStyle? style, + super.key, +}) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) => Linkify( text: text, diff --git a/lib/widgets/loading.dart b/lib/widgets/loading.dart index fc84563..e92860e 100644 --- a/lib/widgets/loading.dart +++ b/lib/widgets/loading.dart @@ -1,14 +1,15 @@ -import "package:flutter/material.dart"; - -class Loading extends StatelessWidget { - final double? height; - const Loading({this.height, super.key}); +import "package:material_ui/material_ui.dart"; +class const Loading({super.key, final double? dimension}) + extends StatelessWidget { @override Widget build(BuildContext context) => Center( child: Padding( padding: .all(16), - child: SizedBox(height: height, child: CircularProgressIndicator()), + child: SizedBox.square( + dimension: dimension, + child: CircularProgressIndicator(), + ), ), ); } diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart index 92bb179..ac0cb2a 100644 --- a/lib/widgets/member_list.dart +++ b/lib/widgets/member_list.dart @@ -1,5 +1,5 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:m3e_buttons/m3e_buttons.dart"; @@ -7,6 +7,7 @@ import "package:m3e_card_list/m3e_card_list.dart"; import "package:nexus/controllers/members_by_status.dart"; import "package:nexus/controllers/members_grouped.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; import "package:nexus/helpers/extensions/string_to_color.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/membership_status.dart"; @@ -14,12 +15,9 @@ import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/divider_text.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/loading.dart"; -import "package:nexus/widgets/user_bottom_sheet.dart"; - -class MemberList extends HookConsumerWidget { - final String roomId; - const MemberList(this.roomId, {super.key}); +class const MemberList(final String roomId, {super.key}) + extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final statusIndex = useState(0); @@ -96,9 +94,9 @@ class MemberList extends HookConsumerWidget { ), SliverM3ECardList( padding: .all(4), - color: Theme.of( - context, - ).colorScheme.surfaceContainerHigh, + color: Theme.of(context) + .colorScheme + .surfaceContainerHigh, margin: .symmetric(horizontal: 12, vertical: 4), itemCount: members.length, itemBuilder: (context, index) => @@ -125,13 +123,16 @@ class MemberList extends HookConsumerWidget { members[index].stateKey!, overflow: .ellipsis, ), - leading: AvatarOrHash( - avatarUrl, - height: 36, - displayName ?? - members[index] - .stateKey! - .localpart, + leading: Transform.translate( + offset: .new(0, 2), + child: AvatarOrHash( + avatarUrl, + dimension: 40, + displayName ?? + members[index] + .stateKey! + .localpart, + ), ), ), _ => throw Exception( @@ -142,20 +143,10 @@ class MemberList extends HookConsumerWidget { final member = members[index]; if (member.content case MembershipContent content) { - showModalBottomSheet( - constraints: .loose( - .new( - 500, - (context.size?.height ?? 1000) - 80, - ), - ), - isScrollControlled: true, - context: context, - builder: (context) => UserBottomSheet( - content, - member.stateKey!, - roomId: roomId, - ), + context.showUserPopover( + content, + member.stateKey!, + roomId: roomId, ); } }, diff --git a/lib/widgets/message_image.dart b/lib/widgets/message_image.dart index 914a252..b04c209 100644 --- a/lib/widgets/message_image.dart +++ b/lib/widgets/message_image.dart @@ -1,4 +1,4 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_blurhash/flutter_blurhash.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/helpers/mxc_image.dart"; @@ -7,12 +7,12 @@ import "package:nexus/models/requests/download_media.dart"; import "package:nexus/widgets/expandable_image.dart"; import "package:nexus/widgets/loading.dart"; -class MessageImage extends ConsumerWidget { - final Uri url; - final i.ImageInfo? info; - final bool encrypted; - const MessageImage(this.url, {this.info, required this.encrypted, super.key}); - +final class const MessageImage( + final Uri url, { + final i.ImageInfo? info, + required final bool encrypted, + super.key, +}) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final request = DownloadMediaRequest(mxc: url, encrypted: encrypted); diff --git a/lib/widgets/notifications_list.dart b/lib/widgets/notifications_list.dart new file mode 100644 index 0000000..fde1bd5 --- /dev/null +++ b/lib/widgets/notifications_list.dart @@ -0,0 +1,176 @@ +import "dart:async"; + +import "package:m3e_buttons/m3e_buttons.dart"; +import "package:material_ui/material_ui.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/notifications.dart"; +import "package:nexus/helpers/extensions/focus_room.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/nav_page.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/highlight_wrapper.dart"; +import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/renderers/event.dart"; +import "package:super_sliver_list/super_sliver_list.dart"; + +class const NotificationsList({ + final int? highlightedEvent, + final bool defaultToAllNotifications = false, + super.key, +}) extends HookConsumerWidget implements NavPage { + @override + String get title => "Notifications"; + + @override + IconData get icon => Icons.notifications; + + // TODO: Show notification badge here + @override + Future? badgeBuilder(WidgetRef ref) => null; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final options = { + M3EToggleButtonGroupAction( + checkedLabel: Text("Mentions"), + icon: Icon(Icons.alternate_email), + ): .highlight, + M3EToggleButtonGroupAction( + checkedLabel: Text("All Notifications"), + icon: Icon(Icons.notifications), + ): .notify, + }; + final unreadTypeIndex = useState(defaultToAllNotifications ? 1 : 0); + + final highlightedId = useState(highlightedEvent); + final listController = useRef(ListController()); + final scrollController = useScrollController(); + + final provider = NotificationsController.provider(( + options.values.toList()[unreadTypeIndex.value], + null, + )); + final notifications = ref.watch(provider); + final notifier = ref.watch(provider.notifier); + + useEffect(() { + if (highlightedId.value == null) return null; + Timer? timer; + + void listener() => + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!listController.value.isAttached) return; + + final notifications = await ref.watch(provider.future); + + final index = notifications.indexWhere( + (tuple) => tuple.$1 == highlightedId.value!, + ); + + if (index == -1) return; + + listController.value.animateToItem( + index: index, + scrollController: scrollController, + alignment: 0.5, + duration: (_) => .new(milliseconds: 700), + curve: (_) => Curves.easeInOut, + ); + timer = Timer(.new(seconds: 1), () { + highlightedId.value = null; + }); + listController.value.removeListener(listener); + }); + + listController.value.addListener(listener); + return timer?.cancel; + }, []); + + useEffect(() { + Future listener() async { + if (!scrollController.hasClients || notifications.isLoading) return; + + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent) { + await notifier.loadOlder(); + } + } + + scrollController.addListener(listener); + return () => scrollController.removeListener(listener); + }, [scrollController, notifications]); + + return Stack( + children: [ + Column( + children: [ + if (notifications is AsyncLoading && notifications.value != null) + const LinearProgressIndicator(minHeight: 2), + Expanded( + child: switch (notifications) { + AsyncData(:final value) || AsyncLoading(:final value?) => + value.isEmpty + ? Center( + child: Text( + "No notifications yet", + style: Theme.of(context).textTheme.headlineMedium, + ), + ) + : SuperListView.builder( + listController: listController.value, + controller: scrollController, + itemCount: value.length, + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 12, + ), + reverse: true, + itemBuilder: (context, index) { + final (rowId, roomId) = value[index]; + final isHighlighted = rowId == highlightedId.value; + + return Padding( + padding: .only(top: 8), + child: HighlightWrapper( + InkWell( + onTap: () async { + final navigator = Navigator.of(context); + if (navigator.canPop()) navigator.pop(); + + await ref.focusRoom(roomId, rowId); + }, + child: IgnorePointer( + child: EventRenderer(rowId, roomId: roomId), + ), + ), + isHighlighted: isHighlighted, + ), + ); + }, + ), + AsyncLoading() => const Loading(), + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + }, + ), + ], + ), + Align( + alignment: .topRight, + child: Padding( + padding: .all(16), + child: M3EToggleButtonGroup( + selectedIndex: unreadTypeIndex.value, + onSelectedIndexChanged: (index) => + unreadTypeIndex.value = index ?? unreadTypeIndex.value, + actions: options.keys.toList(), + ), + ), + ), + ], + ); + } +} diff --git a/lib/pages/chat.dart b/lib/widgets/pages/chat.dart similarity index 72% rename from lib/pages/chat.dart rename to lib/widgets/pages/chat.dart index 316c9af..74aea14 100644 --- a/lib/pages/chat.dart +++ b/lib/widgets/pages/chat.dart @@ -1,25 +1,22 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/emoji.dart"; import "package:nexus/controllers/init_complete.dart"; import "package:nexus/controllers/key.dart"; import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/sidebar.dart"; -import "package:nexus/widgets/room_chat.dart"; +import "package:nexus/widgets/room_chat/room_chat.dart"; import "package:nexus/widgets/loading.dart"; -class ChatPage extends ConsumerWidget { - const ChatPage({super.key}); - +class const ChatPage({super.key}) extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder( builder: (context, constraints) { final isDesktop = constraints.maxWidth > 650; final showMembersByDefault = constraints.maxWidth > 1000; final initComplete = ref.watch(InitCompleteController.provider); - final roomId = ref.watch(KeyController.provider(KeyController.roomKey)); - - ref.read(EmojiController.provider); + final roomId = ref + .watch(KeyController.provider(KeyController.roomKey)) + .requireValue; return SafeArea( child: Scaffold( @@ -27,9 +24,10 @@ class ChatPage extends ConsumerWidget { body: initComplete ? Row( children: [ - if (isDesktop) Sidebar(isDesktop: isDesktop), + if (isDesktop) Sidebar(), Expanded( child: RoomChat( + key: ValueKey(roomId), roomId: roomId, isDesktop: isDesktop, showMembersByDefault: showMembersByDefault, @@ -43,9 +41,7 @@ class ChatPage extends ConsumerWidget { children: [Loading(), Text("Syncing...")], ), ), - drawer: isDesktop || !initComplete - ? null - : Sidebar(isDesktop: isDesktop), + drawer: isDesktop || !initComplete ? null : Sidebar(), ), ); }, diff --git a/lib/pages/select_server.dart b/lib/widgets/pages/select_server.dart similarity index 64% rename from lib/pages/select_server.dart rename to lib/widgets/pages/select_server.dart index f7cefb8..c690dc1 100644 --- a/lib/pages/select_server.dart +++ b/lib/widgets/pages/select_server.dart @@ -1,5 +1,5 @@ import "package:app_links/app_links.dart"; -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_svg/flutter_svg.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; @@ -9,13 +9,11 @@ import "package:nexus/controllers/client_id.dart"; import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/main.dart"; import "package:nexus/models/homeserver.dart"; -import "package:nexus/pages/settings.dart"; +import "package:nexus/widgets/pages/settings.dart"; import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/divider_text.dart"; -class SelectServerPage extends HookConsumerWidget { - const SelectServerPage({super.key}); - +class const SelectServerPage({super.key}) extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); @@ -26,6 +24,17 @@ class SelectServerPage extends HookConsumerWidget { final homeserverUrl = useTextEditingController(); Future setHomeserver(Uri? newHomeserver) async { + if (newHomeserver == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "Failed to parse homeserver URL. Are you sure you typed it correctly?", + style: .new(color: theme.colorScheme.onErrorContainer), + ), + backgroundColor: theme.colorScheme.errorContainer, + ), + ); + } isLoading.value = true; try { @@ -33,26 +42,14 @@ class SelectServerPage extends HookConsumerWidget { newHomeserver = Uri.https(newHomeserver!.path); } - final newUrl = newHomeserver == null - ? null - : await ref - .read(ClientController.provider.notifier) - .discoverHomeserver(newHomeserver); + final newUrl = await ref + .read(ClientController.provider.notifier) + .discoverHomeserver(newHomeserver!); if (context.mounted) { - if (newUrl == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - "Homeserver verification failed. Is your homeserver down?", - style: .new(color: theme.colorScheme.onErrorContainer), - ), - backgroundColor: theme.colorScheme.errorContainer, - ), - ); - } else { + Future tryLogin(Uri url) async { final codeResponse = await ref.watch( - AuthUrlController.provider(newUrl).future, + AuthUrlController.provider(url).future, ); await ref.watch(LaunchHelper.provider).launchUrl(codeResponse.url); @@ -60,33 +57,60 @@ class SelectServerPage extends HookConsumerWidget { AppLinks().uriLinkStream.listen((encodedUri) async { final state = encodedUri.queryParameters["state"]; if (state != codeResponse.state) return; + isLoading.value = true; - final code = encodedUri.queryParameters["code"]; + try { + final code = encodedUri.queryParameters["code"]; - if (code != null) { - await ref - .watch(ClientController.provider.notifier) - .exchangeToken( - .new( - homeserverUrl: newUrl, - codeVerifier: codeResponse.codeVerifier, - redirectUri: .new( - scheme: "nexus.federated.nexus", - path: "/", + if (code != null) { + await ref + .watch(ClientController.provider.notifier) + .exchangeToken( + .new( + homeserverUrl: url, + codeVerifier: codeResponse.codeVerifier, + redirectUri: .new( + scheme: "nexus.federated.nexus", + path: "/", + ), + code: code, + clientId: await ref.watch( + ClientIdController.provider(url).future, + ), ), - code: code, - clientId: await ref.watch( - ClientIdController.provider(newUrl).future, - ), - ), - ) - .onError(showError); + ); + } + } catch (error, stackTrace) { + showError(error, stackTrace); + rethrow; + } finally { + isLoading.value = false; } }); } + + if (newUrl == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "Homeserver verification failed.", + style: .new(color: theme.colorScheme.onErrorContainer), + ), + action: SnackBarAction( + onPressed: () => tryLogin(newHomeserver!).onError(showError), + label: "Attempt log in anyways", + textColor: theme.colorScheme.onErrorContainer, + ), + backgroundColor: theme.colorScheme.errorContainer, + ), + ); + } else { + await tryLogin(newUrl); + } } } catch (error, stackTrace) { showError(error, stackTrace); + rethrow; } finally { isLoading.value = false; } @@ -110,9 +134,9 @@ class SelectServerPage extends HookConsumerWidget { padding: .symmetric(vertical: 8, horizontal: 12), children: [ Row( + spacing: 12, children: [ - SvgPicture.asset("assets/icon.svg", width: 128), - SizedBox(width: 12), + SvgPicture.asset("assets/bundled/icon.svg", width: 128), Expanded( child: Column( crossAxisAlignment: .start, @@ -120,7 +144,7 @@ class SelectServerPage extends HookConsumerWidget { Text("Nexus", style: theme.textTheme.displayMedium), Text( "A Simple Matrix Client", - style: theme.textTheme.headlineMedium, + style: theme.textTheme.titleLarge, overflow: .ellipsis, ), ], @@ -137,6 +161,7 @@ class SelectServerPage extends HookConsumerWidget { child: TextField( textInputAction: .done, autofocus: true, + keyboardType: .url, onSubmitted: (text) => setHomeserver(.tryParse(text)), controller: homeserverUrl, decoration: .new( @@ -158,26 +183,24 @@ class SelectServerPage extends HookConsumerWidget { ...([ .new( name: "Matrix.org", - description: - "The Matrix.org Foundation offers the matrix.org homeserver as an easy entry point for anyone wanting to try out Matrix.", + description: "The Matrix.org Foundation offers the matrix.org homeserver as an easy entry point for anyone wanting to try out Matrix.", url: .https("matrix.org"), iconUrl: "https://raw.githubusercontent.com/element-hq/logos/refs/heads/master/matrix/matrix-favicon${Theme.brightnessOf(context) == Brightness.dark ? "-white" : ""}.png", ), .new( name: "Federated Nexus", - description: - "Federated Nexus is a community resource hosting multiple FOSS (especially federated) services, including Matrix and Forgejo. By the same developers who made Nexus client.", + description: "Federated Nexus is a community resource hosting multiple FOSS (especially federated) services, including Matrix and Forgejo. By the same developers who made Nexus client.", url: .https("federated.nexus"), iconUrl: "https://federated.nexus/images/icon.png", ), - .new( - name: "Unredacted", - description: - "Unredacted is a 501(c)(3) non-profit organization that builds Internet infrastructure and services to help people evade censorship and protect their right to privacy.", - url: .https("unredacted.org", "services/si/matrix"), - iconUrl: "https://unredacted.org/favicon.ico", - ), + // TODO: Add back unredacted when they have MAS, find where new favicon is + // .new( + // name: "Unredacted", + // description: "Unredacted is a 501(c)(3) non-profit organization that builds Internet infrastructure and services to help people evade censorship and protect their right to privacy.", + // url: .https("unredacted.org", "services/si/matrix"), + // iconUrl: "https://unredacted.org/favicon.ico", + // ), ].map( (homeserver) => Card( child: ListTile( diff --git a/lib/pages/settings.dart b/lib/widgets/pages/settings.dart similarity index 81% rename from lib/pages/settings.dart rename to lib/widgets/pages/settings.dart index fa4d8da..50eb7f0 100644 --- a/lib/pages/settings.dart +++ b/lib/widgets/pages/settings.dart @@ -1,6 +1,6 @@ import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:m3e_card_list/m3e_card_list.dart"; @@ -8,14 +8,12 @@ import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; import "package:nexus/controllers/settings_sections.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/show_about_dialog.dart"; -import "package:nexus/pages/settings_category.dart"; +import "package:nexus/widgets/pages/settings_category.dart"; import "package:nexus/widgets/divider_text.dart"; import "package:nexus/widgets/highlight_wrapper.dart"; import "package:super_sliver_list/super_sliver_list.dart"; -class SettingsPage extends ConsumerWidget { - const SettingsPage({super.key}); - +class const SettingsPage({super.key}) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder( builder: (_, constraints) => HookBuilder( @@ -142,12 +140,12 @@ class SettingsPage extends ConsumerWidget { vertical: 8, ), margin: .symmetric(horizontal: 12), - color: Theme.of( - context, - ).colorScheme.primaryContainer, + color: Theme.of(context) + .colorScheme + .primaryContainer, itemCount: categories.length, - onTap: (index) => - Navigator.of(context).push( + onTap: (index) => Navigator.of(context) + .push( MaterialPageRoute( builder: (context) => SettingsCategoryPage( @@ -172,30 +170,34 @@ class SettingsPage extends ConsumerWidget { ) : Row( children: [ - NavigationRailM3E( - type: .alwaysExpand, - trailing: searchBar, - scrollable: true, - sections: sections - .mapTo( - (categoryGroup, categories) => - NavigationRailM3ESection( - header: DividerText(categoryGroup), - destinations: categories - .map( - (category) => - NavigationRailM3EDestination( - icon: Icon(category.icon), - label: category.title, - ), - ) - .toList(), - ), - ) - .toList(), - selectedIndex: selected.value, - onDestinationSelected: (value) => - selected.value = value, + MaterialUiCompatibilityBridge( + child: NavigationRailM3E( + type: .alwaysExpand, + trailing: searchBar, + scrollable: true, + sections: sections + .mapTo( + ( + categoryGroup, + categories, + ) => NavigationRailM3ESection( + header: DividerText(categoryGroup), + destinations: categories + .map( + (category) => + NavigationRailM3EDestination( + icon: Icon(category.icon), + label: category.title, + ), + ) + .toList(), + ), + ) + .toList(), + selectedIndex: selected.value, + onDestinationSelected: (value) => + selected.value = value, + ), ), VerticalDivider(), Expanded( diff --git a/lib/pages/settings_category.dart b/lib/widgets/pages/settings_category.dart similarity index 91% rename from lib/pages/settings_category.dart rename to lib/widgets/pages/settings_category.dart index b21d048..8e94c56 100644 --- a/lib/pages/settings_category.dart +++ b/lib/widgets/pages/settings_category.dart @@ -1,7 +1,7 @@ import "dart:async"; import "package:collection/collection.dart"; -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/settings_sections.dart"; @@ -9,11 +9,11 @@ import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/widgets/highlight_wrapper.dart"; import "package:super_sliver_list/super_sliver_list.dart"; -class SettingsCategoryPage extends HookConsumerWidget { - final int index; - final int? initialHighlight; - const SettingsCategoryPage(this.index, {this.initialHighlight, super.key}); - +class const SettingsCategoryPage( + final int index, { + final int? initialHighlight, + super.key, +}) extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final highlight = useState(initialHighlight); diff --git a/lib/pages/verify.dart b/lib/widgets/pages/verify.dart similarity index 90% rename from lib/pages/verify.dart rename to lib/widgets/pages/verify.dart index 4eefa6c..44d276f 100644 --- a/lib/pages/verify.dart +++ b/lib/widgets/pages/verify.dart @@ -1,14 +1,12 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/client.dart"; -import "package:nexus/pages/settings.dart"; +import "package:nexus/widgets/pages/settings.dart"; import "package:nexus/widgets/appbar.dart"; import "package:nexus/helpers/required_validator_helper.dart"; -class VerifyPage extends HookConsumerWidget { - const VerifyPage({super.key}); - +class const VerifyPage({super.key}) extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final passphraseController = useTextEditingController(); @@ -43,6 +41,8 @@ class VerifyPage extends HookConsumerWidget { ]), ), body: AlertDialog( + insetPadding: .symmetric(vertical: 12, horizontal: 32), + scrollable: true, title: Text("Verify"), content: Form( key: formKey.value, diff --git a/lib/widgets/pinned_events_drawer.dart b/lib/widgets/pinned_events_drawer.dart index 7cb9fe3..cff6ad6 100644 --- a/lib/widgets/pinned_events_drawer.dart +++ b/lib/widgets/pinned_events_drawer.dart @@ -1,23 +1,19 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/pinned_events.dart"; +import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/renderers/event.dart"; -class PinnedEventsDrawer extends HookConsumerWidget { - final String roomId; - final IList Function(Event event) getEventOptions; - final Future Function(String eventId) jumpToId; - const PinnedEventsDrawer( - this.roomId, { - required this.getEventOptions, - required this.jumpToId, - super.key, - }); - +final class const PinnedEventsDrawer( + final String roomId, { + required final IList Function(Event event) getEventOptions, + required final JumpToEvent jumpToEvent, + super.key, +}) extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final pinsProvider = ref.watch(PinnedEventsController.provider(roomId)); @@ -61,21 +57,23 @@ class PinnedEventsDrawer extends HookConsumerWidget { reverse: true, itemCount: value.length, itemBuilder: (context, index) { - final event = value.reversed[index]; + final rowId = value.reversed[index]; return InkWell( borderRadius: .circular(12), onTap: () { Navigator.of(context).pop(); - jumpToId(event.eventId); + jumpToEvent(rowId); }, child: Padding( padding: .symmetric(vertical: 4), - child: EventRenderer( - event, - maxLines: 2, - isGrouped: false, - getEventOptions: getEventOptions, + child: IgnorePointer( + child: EventRenderer( + rowId, + roomId: roomId, + maxLines: 2, + getEventOptions: getEventOptions, + ), ), ), ); diff --git a/lib/widgets/players/audio.dart b/lib/widgets/players/audio.dart index 0c96579..bd50a8b 100644 --- a/lib/widgets/players/audio.dart +++ b/lib/widgets/players/audio.dart @@ -1,18 +1,18 @@ import "dart:async"; -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:media_kit/media_kit.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:nexus/controllers/client.dart"; import "package:nexus/models/info/audio.dart"; -class AudioPlayer extends HookConsumerWidget { - final Uri url; - final AudioInfo? info; - - const AudioPlayer(this.url, this.info, {super.key}); - +class const AudioPlayer( + final Uri uri, + final AudioInfo? info, { + final bool encrypted = false, + super.key, +}) extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final player = useMemoized( @@ -24,11 +24,12 @@ class AudioPlayer extends HookConsumerWidget { final duration = useState(Duration.zero); useEffect(() { + player.platform?.state = player.platform!.state.copyWith(buffering: true); scheduleMicrotask(() async { - await player.open( - Media(url.toString(), httpHeaders: ref.headers), - play: false, - ); + final video = await ref + .watch(ClientController.provider.notifier) + .downloadMedia(.new(mxc: uri, encrypted: encrypted)); + await player.open(Media(video.path), play: false); player.stream.playing.listen((value) { playing.value = value; @@ -67,12 +68,18 @@ class AudioPlayer extends HookConsumerWidget { padding: .only(left: 8, right: 16), child: Row( children: [ - IconButton( - onPressed: player.playOrPause, - icon: Icon( - playing.value ? Icons.pause_circle : Icons.play_circle, + if (player.state.buffering) + SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(padding: .all(4)), + ) + else + IconButton( + onPressed: player.playOrPause, + icon: Icon( + playing.value ? Icons.pause_circle : Icons.play_circle, + ), ), - ), SizedBox(width: 8), Text( format(position.value), diff --git a/lib/widgets/players/video.dart b/lib/widgets/players/video.dart index 8083860..024c30e 100644 --- a/lib/widgets/players/video.dart +++ b/lib/widgets/players/video.dart @@ -1,18 +1,19 @@ import "dart:async"; -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client.dart"; import "package:nexus/models/info/video.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:media_kit/media_kit.dart"; import "package:media_kit_video/media_kit_video.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; - -class VideoPlayer extends HookConsumerWidget { - final VideoInfo? info; - final Uri url; - const VideoPlayer(this.url, this.info, {super.key}); +class const VideoPlayer( + final Uri uri, + final VideoInfo? info, { + final bool encrypted = false, + super.key, +}) extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final player = useMemoized( @@ -21,12 +22,13 @@ class VideoPlayer extends HookConsumerWidget { final controller = useMemoized(() => VideoController(player)); useEffect(() { - scheduleMicrotask( - () => player.open( - Media(url.toString(), httpHeaders: ref.headers), - play: false, - ), - ); + player.platform?.state = player.platform!.state.copyWith(buffering: true); + scheduleMicrotask(() async { + final video = await ref + .watch(ClientController.provider.notifier) + .downloadMedia(.new(mxc: uri, encrypted: encrypted)); + await player.open(Media(video.path), play: false); + }); return player.dispose; }, []); diff --git a/lib/widgets/reaction_row.dart b/lib/widgets/reaction_row.dart index b2fa5ca..edef81c 100644 --- a/lib/widgets/reaction_row.dart +++ b/lib/widgets/reaction_row.dart @@ -1,4 +1,4 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state.dart"; @@ -10,10 +10,7 @@ import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/main.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; -class ReactionRow extends ConsumerWidget { - final Event event; - const ReactionRow(this.event, {super.key}); - +class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final clientState = ref.watch(ClientStateController.provider); @@ -67,9 +64,10 @@ class ReactionRow extends ConsumerWidget { enabled.value = false; try { final controller = ref.watch( - RoomChatController.provider( - event.roomId, - ).notifier, + RoomChatController.provider(( + roomId: event.roomId, + contextualEvent: null, + )).notifier, ); if (selected) { diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index bcca961..af4cef4 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -1,8 +1,13 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; +import "package:flutter/gestures.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/event.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/controllers/rooms.dart"; import "package:nexus/helpers/extensions/show_context_menu.dart"; +import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/canonical_alias.dart"; import "package:nexus/models/content/content.dart"; @@ -24,24 +29,18 @@ import "package:nexus/widgets/renderers/message.dart"; import "package:nexus/widgets/reaction_row.dart"; import "package:nexus/widgets/renderers/membership.dart"; import "package:nexus/widgets/renderers/generic_event.dart"; +import "package:nexus/widgets/timestamp.dart"; -class EventRenderer extends HookConsumerWidget { - final Event event; - final bool textOnly; - final bool isGrouped; - final int? maxLines; - final VoidCallback? onTapReply; - final IList Function(Event event)? getEventOptions; - const EventRenderer( - this.event, { - this.onTapReply, - this.textOnly = false, - this.isGrouped = false, - this.maxLines, - this.getEventOptions, - super.key, - }); - +class const EventRenderer( + final int eventRowId, { + required final String roomId, + final int? previousEventRowId, + final bool textOnly = false, + final int? maxLines, + final JumpToEvent? jumpToEvent, + final IList Function(Event event)? getEventOptions, + super.key, +}) extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); @@ -50,6 +49,38 @@ class EventRenderer extends HookConsumerWidget { final focusNode = useFocusNode(); useListenable(focusNode); + final (event, previousEvent) = ref.watch( + RoomsController.provider.select((rooms) { + final event = rooms[roomId]?.events[eventRowId]; + if (event == null) return (null, null); + + final edited = event.lastEditRowId == 0 + ? null + : rooms[roomId]?.events[event.lastEditRowId]; + + return ( + edited == null + ? event + : event.copyWith( + content: edited.content, + localContent: edited.localContent, + ), + previousEventRowId == null + ? null + : rooms[roomId]?.events[previousEventRowId!], + ); + }), + ); + + if (event == null) return SizedBox.shrink(); + + final isGrouped = + previousEvent?.content is MessageContent && + previousEvent?.redactedBy == null && + previousEvent?.relationType != "m.replace" && + event.sender == previousEvent?.sender && + event.pmp?.id == previousEvent?.pmp?.id; + final child = event.redactedBy != null || event.relationType == "m.replace" ? null : switch (event.content) { @@ -66,7 +97,18 @@ class EventRenderer extends HookConsumerWidget { EncryptedContent() || StickerContent() => MessageRenderer( event, - onTapReply: onTapReply, + onTapReply: jumpToEvent == null + ? null + : () async { + final replyEvent = await ref.read( + EventController.provider( + .new(roomId: roomId, eventId: event.replyTo!), + ).future, + ); + if (replyEvent != null) { + await jumpToEvent!(replyEvent.rowId); + } + }, isGrouped: isGrouped, maxLines: maxLines, textOnly: textOnly, @@ -74,92 +116,181 @@ class EventRenderer extends HookConsumerWidget { MembershipContent content => switch (event.previousContent) { MembershipContent(:final status) => - status == content.status ? null : MembershipRenderer(event), - _ => MembershipRenderer(event), + status == content.status + ? null + : MembershipRenderer(event, maxLines: maxLines), + _ => MembershipRenderer(event, maxLines: maxLines), }, - AvatarContent() => GenericEventRenderer(Icons.interests, [ - MessageDisplayname(event), - Text("changed the room avatar"), - ]), + AvatarContent() => GenericEventRenderer( + Icons.interests, + maxLines: maxLines, + [ + WidgetSpan( + alignment: .middle, + child: MessageDisplayname( + event, + shouldWrap: maxLines == null, + ), + ), + TextSpan(text: "changed the room avatar"), + ], + ), - CreateContent() => GenericEventRenderer(Icons.add, [ - MessageDisplayname(event), - Text("created the room"), - ]), + CreateContent() => GenericEventRenderer( + Icons.add, + maxLines: maxLines, + [ + WidgetSpan( + alignment: .middle, + child: MessageDisplayname( + event, + shouldWrap: maxLines == null, + ), + ), + TextSpan(text: "created the room"), + ], + ), - PowerLevelsContent() => GenericEventRenderer(Icons.power, [ - MessageDisplayname(event), - Text("changed the room's power levels"), - ]), + PowerLevelsContent() => GenericEventRenderer( + Icons.power, + maxLines: maxLines, + [ + WidgetSpan( + alignment: .middle, + child: MessageDisplayname( + event, + shouldWrap: maxLines == null, + ), + ), + TextSpan(text: "changed the room's power levels"), + ], + ), - JoinRulesContent() => GenericEventRenderer(Icons.rule, [ - MessageDisplayname(event), - Text("changed the room's join rules"), - ]), + JoinRulesContent() => GenericEventRenderer( + Icons.rule, + maxLines: maxLines, + [ + WidgetSpan( + alignment: .middle, + child: MessageDisplayname( + event, + shouldWrap: maxLines == null, + ), + ), + TextSpan(text: "changed the room's join rules"), + ], + ), - TopicContent() => GenericEventRenderer(Icons.description, [ - MessageDisplayname(event), - Text("updated the room topic"), - ]), + TopicContent() => GenericEventRenderer( + Icons.description, + maxLines: maxLines, + [ + WidgetSpan( + alignment: .middle, + child: MessageDisplayname( + event, + shouldWrap: maxLines == null, + ), + ), + TextSpan(text: "updated the room topic"), + ], + ), HistoryVisibilityContent(:final historyVisibility) => - GenericEventRenderer(Icons.history, [ - MessageDisplayname(event), - Text( - "changed the room's history visibility to ${switch (historyVisibility) { - .invited => "since invited", - .joined => "since joined", - .shared => "all history visible (shared)", - .worldReadable => "all history visible (world readable)", - }}", + GenericEventRenderer(Icons.history, maxLines: maxLines, [ + WidgetSpan( + alignment: .middle, + child: MessageDisplayname( + event, + shouldWrap: maxLines == null, + ), + ), + TextSpan( + text: + "changed the room's history visibility to ${switch (historyVisibility) { + .invited => "since invited", + .joined => "since joined", + .shared => "all history visible (shared)", + .worldReadable => "all history visible (world readable)", + }}", ), ]), - PinnedEventsContent() => GenericEventRenderer(Icons.push_pin, [ - MessageDisplayname(event), - Text("pinned/unpinned some events"), - ]), + PinnedEventsContent() => GenericEventRenderer( + Icons.push_pin, + maxLines: maxLines, + [ + WidgetSpan( + alignment: .middle, + child: MessageDisplayname( + event, + shouldWrap: maxLines == null, + ), + ), + TextSpan(text: "pinned/unpinned some events"), + ], + ), - ServerACLContent() => GenericEventRenderer(Icons.list, [ - MessageDisplayname(event), - Text("updated the server ban list"), - ]), + ServerACLContent() => GenericEventRenderer( + Icons.list, + maxLines: maxLines, + [ + WidgetSpan( + alignment: .middle, + child: MessageDisplayname( + event, + shouldWrap: maxLines == null, + ), + ), + TextSpan(text: "updated the server ban list"), + ], + ), CanonicalAliasContent(:final alias, :final altAliases) => - GenericEventRenderer(Icons.numbers, [ - MessageDisplayname(event), - Text(switch ([ - if (event.previousContent case CanonicalAliasContent( - alias: final prevAlias, - altAliases: final prevAltAliases, - )) ...[ - if (prevAlias != alias) - if (alias == null) - "removed the room's canonical alias" - else - "changed the room's canonical alias to $alias", + GenericEventRenderer(Icons.numbers, maxLines: maxLines, [ + WidgetSpan( + alignment: .middle, + child: MessageDisplayname( + event, + shouldWrap: maxLines == null, + ), + ), + TextSpan( + text: switch ([ + if (event.previousContent case CanonicalAliasContent( + alias: final prevAlias, + altAliases: final prevAltAliases, + )) ...[ + if (prevAlias != alias) + if (alias == null) + "removed the room's canonical alias" + else + "changed the room's canonical alias to $alias", - if (prevAltAliases - .remove(alias ?? "") - .remove(prevAlias ?? "") != - altAliases.remove(alias ?? "").remove(prevAlias ?? "")) - "changed the room's aliases", - ] else ...[ - if (alias != null) "set the room's canonical alias", - if (altAliases.isNotEmpty) "set the room's aliases", - ], - ]) { - [] => "did something related to room aliases", - List prev => prev.join(" and "), - }), + if (prevAltAliases + .remove(alias ?? "") + .remove(prevAlias ?? "") != + altAliases + .remove(alias ?? "") + .remove(prevAlias ?? "")) + "changed the room's aliases", + ] else ...[ + if (alias != null) "set the room's canonical alias", + if (altAliases.isNotEmpty) "set the room's aliases", + ], + ]) { + [] => "did something related to room aliases", + List prev => prev.join(" and "), + }, + ), ]), _ => null, }; final contextMenuCallback = getEventOptions == null ? null - : (details) => context.showContextMenu( + : (PositionedGestureDetails details) => context.showContextMenu( globalPosition: details.globalPosition, children: getEventOptions!(event).toList(), ); @@ -167,7 +298,7 @@ class EventRenderer extends HookConsumerWidget { return Column( crossAxisAlignment: .start, children: [ - if (child != null) ...[ + if (child != null) if (textOnly) child else ...[ @@ -202,10 +333,18 @@ class EventRenderer extends HookConsumerWidget { onSecondaryTapUp: contextMenuCallback, onLongPressStart: contextMenuCallback, child: Padding( - padding: EdgeInsets.symmetric( - horizontal: 8, - ).copyWith(top: isGrouped ? 0 : 8), - child: child, + padding: EdgeInsets.symmetric(horizontal: 8) + .copyWith(top: isGrouped ? 0 : 8), + child: Wrap( + verticalDirection: .up, + spacing: 6, + crossAxisAlignment: .center, + children: [ + child, + if (child is! MessageRenderer) + Timestamp(event.timestamp), + ], + ), ), ), ), @@ -213,18 +352,44 @@ class EventRenderer extends HookConsumerWidget { ), ...[ - if (event.content is! MessageContent) ReactionRow(event), + if (child is! MessageRenderer) ReactionRow(event), if (event.sendError != null && event.sendError != "not sent") - Text( - event.sendError!, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.error, + Padding( + padding: .only(bottom: 4), + child: Row( + spacing: 4, + children: [ + Text( + event.sendError!, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + InkWell( + child: Text( + "Hide", + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, + decoration: .underline, + decorationColor: theme.colorScheme.primary, + ), + ), + onTap: () => ref + .read( + RoomChatController.provider(( + roomId: roomId, + contextualEvent: null, + )).notifier, + ) + .hideClientSticky(event.rowId), + ), + ], ), ), ].map((child) => Padding(padding: .only(left: 4), child: child)), - ], - ] else if (textOnly) + ] + else if (textOnly) Text("Unknown event type", style: errorStyle), ], ); diff --git a/lib/widgets/renderers/generic_event.dart b/lib/widgets/renderers/generic_event.dart index 6dfae53..5ff9dbc 100644 --- a/lib/widgets/renderers/generic_event.dart +++ b/lib/widgets/renderers/generic_event.dart @@ -1,18 +1,33 @@ -import "package:flutter/material.dart"; - -class GenericEventRenderer extends StatelessWidget { - final IconData icon; - final List children; - const GenericEventRenderer(this.icon, this.children, {super.key}); +import "package:material_ui/material_ui.dart"; +class const GenericEventRenderer( + final IconData icon, + final List children, { + final int? maxLines, + super.key, +}) extends StatelessWidget { @override Widget build(BuildContext context) => Padding( padding: .symmetric(vertical: 4), child: Row( spacing: 8, + mainAxisSize: .min, children: [ Padding(padding: .symmetric(horizontal: 4), child: Icon(icon)), - Expanded(child: Wrap(spacing: 4, children: children)), + Flexible( + child: Text.rich( + TextSpan( + children: [ + for (final (index, child) in children.indexed) ...[ + if (index > 0) TextSpan(text: " "), + child, + ], + ], + ), + maxLines: maxLines ?? 999, + overflow: .ellipsis, + ), + ), ], ), ); diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart index b2835b4..c57200c 100644 --- a/lib/widgets/renderers/membership.dart +++ b/lib/widgets/renderers/membership.dart @@ -1,4 +1,4 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; import "package:nexus/helpers/extensions/string_to_color.dart"; @@ -7,10 +7,11 @@ import "package:nexus/models/event.dart"; import "package:nexus/widgets/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/renderers/generic_event.dart"; -class MembershipRenderer extends StatelessWidget { - final Event event; - const MembershipRenderer(this.event, {super.key}); - +class const MembershipRenderer( + final Event event, { + final int? maxLines, + super.key, +}) extends StatelessWidget { @override Widget build(BuildContext context) { assert( @@ -19,34 +20,45 @@ class MembershipRenderer extends StatelessWidget { ); return switch (event.content) { - MembershipContent content => GenericEventRenderer(Icons.people, [ - InkWell( - onTap: () => context.showUserPopover( - content, - event.stateKey!, - roomId: event.roomId, + MembershipContent content => GenericEventRenderer( + Icons.people, + maxLines: maxLines, + [ + WidgetSpan( + alignment: .middle, + child: InkWell( + onTap: () => context.showUserPopover( + content, + event.stateKey!, + roomId: event.roomId, + ), + child: Text( + overflow: .ellipsis, + content.displayName ?? event.stateKey!.localpart, + maxLines: 1, + style: .new(color: event.sender.colorHash, fontWeight: .bold), + ), + ), ), - child: Text( - overflow: .ellipsis, - content.displayName ?? event.stateKey!.localpart, - maxLines: 1, - style: .new(color: event.sender.colorHash, fontWeight: .bold), + TextSpan( + text: + "${switch (content.status) { + .invite => "was invited to", + .join => "joined", + .leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), + .ban => "was banned from", + .knock => "asked to join", + }} the room${event.sender == event.stateKey ? "" : " by"}", ), - ), - Text( - overflow: .ellipsis, - maxLines: 1, - "${switch (content.status) { - .invite => "was invited to", - .join => "joined", - .leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), - .ban => "was banned from", - .knock => "asked to join", - }} the room${event.sender == event.stateKey ? "" : " by "}", - ), - if (event.sender != event.stateKey) MessageDisplayname(event), - if (content.reason != null) Text("for \"${content.reason}\""), - ]), + if (event.sender != event.stateKey) + WidgetSpan( + alignment: .middle, + child: MessageDisplayname(event, shouldWrap: maxLines == null), + ), + if (content.reason != null) + TextSpan(text: "for \"${content.reason}\""), + ], + ), _ => SizedBox.shrink(), }; } diff --git a/lib/widgets/renderers/message.dart b/lib/widgets/renderers/message.dart index 6d660af..3404bc5 100644 --- a/lib/widgets/renderers/message.dart +++ b/lib/widgets/renderers/message.dart @@ -1,9 +1,10 @@ import "package:collection/collection.dart"; -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:linkify/linkify.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/event.dart"; +import "package:nexus/helpers/extensions/get_filename.dart"; import "package:nexus/models/content/encrypted.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/sticker.dart"; @@ -13,54 +14,143 @@ import "package:nexus/widgets/html/html.dart"; import "package:nexus/widgets/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/linkified_text.dart"; +import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/message_image.dart"; import "package:nexus/widgets/reaction_row.dart"; +import "package:nexus/widgets/timestamp.dart"; import "package:nexus/widgets/url_preview.dart"; -import "package:timeago/timeago.dart"; import "package:nexus/widgets/event_preview.dart"; import "package:nexus/widgets/players/video.dart"; import "package:nexus/widgets/players/audio.dart"; -class MessageRenderer extends ConsumerWidget { - final Event event; - final bool textOnly; - final bool isGrouped; - final int? maxLines; - final VoidCallback? onTapReply; - const MessageRenderer( - this.event, { - this.onTapReply, - this.textOnly = false, - this.isGrouped = false, - this.maxLines, - super.key, - }); - +class const MessageRenderer( + final Event event, { + final VoidCallback? onTapReply, + final bool textOnly = false, + final bool isGrouped = false, + final int? maxLines, + super.key, +}) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final errorStyle = TextStyle(color: colorScheme.error); - final timestamp = Tooltip( - message: event.timestamp.toString(), - child: Text( - format(event.timestamp), - maxLines: 1, - overflow: .ellipsis, - style: theme.textTheme.labelSmall?.copyWith(color: Colors.grey), - ), - ); - final textStyle = TextStyle( - fontSize: event.localContent?.bigEmoji == true ? 32 : null, + fontSize: event.localContent?.bigEmoji == true && maxLines != 1 + ? 32 + : null, fontStyle: event.content is EmoteMessageContent ? .italic : null, ); + final rendered = switch (event.content) { + EncryptedContent() => Text("Unable to decrypt event", style: errorStyle), + StickerContent(:final body, :final url, :final info) => + textOnly + ? Text(body, maxLines: maxLines, overflow: .ellipsis) + : ConstrainedBox( + constraints: .loose(.square(200)), + child: MessageImage(url, info: info, encrypted: false), + ), + // TODO: Handle locations + // LocationMessageContent(:final body , :final geoUri) => + MessageContent content => switch (content) { + TextMessageContent(:final body, :final formattedBody, :final format) || + NoticeMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + EmoteMessageContent(:final body, :final formattedBody, :final format) || + ImageMessageContent(:final body, :final formattedBody, :final format) || + VideoMessageContent(:final body, :final formattedBody, :final format) || + AudioMessageContent(:final body, :final formattedBody, :final format) || + FileMessageContent( + :final body, + :final formattedBody, + :final format, + ) => Column( + crossAxisAlignment: .start, + children: [ + format == .html && !textOnly + ? Html( + roomId: event.roomId, + textStyle: textStyle, + formattedBody!.replaceAllMapped( + RegExp( + r"(]*>.*?<\/a>)|(\bhttps?:\/\/[^\s<]+)", + caseSensitive: false, + dotAll: true, + ), + (m) { + // If it's already an tag, leave it unchanged + if (m.group(1) != null) { + return m.group(1)!; + } + + // Otherwise, wrap the bare URL + final url = m.group(2)!; + return "$url"; + }, + ), + ) + : LinkifiedText( + body.isEmpty ? content.filename ?? "" : body, + style: textStyle, + maxLines: maxLines, + ), + + if (!textOnly) ...[ + if (event.content + case ImageMessageContent(:final url) || + FileMessageContent(:final url) || + VideoMessageContent(:final url) || + AudioMessageContent(:final url)) + ConstrainedBox( + constraints: .loose(.square(500)), + child: switch (event.content) { + VideoMessageContent(:final info, :final file) => + VideoPlayer(url, info, encrypted: file != null), + AudioMessageContent(:final info, :final file) => + AudioPlayer(url, info, encrypted: file != null), + FileMessageContent(:final info, :final filename) => + FileCard(url, info, filename: filename), + ImageMessageContent(:final info, :final file) => + MessageImage(url, info: info, encrypted: file != null), + _ => SizedBox.shrink(), + }, + ), + + if (event.lastEditRowId != 0) + Text("(edited)", style: theme.textTheme.labelSmall), + + if (linkify(body) + .firstWhereOrNull((element) => element is UrlElement) + case final UrlElement link?) + if (Uri.tryParse(link.url) case final Uri url?) UrlPreview(url), + ], + ], + ), + LocationMessageContent(:final String? body) || + UnknownMessageContent(:final body) => + body == null + ? Text("This message is redacted", style: errorStyle) + : Wrap( + spacing: 8, + children: [ + Text("Unknown message type:", style: errorStyle), + Text(body), + ], + ), + }, + _ => throw Exception("This is impossible"), + }; + return Row( crossAxisAlignment: .start, mainAxisSize: .min, - spacing: 8, + spacing: 6, children: [ if (!textOnly) if (isGrouped) @@ -69,7 +159,7 @@ class MessageRenderer extends ConsumerWidget { MessageAvatar(event, height: 40), Flexible( child: Column( - spacing: 4, + spacing: 2, crossAxisAlignment: .start, children: [ if (!isGrouped && !textOnly) @@ -77,7 +167,7 @@ class MessageRenderer extends ConsumerWidget { spacing: 4, children: [ Flexible(child: MessageDisplayname(event)), - Flexible(flex: 0, child: timestamp), + Flexible(flex: 0, child: Timestamp(event.timestamp)), ], ), Card( @@ -109,180 +199,32 @@ class MessageRenderer extends ConsumerWidget { onTap: onTapReply, child: Padding( padding: .symmetric(vertical: 8, horizontal: 12), - child: switch (ref.watch( - EventController.provider( - .new( - roomId: event.roomId, - eventId: event.replyTo!, + child: SizedBox( + height: 48, + child: switch (ref.watch( + EventController.provider( + .new( + roomId: event.roomId, + eventId: event.replyTo!, + ), ), - ), - )) { - AsyncData(:final value?) || - AsyncLoading( - :final value?, - ) => EventPreview(value), - AsyncError _ => Text( - "An error occurred while fetching the reply", - style: errorStyle, - ), - _ => Text("Fetching event..."), - }, + )) { + AsyncData(:final value?) || + AsyncLoading( + :final value?, + ) => EventPreview(value), + AsyncError _ => Text( + "An error occurred while fetching the reply", + style: errorStyle, + ), + _ => Loading(dimension: 12), + }, + ), ), ), ), - switch (event.content) { - EncryptedContent() => Text( - "Unable to decrypt event", - style: errorStyle, - ), - StickerContent(:final body, :final url, :final info) => - textOnly - ? Text( - body, - maxLines: maxLines, - overflow: .ellipsis, - ) - : ConstrainedBox( - constraints: .loose(.square(200)), - child: MessageImage( - url, - info: info, - encrypted: false, - ), - ), - // TODO: Handle locations - // LocationMessageContent(:final body , :final geoUri) => - TextMessageContent( - :final body, - :final formattedBody, - :final format, - ) || - NoticeMessageContent( - :final body, - :final formattedBody, - :final format, - ) || - EmoteMessageContent( - :final body, - :final formattedBody, - :final format, - ) || - ImageMessageContent( - :final body, - :final formattedBody, - :final format, - ) || - VideoMessageContent( - :final body, - :final formattedBody, - :final format, - ) || - AudioMessageContent( - :final body, - :final formattedBody, - :final format, - ) || - FileMessageContent( - :final body, - :final formattedBody, - :final format, - ) => Column( - crossAxisAlignment: .start, - children: [ - format == .html && !textOnly - ? Html( - roomId: event.roomId, - textStyle: textStyle, - formattedBody!.replaceAllMapped( - RegExp( - r"(]*>.*?<\/a>)|(\bhttps?:\/\/[^\s<]+)", - caseSensitive: false, - dotAll: true, - ), - (m) { - // If it's already an tag, leave it unchanged - if (m.group(1) != null) { - return m.group(1)!; - } - - // Otherwise, wrap the bare URL - final url = m.group(2)!; - return "$url"; - }, - ), - ) - : LinkifiedText( - body, - style: textStyle, - maxLines: maxLines, - ), - - if (!textOnly) ...[ - if (event.content - case ImageMessageContent(:final url) || - FileMessageContent(:final url) || - VideoMessageContent(:final url) || - AudioMessageContent(:final url)) - ConstrainedBox( - constraints: .loose(.square(500)), - child: switch (event.content) { - VideoMessageContent(:final info) => - VideoPlayer(url, info), - AudioMessageContent(:final info) => - AudioPlayer(url, info), - FileMessageContent( - :final info, - :final filename, - ) => - FileCard(url, info, filename: filename), - ImageMessageContent( - :final info, - :final file, - ) => - MessageImage( - url, - info: info, - encrypted: file != null, - ), - _ => SizedBox.shrink(), - }, - ), - - if (event.lastEditRowId != 0) - Text( - "(edited)", - style: theme.textTheme.labelSmall, - ), - - if (linkify(body).firstWhereOrNull( - (element) => element is UrlElement, - ) - case final UrlElement link?) - UrlPreview(link.url), - - ReactionRow(event), - ], - ], - ), - MessageContent(:final body) => - body == null - ? Text( - "This message is redacted", - style: errorStyle, - ) - : Row( - spacing: 8, - mainAxisSize: .min, - children: [ - Text( - "Unknown message type:", - style: errorStyle, - ), - Text(body), - ], - ), - _ => throw Exception("This is impossible"), - }, + rendered, + if (!textOnly) ReactionRow(event), ], ), ), diff --git a/lib/widgets/room_appbar.dart b/lib/widgets/room_appbar.dart index 93eca85..3b29129 100644 --- a/lib/widgets/room_appbar.dart +++ b/lib/widgets/room_appbar.dart @@ -1,27 +1,19 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/expandable_image.dart"; -import "package:nexus/widgets/linkified_text.dart"; -import "package:nexus/widgets/room_menu.dart"; - -class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { - final bool isDesktop; - final void Function(BuildContext context)? onOpenMemberList; - final void Function()? onOpenPinnedMessagesList; - final void Function() onOpenDrawer; - final String? roomId; - const RoomAppbar({ - required this.roomId, - required this.isDesktop, - required this.onOpenDrawer, - this.onOpenMemberList, - this.onOpenPinnedMessagesList, - super.key, - }); +import "package:nexus/widgets/room_menu_button.dart"; +import "package:nexus/widgets/room_summary_dialog.dart"; +final class const RoomAppbar({ + required final String? roomId, + required final bool isDesktop, + required final void Function() onOpenDrawer, + final void Function(BuildContext context)? onOpenMemberList, + final void Function()? onOpenPinnedMessagesList, + super.key, +}) extends ConsumerWidget implements PreferredSizeWidget { @override Size get preferredSize => AppBar().preferredSize; @@ -32,57 +24,19 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { : ref.watch(RoomsController.provider.select((value) => value[roomId!])); return Appbar( - onTap: room == null + onTap: room?.metadata == null ? null : () => showDialog( context: context, - builder: (context) => Dialog( - constraints: .loose(.fromWidth(400)), - child: Padding( - padding: .all(24), - child: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - spacing: 8, - children: [ - Row( - spacing: 12, - mainAxisSize: .min, - children: [ - if (room.metadata?.avatar != null) - ExpandableImage( - room.metadata?.avatar == null - ? null - : .new(mxc: room.metadata!.avatar!), - child: AvatarOrHash( - room.metadata?.avatar, - room.metadata?.name ?? "Unnamed Room", - height: 64, - fallback: Icon(Icons.numbers), - ), - ), - Expanded( - child: Text( - room.metadata?.name ?? "Unnamed Room", - overflow: .ellipsis, - maxLines: 3, - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - ], - ), - if (room.metadata?.topic?.isNotEmpty == true) - LinkifiedText( - room.metadata!.topic!, - style: Theme.of(context).textTheme.bodyLarge - ?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - ), - ], - ), + builder: (context) => RoomSummaryDialog( + .new( + roomId: room!.metadata!.id, + joinedMembers: + room.metadata!.lazyLoadSummary?.joinedMemberCount, + avatarUrl: room.metadata?.avatar, + canonicalAlias: room.metadata?.canonicalAlias, + name: room.metadata?.name, + topic: room.metadata?.topic, ), ), ), @@ -92,7 +46,7 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { : AvatarOrHash( room.metadata?.avatar, room.metadata?.name ?? "Unnamed Room", - height: 24, + dimension: 24, fallback: Icon(Icons.numbers), ) : DrawerButton(onPressed: onOpenDrawer), @@ -118,21 +72,23 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { ), ], ), - actions: room == null - ? .new() - : .new([ - IconButton( - onPressed: onOpenPinnedMessagesList?.call, - icon: Icon(Icons.push_pin), - tooltip: "Open pinned messages", - ), - IconButton( - onPressed: () => onOpenMemberList?.call(context), - tooltip: "Open member list", - icon: Icon(Icons.people), - ), - RoomMenu(room), - ]), + actions: .new( + room == null + ? [] + : [ + IconButton( + onPressed: onOpenPinnedMessagesList?.call, + icon: Icon(Icons.push_pin), + tooltip: "Open pinned messages", + ), + IconButton( + onPressed: () => onOpenMemberList?.call(context), + tooltip: "Open member list", + icon: Icon(Icons.people), + ), + RoomMenuButton(room), + ], + ), ); } } diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart deleted file mode 100644 index 0fdd7c7..0000000 --- a/lib/widgets/room_chat.dart +++ /dev/null @@ -1,564 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; -import "package:flutter/services.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:measure_size/measure_size.dart"; -import "package:nexus/controllers/account_data.dart"; -import "package:nexus/controllers/client.dart"; -import "package:nexus/controllers/client_state.dart"; -import "package:nexus/controllers/pinned_ids.dart"; -import "package:nexus/controllers/power_level.dart"; -import "package:nexus/controllers/rooms.dart"; -import "package:nexus/controllers/room_chat.dart"; -import "package:nexus/controllers/via.dart"; -import "package:nexus/models/content/message.dart"; -import "package:nexus/models/event.dart"; -import "package:nexus/models/relation_type.dart"; -import "package:nexus/widgets/composer/composer.dart"; -import "package:nexus/widgets/emoji_picker_button.dart"; -import "package:nexus/widgets/pinned_events_drawer.dart"; -import "package:nexus/widgets/renderers/event.dart"; -import "package:nexus/widgets/member_list.dart"; -import "package:nexus/widgets/room_appbar.dart"; -import "package:nexus/widgets/highlight_wrapper.dart"; -import "package:nexus/widgets/error_dialog.dart"; -import "package:nexus/main.dart"; -import "package:nexus/widgets/loading.dart"; -import "package:super_sliver_list/super_sliver_list.dart"; - -class RoomChat extends HookConsumerWidget { - final bool isDesktop; - final bool showMembersByDefault; - final String? roomId; - const RoomChat({ - required this.roomId, - required this.isDesktop, - required this.showMembersByDefault, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final relatedEvent = useState(null); - final relationType = useState(RelationType.reply); - final highlightedEvent = useState(null); - - final composerSize = useState(64); - - final memberListOpened = useState(showMembersByDefault); - - final userId = ref.watch(ClientStateController.provider)?.userId; - final theme = Theme.of(context); - - final nothing = Center( - child: Text( - "Nothing to see here...", - style: theme.textTheme.headlineMedium, - ), - ); - if (userId == null || this.roomId == null) { - return Scaffold( - appBar: RoomAppbar( - roomId: this.roomId, - isDesktop: isDesktop, - onOpenDrawer: () => Scaffold.of(context).openDrawer(), - ), - body: nothing, - ); - } - - final roomId = this.roomId!; - - final controllerProvider = RoomChatController.provider(roomId); - final notifier = ref.watch(controllerProvider.notifier); - - final client = ref.watch(ClientController.provider.notifier); - - final listController = useRef(ListController()); - final scrollController = useScrollController(); - final controllerData = ref.watch(controllerProvider); - - final topEventBeforeLoad = useState(null); - - Future jumpToId(String eventId) async { - final index = controllerData.value?.indexWhere( - (element) => element.eventId == eventId, - ); - if (index == null) return; - - listController.value.animateToItem( - index: index, - scrollController: scrollController, - alignment: 0.5, - duration: (_) => .new(milliseconds: 700), - curve: (_) => Curves.easeInOut, - ); - highlightedEvent.value = eventId; - await Future.delayed(.new(seconds: 1), () { - if (highlightedEvent.value == eventId) { - highlightedEvent.value = null; - } - }); - } - - Future loadOlder() async { - if (controllerData case AsyncData(:final value?)) { - topEventBeforeLoad.value = value.firstOrNull?.eventId; - await notifier.loadOlder(); - } - } - - useEffect(() { - ref - .read(controllerProvider.future) - .then( - (_) => WidgetsBinding.instance.addPostFrameCallback((_) { - if (scrollController.hasClients) { - scrollController.jumpTo( - scrollController.position.maxScrollExtent - .000001, - ); - } - }), - ); - - return null; - }, [scrollController.hasClients]); - - useEffect(() { - if (controllerData case AsyncData( - :final value?, - ) when scrollController.hasClients) { - if (topEventBeforeLoad.value != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (scrollController.hasClients) { - final index = value.indexWhere( - (event) => event.eventId == topEventBeforeLoad.value, - ); - if (index != -1) { - listController.value.jumpToItem( - index: index, - scrollController: scrollController, - alignment: 0, - ); - } - } - topEventBeforeLoad.value = null; - }); - } else if (scrollController.position.atEdge && - scrollController.position.pixels != 0) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (scrollController.hasClients) { - scrollController.jumpTo( - scrollController.position.maxScrollExtent, - ); - } - }); - } - } - - return null; - }, [controllerData]); - - useEffect(() { - Future listener() async { - if (!scrollController.hasClients || !scrollController.position.atEdge) { - return; - } - - final room = ref.watch( - RoomsController.provider.select((value) => value[roomId]), - ); - if (room == null) return; - - if (scrollController.position.pixels == 0) { - if (room.hasMore) { - await loadOlder(); - } - } else { - await client.markRead(room); - } - } - - scrollController.addListener(listener); - return () => scrollController.removeListener(listener); - }, [roomId, controllerData]); - - final composerNode = useFocusNode( - onKeyEvent: (_, event) { - if (event is KeyDownEvent && event.logicalKey == .escape) { - relatedEvent.value = null; - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - }, - ); - - IList getEventOptions(Event event) { - final danger = theme.colorScheme.error; - final isSentByMe = event.sender == userId; - - return [ - if (ref.watch( - PowerLevelController.provider( - .new(eventType: .reaction, roomId: roomId), - ), - )) - PopupMenuItem( - enabled: false, - child: IconTheme( - data: theme.iconTheme, - child: Row( - children: [ - ...{ - ...ref.watch( - AccountDataController.provider.select( - (value) => IList( - value["m.recent_emoji"] - ?.content["recent_emoji"] ?? - [], - ).map((entry) => entry["emoji"]).toIList(), - ), - ), - "👍", - "🤣", - "😭", - "🤔", - } - .toIList() - .sublist(0, 4) - .map( - (emoji) => IconButton( - onPressed: () async { - Navigator.of(context).pop(); - await notifier - .sendReaction(emoji, event) - .onError(showError); - }, - icon: Text(emoji), - ), - ), - EmojiPickerButton( - context: context, - onPressed: Navigator.of(context).pop, - onSelection: (emoji) => - notifier.sendReaction(emoji, event).onError(showError), - ), - ], - ), - ), - ), - if (ref.watch( - PowerLevelController.provider( - .new(eventType: .message, roomId: roomId), - ), - )) - PopupMenuItem( - onTap: () { - relatedEvent.value = event; - relationType.value = .reply; - composerNode.requestFocus(); - }, - child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")), - ), - if (event.content is MessageContent && isSentByMe) - PopupMenuItem( - onTap: () { - relatedEvent.value = event; - relationType.value = .edit; - composerNode.requestFocus(); - }, - child: ListTile(leading: Icon(Icons.edit), title: Text("Edit")), - ), - if (ref.watch( - PowerLevelController.provider( - .state(eventType: .pinnedEvents, roomId: roomId), - ), - )) - switch (ref - .watch(PinnedIdsController.provider(roomId)) - .contains(event.eventId)) { - bool isPinned => PopupMenuItem( - onTap: () async { - try { - final notifier = ref.read( - PinnedIdsController.provider(roomId).notifier, - ); - if (isPinned) { - await notifier.removePin(event.eventId); - } else { - await notifier.addPin(event.eventId); - } - } catch (error, stackTrace) { - showError(error, stackTrace); - } - }, - child: ListTile( - leading: Icon(Icons.push_pin), - title: Text(isPinned == true ? "Unpin Event" : "Pin Event"), - ), - ), - }, - PopupMenuItem( - onTap: () async { - final room = ref.watch( - RoomsController.provider.select((value) => value[roomId]), - ); - if (room == null) return; - - final vias = ref.watch(ViaController.provider(room)); - - await Clipboard.setData( - ClipboardData( - text: - "matrix:roomid/${room.metadata?.id.substring(1)}/e/${event.eventId}$vias)", - ), - ); - }, - child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")), - ), - if (ref.watch( - PowerLevelController.provider( - .redaction(targetUser: event.sender, roomId: roomId), - ), - )) - PopupMenuItem( - onTap: () => showDialog( - context: context, - builder: (context) => HookBuilder( - builder: (_) { - final deleteReasonController = useTextEditingController(); - return AlertDialog( - title: Text("Delete Message"), - content: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [ - Text( - "Are you sure you want to delete this message? This can not be reversed.", - ), - SizedBox(height: 12), - TextField( - controller: deleteReasonController, - textCapitalization: .sentences, - decoration: .new( - labelText: "Reason for deletion (optional)", - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text("Cancel"), - ), - TextButton( - onPressed: () async { - Navigator.of(context).pop(); - await notifier - .deleteMessage( - event, - reason: deleteReasonController.text, - ) - .onError(showError); - }, - child: Text("Delete"), - ), - ], - ); - }, - ), - ), - child: ListTile( - leading: Icon(Icons.delete, color: danger), - title: Text("Delete", style: .new(color: danger)), - ), - ), - PopupMenuItem( - onTap: () => showDialog( - context: context, - builder: (context) => HookBuilder( - builder: (_) { - final reasonController = useTextEditingController(); - return AlertDialog( - title: Text("Report"), - content: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [ - Text( - "Report this event to your server administrators, who can take action like banning this server or room.", - ), - - SizedBox(height: 12), - TextField( - controller: reasonController, - textCapitalization: .sentences, - decoration: .new( - labelText: "Reason for report (optional)", - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text("Cancel"), - ), - TextButton( - onPressed: () { - client.reportEvent( - .new( - roomId: roomId, - eventId: event.eventId, - reason: reasonController.text.isEmpty - ? null - : reasonController.text, - ), - ); - Navigator.of(context).pop(); - }, - child: Text("Report"), - ), - ], - ); - }, - ), - ), - child: ListTile( - leading: Icon(Icons.report, color: danger), - title: Text("Report", style: .new(color: danger)), - ), - ), - ].toIList(); - } - - return Scaffold( - endDrawer: PinnedEventsDrawer( - roomId, - getEventOptions: getEventOptions, - jumpToId: jumpToId, - ), - body: Builder( - builder: (middleContext) => Scaffold( - endDrawer: showMembersByDefault ? null : MemberList(roomId), - appBar: RoomAppbar( - roomId: roomId, - isDesktop: isDesktop, - onOpenDrawer: Scaffold.of(context).openDrawer, - onOpenMemberList: (thisContext) { - memberListOpened.value = !memberListOpened.value; - Scaffold.of(thisContext).openEndDrawer(); - }, - onOpenPinnedMessagesList: () { - Scaffold.of(middleContext).openEndDrawer(); - }, - ), - body: Row( - children: [ - Expanded( - child: Stack( - children: [ - Positioned.fill( - child: Padding( - padding: .symmetric(horizontal: 4), - child: switch (controllerData) { - AsyncData(:final value?) || - AsyncLoading(:final value?) => CustomScrollView( - controller: scrollController, - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: .symmetric(vertical: 36), - child: Center( - child: ElevatedButton( - onPressed: controllerData is AsyncData - ? loadOlder - : null, - child: Text("Load More"), - ), - ), - ), - ), - - SuperSliverList.builder( - listController: listController.value, - itemCount: value.length, - itemBuilder: (_, index) { - final event = value[index]; - final previousEvent = value.getOrNull( - index - 1, - ); - return HighlightWrapper( - EventRenderer( - event, - onTapReply: () => - jumpToId(event.replyTo!), - getEventOptions: getEventOptions, - isGrouped: - previousEvent?.content - is MessageContent && - previousEvent?.redactedBy == null && - previousEvent?.relationType != - "m.replace" && - "${event.sender}${event.pmp?.id}" == - "${previousEvent?.sender}${previousEvent?.pmp?.id}", - ), - isHighlighted: - highlightedEvent.value == event.eventId, - ); - }, - ), - - SliverPadding( - padding: .only(bottom: composerSize.value), - ), - ], - ), - AsyncData() => nothing, - AsyncLoading() => Loading(), - AsyncError(:final error, :final stackTrace) => - ErrorDialog(error, stackTrace), - }, - ), - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: MeasureSize( - onChange: (size) => composerSize.value = size.height, - child: Composer( - roomId, - node: composerNode, - onSend: - (text, {required shouldMention, required tags}) => - notifier - .send( - text, - tags: tags, - relationType: relationType.value, - shouldMention: shouldMention, - relation: relatedEvent.value, - ) - .onError(showError), - relationType: relationType.value, - relatedEvent: relatedEvent.value, - onDismiss: () => relatedEvent.value = null, - ), - ), - ), - ], - ), - ), - - if (memberListOpened.value == true && showMembersByDefault) - MemberList(roomId), - ], - ), - ), - ), - ); - } -} diff --git a/lib/widgets/room_chat/chat_timeline.dart b/lib/widgets/room_chat/chat_timeline.dart new file mode 100644 index 0000000..254c782 --- /dev/null +++ b/lib/widgets/room_chat/chat_timeline.dart @@ -0,0 +1,65 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/hooks/chat_scroll.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/renderers/event.dart"; +import "package:nexus/widgets/highlight_wrapper.dart"; +import "package:super_sliver_list/super_sliver_list.dart"; + +class const ChatTimeline({ + required final String roomId, + required final ChatScroll scroll, + required final JumpToEvent jumpToEvent, + required final IList Function(Event) getEventOptions, + required final int? highlightedEvent, + required final double composerHeight, + super.key, +}) extends StatelessWidget { + Widget eventRow( + int eventRowId, + int? previousEventRowId, { + required Key key, + }) => HighlightWrapper( + EventRenderer( + eventRowId, + previousEventRowId: previousEventRowId, + roomId: roomId, + jumpToEvent: jumpToEvent, + getEventOptions: getEventOptions, + ), + key: key, + isHighlighted: highlightedEvent == eventRowId, + ); + + @override + Widget build(BuildContext context) => CustomScrollView( + reverse: true, + center: scroll.centerKey, + keyboardDismissBehavior: .onDrag, + controller: scroll.scrollController, + slivers: [ + SliverToBoxAdapter(child: SizedBox(height: composerHeight)), + + SuperSliverList.builder( + itemCount: scroll.liveRows.length, + itemBuilder: (_, index) => eventRow( + scroll.liveRows[index], + index > 0 + ? scroll.liveRows.getOrNull(index - 1) + : scroll.historyRows.firstOrNull, + key: scroll.keyFor(scroll.liveRows[index]), + ), + ), + + SuperSliverList.builder( + key: scroll.centerKey, + itemCount: scroll.historyRows.length, + itemBuilder: (_, index) => eventRow( + scroll.historyRows[index], + scroll.historyRows.getOrNull(index + 1), + key: scroll.keyFor(scroll.historyRows[index]), + ), + ), + ], + ); +} diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart new file mode 100644 index 0000000..a49e6f9 --- /dev/null +++ b/lib/widgets/room_chat/room_chat.dart @@ -0,0 +1,282 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:measure_size/measure_size.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/member_list_opened.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/helpers/hooks/chat_scroll.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/relation_type.dart"; +import "package:nexus/widgets/composer/composer.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/pinned_events_drawer.dart"; +import "package:nexus/widgets/member_list.dart"; +import "package:nexus/widgets/room_appbar.dart"; +import "package:nexus/main.dart"; +import "package:nexus/widgets/room_chat/chat_timeline.dart"; +import "package:nexus/helpers/extensions/build_event_options.dart"; +import "package:nexus/controllers/jump_request.dart"; + +final class const RoomChat({ + required final String? roomId, + required final bool isDesktop, + required final bool showMembersByDefault, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final relatedEvent = useState(null); + final relationType = useState(RelationType.reply); + final pendingJump = ref.read(JumpRequestController.provider); + final contextualEvent = useState( + pendingJump != null && pendingJump.roomId == this.roomId + ? pendingJump.rowId + : null, + ); + final highlightedEvent = useState(null); + + final composerSize = useState(64); + + final userId = ref.watch(ClientStateController.provider)?.userId; + final memberListOpened = ref + .watch(MemberListOpenedController.provider) + .requireValue; + final theme = Theme.of(context); + + final nothing = Center( + child: Text( + "Nothing to see here...", + style: theme.textTheme.headlineMedium, + ), + ); + if (userId == null || this.roomId == null) { + return Scaffold( + appBar: RoomAppbar( + roomId: this.roomId, + isDesktop: isDesktop, + onOpenDrawer: Scaffold.of(context).openDrawer, + ), + body: nothing, + ); + } + + final roomId = this.roomId!; + + final jumpRowId = ref.watch( + JumpRequestController.provider.select( + (request) => request?.roomId == roomId ? request?.rowId : null, + ), + ); + + final controllerProvider = RoomChatController.provider(( + roomId: roomId, + contextualEvent: contextualEvent.value, + )); + final notifier = ref.watch(controllerProvider.notifier); + + final client = ref.read(ClientController.provider.notifier); + + final controllerData = ref.watch(controllerProvider); + + final scroll = ChatScroll.use( + controllerData: controllerData, + paginate: notifier.paginate, + contextualEvent: contextualEvent, + markRead: () async { + final room = ref.read( + RoomsController.provider.select((rooms) => rooms[roomId]), + ); + + if (room != null) { + await client.markRead(room); + } + }, + ); + + final lastComposerHeight = useRef(composerSize.value); + + useEffect(() { + final delta = composerSize.value - lastComposerHeight.value; + lastComposerHeight.value = composerSize.value; + + if (delta != 0) { + final wasAtBottom = scroll.atBottom; + + WidgetsBinding.instance.addPostFrameCallback((_) { + final controller = scroll.scrollController; + if (!controller.hasClients) return; + + final position = controller.position; + controller.jumpTo( + wasAtBottom ? position.minScrollExtent : position.pixels - delta, + ); + }); + } + + return null; + }, [composerSize.value]); + + final composerNode = useFocusNode( + onKeyEvent: (_, event) { + if (event is KeyDownEvent && event.logicalKey == .escape) { + relatedEvent.value = null; + return .handled; + } + + return .ignored; + }, + ); + + Future jumpToEvent(int rowId) async { + highlightedEvent.value = rowId; + + await scroll.jumpToEvent(rowId); + await Future.delayed(.new(milliseconds: 700), () { + if (highlightedEvent.value == rowId) { + highlightedEvent.value = null; + } + }); + } + + useEffect(() { + if (jumpRowId == null) return null; + + void check() { + if (!context.mounted) return; + + if (scroll.scrollController.hasClients) { + jumpToEvent(jumpRowId); + Future.microtask( + () => ref.read(JumpRequestController.provider.notifier).consume(), + ); + } else { + WidgetsBinding.instance.addPostFrameCallback((_) => check()); + } + } + + check(); + + return null; + }, [jumpRowId]); + + IList getEventOptions(Event event) => + event.buildEventOptions( + context: context, + ref: ref, + roomId: roomId, + userId: userId, + onRelation: (rowId, type) { + relatedEvent.value = rowId; + relationType.value = type; + composerNode.requestFocus(); + }, + ); + + return Scaffold( + endDrawer: PinnedEventsDrawer( + roomId, + getEventOptions: getEventOptions, + jumpToEvent: jumpToEvent, + ), + body: Builder( + builder: (middleContext) => Scaffold( + endDrawer: showMembersByDefault ? null : MemberList(roomId), + appBar: RoomAppbar( + roomId: roomId, + isDesktop: isDesktop, + onOpenDrawer: Scaffold.of(context).openDrawer, + onOpenMemberList: (thisContext) { + ref + .read(MemberListOpenedController.provider.notifier) + .set(!memberListOpened); + Scaffold.of(thisContext).openEndDrawer(); + }, + onOpenPinnedMessagesList: Scaffold.of(middleContext).openEndDrawer, + ), + body: Row( + children: [ + Expanded( + child: Stack( + children: [ + Positioned.fill( + child: Padding( + padding: .symmetric(horizontal: 4), + child: switch (controllerData) { + AsyncLoading(value: _?) || + AsyncData(value: _) => ChatTimeline( + scroll: scroll, + roomId: roomId, + jumpToEvent: jumpToEvent, + getEventOptions: getEventOptions, + highlightedEvent: highlightedEvent.value, + composerHeight: composerSize.value, + ), + AsyncLoading _ => Loading(), + AsyncError(:final error, :final stackTrace) => + ErrorDialog(error, stackTrace), + }, + ), + ), + Positioned( + right: 16, + bottom: composerSize.value, + child: IgnorePointer( + ignoring: scroll.atBottom, + child: AnimatedOpacity( + opacity: scroll.atBottom ? 0 : 1, + duration: const Duration(milliseconds: 200), + child: FloatingActionButton.small( + onPressed: scroll.jumpToBottom, + child: const Icon(Icons.keyboard_arrow_down), + ), + ), + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: MeasureSize( + onChange: (size) => composerSize.value = size.height, + child: Composer( + roomId, + node: composerNode, + onSend: + (text, {required shouldMention, required tags}) => + notifier + .send( + text, + tags: tags, + relationType: relationType.value, + shouldMention: shouldMention, + relatedEventRowId: relatedEvent.value, + ) + .onError(showError), + relationType: relationType.value, + relatedEvent: relatedEvent.value, + onDismiss: () { + relatedEvent.value = null; + composerNode.requestFocus(); + }, + ), + ), + ), + ], + ), + ), + + if (memberListOpened == true && showMembersByDefault) + MemberList(roomId), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/room_list.dart b/lib/widgets/room_list.dart new file mode 100644 index 0000000..b4f07f5 --- /dev/null +++ b/lib/widgets/room_list.dart @@ -0,0 +1,318 @@ +import "dart:math"; + +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; +import "package:nexus/helpers/extensions/get_all_child_rooms.dart"; +import "package:nexus/models/nav_page.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/divider_widget.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/pages/settings.dart"; +import "package:nexus/widgets/join_dialog.dart"; +import "package:nexus/widgets/room_menu_button.dart"; +import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/spaces.dart"; +import "package:nexus/models/room.dart"; +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; + +// Needed for navigation_rail_m3e (#65). +import "package:flutter/material.dart" as old_mat; + +List roomDestinations( + IList rooms, { + required bool isDm, +}) => [ + for (final room in rooms) + .new( + label: room.metadata?.name ?? "Unnamed Room", + badgeCount: switch (room.metadata?.unreadNotifications) { + 0 || null => room.metadata?.unreadMessages == 0 ? null : 0, + int unread => unread, + }, + icon: AvatarOrHash( + room.metadata?.avatar, + room.metadata?.name ?? "Unnamed Room", + fallback: isDm ? null : const Icon(Icons.numbers), + ), + ), +]; + +class const RoomList({super.key}) extends ConsumerWidget implements NavPage { + @override + String get title => "Rooms"; + + @override + IconData get icon => Icons.list; + + // TODO: Show unread badge here and on sidebar hamburger + @override + Future? badgeBuilder(WidgetRef ref) => null; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final spacesAsync = ref.watch(SpacesController.provider); + if (spacesAsync.value == null) { + return switch (spacesAsync) { + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + _ => const Center(child: CircularProgressIndicator()), + }; + } + + return const Row( + children: [ + SpacesRail(), + Expanded(child: RoomsPane()), + ], + ); + } +} + +class const SpacesRail({super.key}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final spaces = ref.watch( + SpacesController.provider.select((async) => async.requireValue), + ); + final selectedSpaceId = ref.watch( + KeyController.provider(KeyController.spaceKey) + .select((async) => async.requireValue), + ); + + return MaterialUiCompatibilityBridge( + child: Builder( + builder: (context) => old_mat.Theme( + data: old_mat.Theme.of(context).copyWith( + extensions: [ + NavigationRailM3ETheme( + itemCollapsedHeight: 48, + itemVerticalGap: 0, + ), + ], + ), + child: Container( + color: NavigationRailTokensAdapter(context).containerColor, + padding: .only(top: 16), + child: NavigationRailM3E( + type: .alwaysCollapse, + labelBehavior: .alwaysHide, + scrollable: true, + onDestinationSelected: (value) { + ref + .read( + KeyController.provider(KeyController.spaceKey).notifier, + ) + .set(spaces[value].id); + ref + .read( + KeyController.provider(KeyController.roomKey).notifier, + ) + .set(spaces[value].children.firstOrNull?.metadata?.id); + }, + sections: [ + .new( + destinations: spaces.map((space) { + final notifications = space.allChildRooms.fold( + 0, + (sum, room) => + sum + (room.metadata?.unreadNotifications ?? 0), + ); + + return NavigationRailM3EDestination( + badgeCount: notifications > 0 + ? notifications + : space.allChildRooms.any( + (room) => room.metadata?.unreadMessages != 0, + ) + ? 0 + : null, + short: true, + icon: AvatarOrHash( + dimension: 28, + space.room?.metadata?.avatar, + fallback: space.icon == null ? null : Icon(space.icon), + space.title, + ), + label: space.title, + ); + }).toList(), + ), + ], + selectedIndex: max( + 0, + spaces.indexWhere((space) => space.id == selectedSpaceId), + ), + trailingAtBottom: true, + trailing: Padding( + padding: .only(top: 8), + child: Column( + children: [ + PopupMenuButton( + itemBuilder: (_) => [ + PopupMenuItem( + onTap: () => showDialog( + context: context, + builder: (_) => JoinDialog(ref), + ), + child: const ListTile( + title: Text("Join an existing room (or space)"), + leading: Icon(Icons.numbers), + ), + ), + const PopupMenuItem( + onTap: null, + child: ListTile( + title: Text("Create a new room"), + leading: Icon(Icons.add), + ), + ), + ], + icon: const Icon(Icons.add), + ), + const IconButton( + tooltip: "Explore other rooms", + onPressed: null, + icon: Icon(Icons.explore), + ), + IconButton( + tooltip: "Open settings", + onPressed: () => showDialog( + context: context, + builder: (_) => SettingsPage(), + ), + icon: const Icon(Icons.settings), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class const RoomsPane({super.key}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedSpaceId = ref.watch( + KeyController.provider(KeyController.spaceKey) + .select((async) => async.requireValue), + ); + final selectedRoomId = ref.watch( + KeyController.provider(KeyController.roomKey) + .select((async) => async.requireValue), + ); + final selectedSpace = ref.watch( + SpacesController.provider.select((async) { + final spaces = async.requireValue; + return spaces.firstWhereOrNull( + (space) => space.id == selectedSpaceId, + ) ?? + spaces.first; + }), + ); + final isDm = selectedSpace.id == "dms"; + + return Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + leading: AvatarOrHash( + selectedSpace.room?.metadata?.avatar, + fallback: selectedSpace.icon == null + ? null + : Icon(selectedSpace.icon), + selectedSpace.title, + ), + title: Text(selectedSpace.title, overflow: .ellipsis), + backgroundColor: Colors.transparent, + actions: [ + RoomMenuButton( + selectedSpace.room, + children: selectedSpace.allChildRooms, + ), + ], + ), + body: MaterialUiCompatibilityBridge( + child: Builder( + builder: (context) => old_mat.Theme( + data: old_mat.Theme.of(context).copyWith( + extensions: [ + NavigationRailM3ETheme( + itemExpandedHeight: 48, + iconLabelGap: 16, + ), + ], + ), + child: NavigationRailM3E( + expandedWidth: double.infinity, + scrollable: true, + background: Colors.transparent, + type: .alwaysExpand, + selectedIndex: max( + 0, + selectedSpace.allChildRooms.indexWhere( + (room) => room.metadata?.id == selectedRoomId, + ), + ), + sections: [ + .new( + header: selectedSpace.room == null + ? null + : const DividerWidget(Text("Rooms")), + destinations: roomDestinations( + selectedSpace.children, + isDm: isDm, + ), + ), + for (final subSpace in selectedSpace.subSpaces) + .new( + header: DividerWidget( + Row( + mainAxisSize: .min, + spacing: 8, + children: [ + if (subSpace.room.metadata?.avatar != null) + AvatarOrHash( + subSpace.room.metadata?.avatar, + subSpace.room.metadata?.name ?? "Unnamed Room", + dimension: 16, + ), + Flexible( + child: Text( + subSpace.room.metadata?.name ?? "Unnamed Space", + maxLines: 1, + overflow: .ellipsis, + ), + ), + ], + ), + ), + destinations: roomDestinations( + subSpace.children, + isDm: isDm, + ), + ), + ], + onDestinationSelected: (value) { + ref + .read( + KeyController.provider(KeyController.roomKey).notifier, + ) + .set(selectedSpace.allChildRooms[value].metadata?.id); + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + }, + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/room_menu.dart b/lib/widgets/room_menu_button.dart similarity index 50% rename from lib/widgets/room_menu.dart rename to lib/widgets/room_menu_button.dart index 95936df..7e6516d 100644 --- a/lib/widgets/room_menu.dart +++ b/lib/widgets/room_menu_button.dart @@ -1,20 +1,22 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter/services.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/via.dart"; import "package:nexus/models/room.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:nexus/main.dart"; -class RoomMenu extends ConsumerWidget { - final Room? room; - final IList children; - const RoomMenu(this.room, {this.children = const IList.empty(), super.key}); - +final class const RoomMenuButton( + final Room? room, { + final IList children = const IList.empty(), + super.key, +}) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final danger = Theme.of(context).colorScheme.error; - final client = ref.watch(ClientController.provider.notifier); + final client = ref.read(ClientController.provider.notifier); return PopupMenuButton( itemBuilder: (_) => [ @@ -35,8 +37,7 @@ class RoomMenu extends ConsumerWidget { await Clipboard.setData( .new( - text: - "matrix:roomid/${room!.metadata?.id.substring(1)}$vias)", + text: "matrix:roomid/${room!.metadata?.id.substring(1)}$vias", ), ); }, @@ -45,6 +46,63 @@ class RoomMenu extends ConsumerWidget { title: Text("Copy Link"), ), ), + PopupMenuItem( + onTap: () => showDialog( + context: context, + builder: (context) => HookBuilder( + builder: (context) { + final userIdController = useTextEditingController(); + final text = useValueListenable(userIdController).text.trim(); + final valid = RegExp(r"^@[^:\s]+:[^\s]+$").hasMatch(text); + + void submit() { + if (!valid) return; + Navigator.of(context).pop(); + client + .setMembership( + .new( + userId: text, + roomId: room!.metadata!.id, + action: .invite, + ), + ) + .onError(showError); + } + + return AlertDialog( + title: Text("Invite User"), + content: TextField( + controller: userIdController, + autofocus: true, + keyboardType: .emailAddress, + onSubmitted: (_) => submit(), + decoration: .new( + labelText: "User ID", + hintText: "@user:example.com", + errorText: text.isEmpty || valid + ? null + : "Enter a valid user ID", + ), + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton( + onPressed: valid ? submit : null, + child: Text("Invite"), + ), + ], + ); + }, + ), + ), + child: ListTile( + leading: Icon(Icons.person_add), + title: Text("Invite"), + ), + ), PopupMenuItem( onTap: () => showDialog( context: context, @@ -82,54 +140,6 @@ class RoomMenu extends ConsumerWidget { ), ), ], - - // PopupMenuItem( - // onTap: () => showDialog( - // context: context, - // builder: (context) => HookBuilder( - // builder: (_) { - // final reasonController = useTextEditingController(); - // return AlertDialog( - // title: Text("Report"), - // content: Column( - // mainAxisSize: MainAxisSize.min, - // crossAxisAlignment: CrossAxisAlignment.start, - // children: [ - // Text( - // "Report this room to your server administrators, who can take action like banning this room.", - // ), - - // SizedBox(height: 12), - // FormTextInput( - // required: false, - // capitalize: true, - // controller: reasonController, - // title: "Reason for report (optional)", - // ), - // ], - // ), - // actions: [ - // TextButton( - // onPressed: Navigator.of(context).pop, - // child: Text("Cancel"), - // ), - // TextButton( - // onPressed: () { - // room.client.reportRoom(room.id, reasonController.text); - // Navigator.of(context).pop(); - // }, - // child: Text("Report"), - // ), - // ], - // ); - // }, - // ), - // ), - // child: ListTile( - // leading: Icon(Icons.report, color: danger), - // title: Text("Report", style: TextStyle(color: danger)), - // ), - // ), ], ); } diff --git a/lib/widgets/room_summary_dialog.dart b/lib/widgets/room_summary_dialog.dart new file mode 100644 index 0000000..496e72f --- /dev/null +++ b/lib/widgets/room_summary_dialog.dart @@ -0,0 +1,122 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/helpers/extensions/focus_room.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/expandable_image.dart"; +import "package:nexus/widgets/linkified_text.dart"; +import "package:nexus/models/room_summary.dart"; + +class const RoomSummaryDialog( + final RoomSummary summary, { + final IList? via, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) => AlertDialog( + constraints: .loose(.fromWidth(400)), + scrollable: true, + contentPadding: EdgeInsets.all(24).copyWith(top: 8), + title: Row( + spacing: 12, + mainAxisSize: .min, + children: [ + if (summary.avatarUrl != null) + ExpandableImage( + summary.avatarUrl == null ? null : .new(mxc: summary.avatarUrl!), + child: AvatarOrHash( + summary.avatarUrl, + "", + dimension: 64, + fallback: Icon(Icons.numbers), + ), + ), + Expanded( + child: Text( + summary.name ?? summary.canonicalAlias ?? "Unnamed Room", + overflow: .ellipsis, + maxLines: 3, + ), + ), + ], + ), + content: Column( + children: [ + ListTile( + title: Text( + "${summary.joinedMembers ?? "Unknown number of"} members", + ), + leading: Icon(Icons.people), + ), + ListTile( + title: SelectableText(summary.canonicalAlias ?? summary.roomId), + leading: Icon(Icons.numbers), + ), + + if (summary.topic != null) + ListTile( + titleAlignment: ListTileTitleAlignment.titleHeight, + title: LinkifiedText( + summary.topic!, + style: Theme.of(context).textTheme.bodyLarge, + ), + leading: Icon(Icons.info), + ), + ], + ), + + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + if (via != null) + TextButton( + onPressed: () async { + // Capture everything before the dialog closes. + final container = ProviderScope.containerOf(context); + final scaffoldMessenger = ScaffoldMessenger.of(context); + final colors = Theme.of(context).colorScheme; + Navigator.of(context).pop(); + + final roomIdOrAlias = summary.canonicalAlias ?? summary.roomId; + + final snackbar = scaffoldMessenger.showSnackBar( + .new( + content: Text("Joining room $roomIdOrAlias."), + duration: Duration(days: 999), + ), + ); + + try { + final id = await container + .read(ClientController.provider.notifier) + .joinRoom(.new(roomIdOrAlias: summary.roomId, via: via!)); + + snackbar.close(); + + scaffoldMessenger.showSnackBar( + .new( + content: Text("Room $roomIdOrAlias successfully joined."), + action: .new( + label: "Open", + onPressed: () => container.focusRoom(id), + ), + ), + ); + } catch (error) { + snackbar.close(); + scaffoldMessenger.showSnackBar( + .new( + backgroundColor: colors.errorContainer, + content: Text( + error.toString(), + style: .new(color: colors.onErrorContainer), + ), + ), + ); + } + }, + child: Text("Join"), + ), + ], + ); +} diff --git a/lib/widgets/settings/dialog_list_tile.dart b/lib/widgets/settings/dialog_list_tile.dart index f0a236c..e66e335 100644 --- a/lib/widgets/settings/dialog_list_tile.dart +++ b/lib/widgets/settings/dialog_list_tile.dart @@ -1,28 +1,18 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/widgets/settings/radio_dialog.dart"; -class DialogListTile extends ConsumerWidget { - final T? initialValue; - final String title; - final Widget? subtitle; - final List options; - final bool required; - final Icon icon; - final void Function(T value)? onChanged; - final String Function(T option) getName; - const DialogListTile({ - super.key, - required this.icon, - required this.title, - required this.initialValue, - required this.options, - required this.onChanged, - required this.getName, - this.subtitle, - this.required = true, - }); - +final class const DialogListTile({ + required final T? initialValue, + required final Icon icon, + required final String title, + required final List options, + required final void Function(T value)? onChanged, + required final String Function(T option) getName, + final Widget? subtitle, + final bool required = true, + super.key, +}) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) => FormField( validator: (value) => diff --git a/lib/widgets/settings/radio_dialog.dart b/lib/widgets/settings/radio_dialog.dart index 7695b9d..6696139 100644 --- a/lib/widgets/settings/radio_dialog.dart +++ b/lib/widgets/settings/radio_dialog.dart @@ -1,21 +1,14 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; -class RadioDialog extends HookWidget { - final T? value; - final String title; - final List options; - final void Function(T value)? onChanged; - final String Function(T option) getName; - const RadioDialog({ - super.key, - required this.title, - required this.value, - required this.options, - required this.onChanged, - required this.getName, - }); - +final class const RadioDialog({ + required final T? value, + required final String title, + required final List options, + required final void Function(T value)? onChanged, + required final String Function(T option) getName, + super.key, +}) extends HookWidget { @override Widget build(BuildContext context) { final mutValue = useState(null); diff --git a/lib/widgets/sidebar.dart b/lib/widgets/sidebar.dart index 1bd6cf5..2d1c728 100644 --- a/lib/widgets/sidebar.dart +++ b/lib/widgets/sidebar.dart @@ -1,290 +1,34 @@ -import "package:collection/collection.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; -import "package:nexus/controllers/key.dart"; -import "package:nexus/controllers/spaces.dart"; -import "package:nexus/models/room.dart"; -import "package:nexus/pages/settings.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/divider_widget.dart"; -import "package:nexus/widgets/join_dialog.dart"; -import "package:nexus/widgets/room_menu.dart"; - -class Sidebar extends HookConsumerWidget { - final bool isDesktop; - const Sidebar({required this.isDesktop, super.key}); +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/nav_page.dart"; +import "package:nexus/widgets/invites_list.dart"; +import "package:nexus/widgets/notifications_list.dart"; +import "package:nexus/widgets/room_list.dart"; +class const Sidebar({super.key}) extends HookWidget { @override - Widget build(BuildContext context, WidgetRef ref) { - final selectedSpaceProvider = KeyController.provider( - KeyController.spaceKey, - ); - final selectedSpaceId = ref.watch(selectedSpaceProvider); - final selectedSpaceIdNotifier = ref.watch(selectedSpaceProvider.notifier); - - final selectedRoomController = KeyController.provider( - KeyController.roomKey, - ); - final selectedRoomId = ref.watch(selectedRoomController); - final selectedRoomIdNotifier = ref.watch(selectedRoomController.notifier); - - final spaces = ref.watch(SpacesController.provider); - final indexOfSelected = spaces.indexWhere( - (space) => space.id == selectedSpaceId, - ); - final selectedIndex = indexOfSelected == -1 ? 0 : indexOfSelected; - - final selectedSpace = - spaces.firstWhereOrNull((space) => space.id == selectedSpaceId) ?? - spaces.first; - - final indexOfSelectedRoom = selectedSpace.children - .addAll( - selectedSpace.subSpaces.map((element) => element.children).flattened, - ) - .indexWhere((room) => room.metadata?.id == selectedRoomId); - final selectedRoomIndex = indexOfSelectedRoom == -1 - ? null - : indexOfSelectedRoom; - - List roomsToDestinations(IList rooms) => - rooms - .map( - (room) => NavigationRailM3EDestination( - label: room.metadata?.name ?? "Unnamed Room", - badgeCount: switch (room.metadata?.unreadNotifications) { - 0 || null => room.metadata?.unreadMessages == 0 ? null : 0, - int unread => unread, - }, - icon: AvatarOrHash( - room.metadata?.avatar, - room.metadata?.name ?? "Unnamed Room", - fallback: selectedSpaceId == "dms" - ? null - : Icon(Icons.numbers), - ), - ), - ) - .toList(); + Widget build(BuildContext context) { + final selectedIndex = useState(0); + const pages = [RoomList(), NotificationsList(), InvitesList()]; return Drawer( width: 330, shape: Border(), - child: Row( - children: [ - Theme( - data: Theme.of(context).copyWith( - extensions: [ - NavigationRailM3ETheme( - itemCollapsedHeight: 48, - itemVerticalGap: 0, + child: Scaffold( + backgroundColor: Colors.transparent, + body: pages[selectedIndex.value], + bottomNavigationBar: NavigationBar( + selectedIndex: selectedIndex.value, + destinations: pages + .map( + (element) => NavigationDestination( + icon: Icon(element.icon), + label: element.title, ), - ], - ), - child: Container( - color: NavigationRailTokensAdapter(context).containerColor, - padding: EdgeInsets.only(top: 16), - child: NavigationRailM3E( - type: .alwaysCollapse, - labelBehavior: .alwaysHide, - scrollable: true, - onDestinationSelected: (value) { - selectedSpaceIdNotifier.set(spaces[value].id); - selectedRoomIdNotifier.set( - spaces[value].children.firstOrNull?.metadata?.id, - ); - }, - sections: [ - .new( - destinations: spaces - .map( - (space) => NavigationRailM3EDestination( - badgeCount: switch (space.children - .addAll( - space.subSpaces - .map((element) => element.children) - .flattened, - ) - .fold( - 0, - (previousValue, room) => - previousValue + - (room.metadata?.unreadNotifications ?? 0), - )) { - 0 => - space.children - .addAll( - space.subSpaces - .map( - (element) => element.children, - ) - .flattened, - ) - .any( - (room) => - room.metadata?.unreadMessages != - 0, - ) - ? 0 - : null, - int badgeCount => badgeCount, - }, - short: true, - icon: AvatarOrHash( - height: 28, - space.room?.metadata?.avatar, - fallback: space.icon == null - ? null - : Icon(space.icon), - space.title, - ), - label: space.title, - ), - ) - .toList(), - ), - ], - selectedIndex: selectedIndex, - trailingAtBottom: true, - trailing: Padding( - padding: .symmetric(vertical: 16), - child: Column( - spacing: 8, - children: [ - PopupMenuButton( - itemBuilder: (_) => [ - PopupMenuItem( - onTap: () => showDialog( - context: context, - builder: (_) => JoinDialog(ref), - ), - child: ListTile( - title: Text("Join an existing room (or space)"), - leading: Icon(Icons.numbers), - ), - ), - PopupMenuItem( - onTap: null, - child: ListTile( - title: Text("Create a new room"), - leading: Icon(Icons.add), - ), - ), - ], - icon: Icon(Icons.add), - ), - IconButton( - tooltip: "Explore other rooms", - onPressed: null, - icon: Icon(Icons.explore), - ), - IconButton( - tooltip: "Open settings", - onPressed: () => showDialog( - context: context, - builder: (_) => SettingsPage(), - ), - icon: Icon(Icons.settings), - ), - ], - ), - ), - ), - ), - ), - Expanded( - child: Scaffold( - backgroundColor: Colors.transparent, - appBar: AppBar( - leading: AvatarOrHash( - selectedSpace.room?.metadata?.avatar, - fallback: selectedSpace.icon == null - ? null - : Icon(selectedSpace.icon), - - selectedSpace.title, - ), - title: Text(selectedSpace.title, overflow: .ellipsis), - backgroundColor: Colors.transparent, - actions: [ - RoomMenu( - selectedSpace.room, - children: selectedSpace.children.addAll( - selectedSpace.subSpaces - .map((element) => element.children) - .flattened, - ), - ), - ], - ), - body: Theme( - data: Theme.of(context).copyWith( - extensions: [ - NavigationRailM3ETheme( - itemExpandedHeight: 48, - iconLabelGap: 16, - ), - ], - ), - child: NavigationRailM3E( - expandedWidth: double.infinity, - scrollable: true, - background: Colors.transparent, - type: .alwaysExpand, - selectedIndex: selectedRoomIndex ?? 0, - sections: [ - .new( - header: selectedSpace.room == null - ? null - : DividerWidget(Text("Rooms")), - destinations: roomsToDestinations(selectedSpace.children), - ), - for (final subSpace in selectedSpace.subSpaces) - .new( - header: DividerWidget( - Row( - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - if (subSpace.room.metadata?.avatar != null) - AvatarOrHash( - subSpace.room.metadata?.avatar, - subSpace.room.metadata?.name ?? - "Unnamed Room", - height: 16, - ), - Flexible( - child: Text( - subSpace.room.metadata?.name ?? - "Unnamed Space", - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - destinations: roomsToDestinations(subSpace.children), - ), - ], - onDestinationSelected: (value) { - final children = selectedSpace.children.addAll( - selectedSpace.subSpaces - .map((element) => element.children) - .flattened, - ); - selectedRoomIdNotifier.set( - children[value].metadata?.id, // - ); - if (!isDesktop) Navigator.of(context).pop(); - }, - ), - ), - ), - ), - ], + ) + .toList(), + onDestinationSelected: (value) => selectedIndex.value = value, + ), ), ); } diff --git a/lib/widgets/timestamp.dart b/lib/widgets/timestamp.dart new file mode 100644 index 0000000..bcdabd1 --- /dev/null +++ b/lib/widgets/timestamp.dart @@ -0,0 +1,17 @@ +import "package:flutter/material.dart"; +import "package:timeago/timeago.dart"; + +class const Timestamp(final DateTime timestamp, {super.key}) + extends StatelessWidget { + @override + Widget build(BuildContext context) => Tooltip( + message: timestamp.toString(), + child: Text( + format(timestamp), + maxLines: 1, + overflow: .ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: Colors.grey), + ), + ); +} diff --git a/lib/widgets/url_preview.dart b/lib/widgets/url_preview.dart index 5831300..5eda427 100644 --- a/lib/widgets/url_preview.dart +++ b/lib/widgets/url_preview.dart @@ -1,14 +1,11 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/url_preview.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/helpers/mxc_image.dart"; -class UrlPreview extends ConsumerWidget { - final String link; - const UrlPreview(this.link, {super.key}); - +class const UrlPreview(final Uri link, {super.key}) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) => ConstrainedBox( constraints: .loose(.fromWidth(400)), @@ -18,13 +15,12 @@ class UrlPreview extends ConsumerWidget { data: (preview) => preview == null ? SizedBox.shrink() : InkWell( - onTap: () => - ref.watch(LaunchHelper.provider).launchUrl(.parse(link)), + onTap: () => ref.watch(LaunchHelper.provider).launchUrl(link), child: Card( margin: .symmetric(vertical: 4), - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, child: Padding( padding: .all(16), child: Column( @@ -35,9 +31,15 @@ class UrlPreview extends ConsumerWidget { Text( preview.title!, style: Theme.of(context).textTheme.titleLarge, + maxLines: 3, + overflow: .ellipsis, ), if (preview.description != null) ...[ - Text(preview.description!), + Text( + preview.description!, + maxLines: 20, + overflow: .ellipsis, + ), SizedBox(height: 4), ], if (preview.imageUrl != null) diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart index 6f9f154..d7cc55b 100644 --- a/lib/widgets/user_bottom_sheet.dart +++ b/lib/widgets/user_bottom_sheet.dart @@ -1,5 +1,5 @@ import "package:collection/collection.dart"; -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:intl/intl.dart"; @@ -16,17 +16,17 @@ import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/main.dart"; import "package:nexus/widgets/expandable_image.dart"; -class UserBottomSheet extends ConsumerWidget { - final MembershipContent member; - final String userId; - final String? roomId; - const UserBottomSheet(this.member, this.userId, {this.roomId, super.key}); - +final class const UserBottomSheet( + final MembershipContent member, + final String userId, { + final String? roomId, + super.key, +}) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final textTheme = theme.textTheme; - final client = ref.watch(ClientController.provider.notifier); + final client = ref.read(ClientController.provider.notifier); void showMembershipDialog(MembershipAction action) => showDialog( context: context, @@ -77,178 +77,180 @@ class UserBottomSheet extends ConsumerWidget { ), ); - return Padding( - padding: .all(42), - child: Column( - spacing: 4, - mainAxisSize: .min, - crossAxisAlignment: .center, - children: [ - Row( - mainAxisAlignment: .end, - children: [ - M3EButton( - onPressed: Navigator.of(context).pop, - child: Icon(Icons.close), - ), - ], - ), - SizedBox(height: 18), - - ExpandableImage( - member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!), - child: AvatarOrHash( - member.avatarUrl, - member.displayName ?? userId.localpart, - height: 200, - ), - ), - - SizedBox(height: 8), - - SelectableText( - member.displayName ?? userId.localpart, - style: textTheme.headlineLarge, - textAlign: .center, - ), - SelectableText( - userId, - textAlign: .center, - style: textTheme.titleSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - - ref - .watch(ProfileController.provider(userId)) - .betterWhen( - loading: () => Text(""), - data: (profile) => Column( - children: [ - if (profile.timezone == null && profile.pronouns.isEmpty) - Text(""), - Wrap( - crossAxisAlignment: .center, - alignment: .center, - spacing: 4, - runSpacing: 4, - children: [ - ...profile.pronouns - .where( - // TODO: Check system language (l10n) - (pronoun) => pronoun.language == "en", - ) - .mapIndexed( - (index, pronoun) => [ - if (index != 0) - Icon( - Icons.circle, - size: 4, - color: theme.colorScheme.onSurfaceVariant, - ), - Text( - pronoun.summary, - textAlign: .center, - style: textTheme.titleSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ) - .flattened, - - if (profile.timezone != null) ...[ - if (profile.pronouns.isNotEmpty) - SizedBox( - height: 16, - child: VerticalDivider( - thickness: 1.5, - width: 4, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - Text( - profile.timezone!, - textAlign: .center, - style: textTheme.titleSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ], - ), - ], - ), - ), - - SizedBox(height: 8), - if (userId != ref.watch(ClientStateController.provider)?.userId && - roomId != null) ...[ + return SingleChildScrollView( + child: Padding( + padding: .all(42), + child: Column( + spacing: 4, + mainAxisSize: .min, + crossAxisAlignment: .center, + children: [ Row( + mainAxisAlignment: .end, children: [ - Expanded( - child: M3EButton.icon( - onPressed: null, - shape: .square, - style: .tonal, - icon: Icon(Icons.message), - label: Text("Message"), - ), + M3EButton( + onPressed: Navigator.of(context).pop, + child: Icon(Icons.close), ), ], ), + SizedBox(height: 18), - if (ref.watch( - PowerLevelController.provider( - .membershipAction( - action: .kick, - roomId: roomId!, - targetUser: userId, - ), - ), - ) && - member.status == .join || - member.status == .invite) - Padding( - padding: .only(top: 4), - child: Row( - mainAxisSize: MainAxisSize.max, - spacing: 8, - children: [ - M3EButton.icon( - onPressed: () => showMembershipDialog(.kick), - shape: .square, - icon: Icon(Icons.sports_martial_arts), - label: Text("Kick"), - decoration: .new( - backgroundColor: WidgetStatePropertyAll( - theme.colorScheme.error, - ), - foregroundColor: WidgetStatePropertyAll( - theme.colorScheme.onError, - ), - ), - ), - - M3EButton.icon( - onPressed: () => showMembershipDialog(.ban), - shape: .square, - icon: Icon(Icons.gavel), - label: Text("Ban"), - decoration: .new( - backgroundColor: WidgetStatePropertyAll( - theme.colorScheme.errorContainer, - ), - foregroundColor: WidgetStatePropertyAll( - theme.colorScheme.onErrorContainer, - ), - ), - ), - ].map((e) => Expanded(child: e)).toList(), - ), + ExpandableImage( + member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!), + child: AvatarOrHash( + member.avatarUrl, + member.displayName ?? userId.localpart, + dimension: 200, ), + ), + + SizedBox(height: 8), + + SelectableText( + member.displayName ?? userId.localpart, + style: textTheme.headlineLarge, + textAlign: .center, + ), + SelectableText( + userId, + textAlign: .center, + style: textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + + ref + .watch(ProfileController.provider(userId)) + .betterWhen( + loading: () => Text(""), + data: (profileResponse) => Column( + children: [ + if (profileResponse.profile.timezone == null && + profileResponse.profile.pronouns.isEmpty) + Text(""), + Wrap( + crossAxisAlignment: .center, + alignment: .center, + spacing: 4, + runSpacing: 4, + children: [ + ...profileResponse.profile.pronouns + .where( + // TODO: Check system language (l10n) + (pronoun) => pronoun.language == "en", + ) + .mapIndexed( + (index, pronoun) => [ + if (index != 0) + Icon( + Icons.circle, + size: 4, + color: theme.colorScheme.onSurfaceVariant, + ), + Text( + pronoun.summary, + textAlign: .center, + style: textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ) + .flattened, + + if (profileResponse.profile.timezone != null) ...[ + if (profileResponse.profile.pronouns.isNotEmpty) + SizedBox( + height: 16, + child: VerticalDivider( + thickness: 1.5, + width: 4, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Text( + profileResponse.profile.timezone!, + textAlign: .center, + style: textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ], + ), + ), + + SizedBox(height: 8), + if (userId != ref.watch(ClientStateController.provider)?.userId && + roomId != null) ...[ + Row( + children: [ + Expanded( + child: M3EButton.icon( + onPressed: null, + shape: .square, + style: .tonal, + icon: Icon(Icons.message), + label: Text("Message"), + ), + ), + ], + ), + + if (ref.watch( + PowerLevelController.provider( + .membershipAction( + action: .kick, + roomId: roomId!, + targetUser: userId, + ), + ), + )) + Padding( + padding: .only(top: 4), + child: Row( + mainAxisSize: MainAxisSize.max, + spacing: 8, + children: [ + if (member.status == .join || member.status == .invite) + M3EButton.icon( + onPressed: () => showMembershipDialog(.kick), + shape: .square, + icon: Icon(Icons.sports_martial_arts), + label: Text("Kick"), + decoration: .new( + backgroundColor: WidgetStatePropertyAll( + theme.colorScheme.error, + ), + foregroundColor: WidgetStatePropertyAll( + theme.colorScheme.onError, + ), + ), + ), + + M3EButton.icon( + onPressed: () => showMembershipDialog(.ban), + shape: .square, + icon: Icon(Icons.gavel), + label: Text("Ban"), + decoration: .new( + backgroundColor: WidgetStatePropertyAll( + theme.colorScheme.errorContainer, + ), + foregroundColor: WidgetStatePropertyAll( + theme.colorScheme.onErrorContainer, + ), + ), + ), + ].map((e) => Expanded(child: e)).toList(), + ), + ), + ], ], - ], + ), ), ); } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 603ea6a..70d9ba2 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -13,6 +13,7 @@ #include #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -37,6 +38,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) webcrypto_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WebcryptoPlugin"); + webcrypto_plugin_register_with_registrar(webcrypto_registrar); g_autoptr(FlPluginRegistrar) window_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); window_manager_plugin_register_with_registrar(window_manager_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 7d8f046..1d53ad7 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_video screen_retriever_linux url_launcher_linux + webcrypto window_manager ) diff --git a/linux/nexus.federated.nexus.UnifiedPush.service b/linux/nexus.federated.nexus.UnifiedPush.service new file mode 100644 index 0000000..663ab9b --- /dev/null +++ b/linux/nexus.federated.nexus.UnifiedPush.service @@ -0,0 +1,3 @@ +[D-BUS Service] +Name=nexus.federated.nexus.UnifiedPush +Exec=/usr/bin/env FLUTTER_HEADLESS=1 nexus diff --git a/linux/nexus.federated.nexus.desktop b/linux/nexus.federated.nexus.desktop index d61734e..64b4264 100644 --- a/linux/nexus.federated.nexus.desktop +++ b/linux/nexus.federated.nexus.desktop @@ -2,9 +2,9 @@ Name=Nexus GenericName=Matrix Client Comment=A simple and user-friendly Matrix client -Exec=nexus %u +Exec=nexus Icon=nexus Terminal=false Type=Application Categories=Chat;Network;InstantMessaging; -MimeType=x-scheme-handler/nexus.federated.nexus; \ No newline at end of file +DBusActivatable=true \ No newline at end of file diff --git a/linux/nexus.federated.nexus.service b/linux/nexus.federated.nexus.service new file mode 100644 index 0000000..1757c2c --- /dev/null +++ b/linux/nexus.federated.nexus.service @@ -0,0 +1,3 @@ +[D-BUS Service] +Name=nexus.federated.nexus +Exec=/usr/bin/env nexus diff --git a/linux/nexus.federated.nexus.uri.desktop b/linux/nexus.federated.nexus.uri.desktop new file mode 100644 index 0000000..10f495f --- /dev/null +++ b/linux/nexus.federated.nexus.uri.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Name=Nexus URI Handler +Exec=nexus %u +Icon=nexus +Terminal=false +Type=Application +NoDisplay=true +MimeType=x-scheme-handler/nexus.federated.nexus; \ No newline at end of file diff --git a/linux/nix/default.nix b/linux/nix/default.nix new file mode 100644 index 0000000..3c0a301 --- /dev/null +++ b/linux/nix/default.nix @@ -0,0 +1,60 @@ +{ self, inputs, ... }: { + + perSystem = + { + pkgs, + system, + lib, + ... + }: + { + _module.args.pkgs = import inputs.nixpkgs { + inherit system; + config = { + android_sdk.accept_license = true; + allowUnfree = true; + }; + }; + + packages = + let + default = pkgs.callPackage ./pkg { + src = self; + commit = self.shortRev or self.dirtyShortRev; + }; + in + { + inherit default; + + flatpak = inputs.nix2flatpak.lib.${system}.mkFlatpak { + appName = "Nexus"; + developer = "QuadRadical"; + appId = "nexus.federated.nexus"; + package = default; + runtime = "org.gnome.Platform/51"; + permissions = { + share = [ "network" ]; + sockets = [ + "pulseaudio" + "fallback-x11" + "wayland" + ]; + + talk-names = [ + "org.unifiedpush.Distributor.*" + "org.freedesktop.Notifications" + ]; + devices = [ "dri" ]; + }; + }; + + gomuks = pkgs.callPackage ./pkg/gomuks.nix { + src = self; + }; + }; + + devShells.default = pkgs.callPackage ./devshell.nix { }; + }; + + flake.nixosModules.default = import ./module.nix self; +} diff --git a/linux/nix/devshell.nix b/linux/nix/devshell.nix index ae77467..8bc8641 100644 --- a/linux/nix/devshell.nix +++ b/linux/nix/devshell.nix @@ -2,7 +2,7 @@ let android = pkgs.androidenv.composeAndroidPackages { toolsVersion = "26.1.1"; - platformToolsVersion = "36.0.1"; + platformToolsVersion = "37.0.1"; buildToolsVersions = [ "35.0.0" "36.0.0" @@ -18,31 +18,33 @@ let }; in pkgs.mkShell { - packages = with pkgs; [ - go - git - jdk17 - libGL - wayland - (flutter.override { - extraPkgConfigPackages = [ - mpv-unwrapped - libass - ]; - }) - android.platform-tools - ]; + packages = + with pkgs; + [ + go + git + + libGL + (flutter.override { + extraPkgConfigPackages = [ + mpv-unwrapped + libass + ]; + }) + android.platform-tools + ] + ++ lib.optional pkgs.stdenv.isLinux wayland; env = rec { LIBCLANG_PATH = lib.makeLibraryPath [ pkgs.libclang ]; - LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}"; - CPATH = lib.makeSearchPath "include" [ pkgs.glibc.dev ]; ANDROID_HOME = "${android.androidsdk}/libexec/android-sdk"; ANDROID_SDK_ROOT = ANDROID_HOME; - JAVA_HOME = pkgs.jdk17; + JAVA_HOME = pkgs.jdk21; - TOOLS = "${ANDROID_HOME}/build-tools/${"36.0.0"}"; - GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${TOOLS}/aapt2"; + GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_HOME}/build-tools/36.0.0/aapt2"; + } + // lib.optionalAttrs pkgs.stdenv.isLinux { + LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}"; }; } diff --git a/linux/nix/module.nix b/linux/nix/module.nix new file mode 100644 index 0000000..a8abd6c --- /dev/null +++ b/linux/nix/module.nix @@ -0,0 +1,45 @@ +self: +{ + pkgs, + lib, + config, + ... +}: + +let + inherit (lib) + mkIf + mkMerge + mkEnableOption + mkPackageOption + ; + + cfg = config.programs.nexus; +in +{ + options.programs.nexus = { + enable = mkEnableOption "Nexus, a simple and user-friendly Matrix client"; + package = mkPackageOption self.packages.${pkgs.stdenv.hostPlatform.system} "default" { }; + + enableNotifications = mkEnableOption "notifications support via UnifiedPush"; + }; + + config = mkIf cfg.enable (mkMerge [ + { + environment.systemPackages = [ cfg.package ]; + } + (mkIf cfg.enableNotifications { + environment.systemPackages = [ + pkgs.kdePackages.kunifiedpush + ]; + + systemd.packages = [ + pkgs.kdePackages.kunifiedpush + ]; + + services.dbus.packages = [ + cfg.package + ]; + }) + ]); +} diff --git a/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix index 4e00959..7574d45 100644 --- a/linux/nix/pkg/default.nix +++ b/linux/nix/pkg/default.nix @@ -5,7 +5,9 @@ libass, libclang, flutter, + stdenv, src, + commit ? "dev", }: flutter.buildFlutterApplication { @@ -19,6 +21,10 @@ flutter.buildFlutterApplication { packageRun build_runner build ''; + flutterBuildFlags = [ + "--dart-define=GIT_COMMIT=${commit}" + ]; + buildInputs = [ mpv-unwrapped libass @@ -32,11 +38,15 @@ flutter.buildFlutterApplication { emoji_text_field = "sha256-3TOys09EP2GRo6pUBGPXaqBlE39O2Cmwt42Hs1cTDKo="; linkify = "sha256-TpMD6+0zyY6i9l+6d8ErnVufmepCv362rCtnbOht/z4="; navigation_rail_m3e = "sha256-+2awDTQnK58gGRY1nuHckG/jjxarsYSRu9ovR4i4TEc="; + unifiedpush_linux = "sha256-aIF/8oabSuuZo7K6qr4r7UBmf57qgoGAXyJ40fdntuQ="; + unifiedpush_platform_interface = "sha256-aIF/8oabSuuZo7K6qr4r7UBmf57qgoGAXyJ40fdntuQ="; + xdg_desktop_portal = "sha256-8GgiXHGv+wy6hJgj/rZxbymMitNVmdDLAnedZm9STSc="; }; postInstall = '' - install -D assets/icon.svg $out/share/icons/hicolor/scalable/apps/nexus.svg - install -Dm755 linux/nexus.federated.nexus.desktop -t $out/share/applications + install -D assets/bundled/icon.svg $out/share/icons/hicolor/scalable/apps/nexus.svg + install -Dm755 linux/*.desktop -t $out/share/applications + install -Dm644 linux/*.service -t $out/share/dbus-1/services wrapProgram $out/bin/nexus \ --suffix LD_LIBRARY_PATH : $out/app/nexus/lib ''; @@ -44,7 +54,11 @@ flutter.buildFlutterApplication { meta = { description = "A simple and user-friendly Matrix client"; mainProgram = "nexus"; - platforms = lib.platforms.linux; + platforms = lib.platforms.unix; + + # https://github.com/NixOS/nixpkgs/blob/c59305bab2065cfecc4944690d9eedbb56f3a9fa/pkgs/development/compilers/flutter/build-support/build-flutter-application.nix#L15 + broken = stdenv.hostPlatform.isDarwin; + maintainers = with lib.maintainers; [ quadradical ]; }; } diff --git a/linux/nix/pkg/gomuks.nix b/linux/nix/pkg/gomuks.nix index 40fe29c..d84450c 100644 --- a/linux/nix/pkg/gomuks.nix +++ b/linux/nix/pkg/gomuks.nix @@ -1,31 +1,38 @@ { src, + stdenv, buildGoModule, }: -buildGoModule (finalAttrs: { - pname = "gomuks-ffi"; - version = "submodule"; +buildGoModule ( + finalAttrs: + let + filename = "libgomuks${stdenv.hostPlatform.extensions.sharedLibrary}"; + in + { + pname = "gomuks-ffi"; + version = "submodule"; - doCheck = false; + doCheck = false; - src = "${src}/gomuks"; + src = "${src}/gomuks"; - vendorHash = "sha256-4QU51WGiNKYm7z/ajnas2qQaMK5BgyHwPfmNpQvW0qg="; + vendorHash = "sha256-lKiVzlFk0yffT6uUiSqQo/CiyTOfgKqiD+MEMoBvmE8="; - buildPhase = '' - runHook preBuild + buildPhase = '' + runHook preBuild - go build -buildmode=c-shared -o libgomuks.so -tags goolm,noheic,sqlite_fts5 ./pkg/ffi + go build -buildmode=c-shared -o ${filename} -tags goolm,noheic,sqlite_fts5 ./pkg/ffi - runHook postBuild - ''; + runHook postBuild + ''; - installPhase = '' - runHook preInstall + installPhase = '' + runHook preInstall - install -Dm0644 libgomuks.so -t $out/lib + install -Dm0644 ${filename} -t $out/lib - runHook postInstall - ''; -}) + runHook postInstall + ''; + } +) diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 62e5450..bf6e5ef 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1,6 +1,11 @@ #include "my_application.h" +#include + #include +#include +#include + #ifdef GDK_WINDOWING_X11 #include #endif @@ -9,144 +14,384 @@ struct _MyApplication { GtkApplication parent_instance; + char** dart_entrypoint_arguments; + + FlEngine* flutter_engine; + FlMethodChannel* notification_channel; + + gchar* pending_notification_event_id; + gboolean flutter_ready; }; G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) -// Called when first Flutter frame received. -static void first_frame_cb(MyApplication* self, FlView *view) -{ - gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +static void my_application_activate(GApplication* application); + +static void send_notification_event_to_flutter( + MyApplication* self, + const gchar* event_id) { + if (self->notification_channel == nullptr || + !self->flutter_ready) { + g_free(self->pending_notification_event_id); + self->pending_notification_event_id = g_strdup(event_id); + return; + } + + g_autoptr(FlValue) args = + fl_value_new_string(event_id); + + fl_method_channel_invoke_method( + self->notification_channel, + "notificationClicked", + args, + nullptr, + nullptr, + nullptr); } -// Implements GApplication::activate. -static void my_application_activate(GApplication* application) { - MyApplication* self = MY_APPLICATION(application); +static void notification_event_action( + GSimpleAction* action, + GVariant* parameter, + gpointer user_data) { + MyApplication* self = + MY_APPLICATION(user_data); - GList* windows = gtk_application_get_windows(GTK_APPLICATION(application)); - if (windows) { - gtk_window_present(GTK_WINDOW(windows->data)); + if (parameter == nullptr) { + g_warning( + "Notification event action invoked without a target"); + return; + } + + if (!g_variant_is_of_type( + parameter, + G_VARIANT_TYPE_STRING)) { + g_warning( + "Notification event action received unexpected parameter type: %s", + g_variant_get_type_string(parameter)); + return; + } + + const gchar* event_id = + g_variant_get_string(parameter, nullptr); + + g_message( + "Notification event activated: %s", + event_id); + + send_notification_event_to_flutter( + self, + event_id); + + GList* windows = + gtk_application_get_windows( + GTK_APPLICATION(self)); + + if (windows != nullptr) { + gtk_window_present( + GTK_WINDOW(windows->data)); + } else { + my_application_activate( + G_APPLICATION(self)); + } +} + +static void first_frame_cb( + MyApplication* self, + FlView* view) { + self->flutter_ready = TRUE; + + gtk_widget_show( + gtk_widget_get_toplevel( + GTK_WIDGET(view))); + + if (self->pending_notification_event_id != nullptr) { + gchar* event_id = + self->pending_notification_event_id; + + self->pending_notification_event_id = nullptr; + + send_notification_event_to_flutter( + self, + event_id); + + g_free(event_id); + } +} + +static void my_application_activate( + GApplication* application) { + MyApplication* self = + MY_APPLICATION(application); + + GList* windows = + gtk_application_get_windows( + GTK_APPLICATION(application)); + + if (windows != nullptr) { + gtk_window_present( + GTK_WINDOW(windows->data)); return; } GtkWindow* window = - GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + GTK_WINDOW( + gtk_application_window_new( + GTK_APPLICATION(application))); - // Use a header bar when running in GNOME as this is the common style used - // by applications and is the setup most users will be using (e.g. Ubuntu - // desktop). - // If running on X and not using GNOME then just use a traditional title bar - // in case the window manager does more exotic layout, e.g. tiling. - // If running on Wayland assume the header bar will work (may need changing - // if future cases occur). gboolean use_header_bar = TRUE; + #ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); + GdkScreen* screen = + gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + const gchar* wm_name = + gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { use_header_bar = FALSE; } } #endif - gtk_widget_set_size_request(GTK_WIDGET(window), 250, -1); + + gtk_widget_set_size_request( + GTK_WIDGET(window), + 250, + -1); + if (use_header_bar) { - GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); - gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "nexus"); - gtk_header_bar_set_show_close_button(header_bar, TRUE); - gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + GtkHeaderBar* header_bar = + GTK_HEADER_BAR( + gtk_header_bar_new()); + + gtk_widget_show( + GTK_WIDGET(header_bar)); + + gtk_header_bar_set_title( + header_bar, + "nexus"); + + gtk_header_bar_set_show_close_button( + header_bar, + TRUE); + + gtk_window_set_titlebar( + window, + GTK_WIDGET(header_bar)); } else { - gtk_window_set_title(window, "nexus"); + gtk_window_set_title( + window, + "nexus"); } - gtk_window_set_default_size(window, 1280, 720); + gtk_window_set_default_size( + window, + 1280, + 720); - g_autoptr(FlDartProject) project = fl_dart_project_new(); - fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + g_autoptr(FlDartProject) project = + fl_dart_project_new(); + + fl_dart_project_set_dart_entrypoint_arguments( + project, + self->dart_entrypoint_arguments); + + FlView* view = + fl_view_new(project); + + self->flutter_engine = + fl_view_get_engine(view); + + g_autoptr(FlStandardMethodCodec) codec = + fl_standard_method_codec_new(); + + self->notification_channel = + fl_method_channel_new( + fl_engine_get_binary_messenger( + self->flutter_engine), + "nexus/notifications", + FL_METHOD_CODEC(codec)); - FlView* view = fl_view_new(project); GdkRGBA background_color; - // Background defaults to black, override it here if necessary, e.g. #00000000 for transparent. - gdk_rgba_parse(&background_color, "#000000"); - fl_view_set_background_color(view, &background_color); - gtk_widget_show(GTK_WIDGET(view)); - gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); - // Show the window when Flutter renders. - // Requires the view to be realized so we can start rendering. - g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), self); - gtk_widget_realize(GTK_WIDGET(view)); + gdk_rgba_parse( + &background_color, + "#000000"); - fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + fl_view_set_background_color( + view, + &background_color); - gtk_widget_grab_focus(GTK_WIDGET(view)); + gtk_widget_show( + GTK_WIDGET(view)); + + if (std::getenv("FLUTTER_HEADLESS")) { + gtk_widget_hide( + GTK_WIDGET(window)); + } + + gtk_container_add( + GTK_CONTAINER(window), + GTK_WIDGET(view)); + + g_signal_connect_swapped( + view, + "first-frame", + G_CALLBACK(first_frame_cb), + self); + + gtk_widget_realize( + GTK_WIDGET(view)); + + fl_register_plugins( + FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus( + GTK_WIDGET(view)); } -// Implements GApplication::local_command_line. -static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { - MyApplication* self = MY_APPLICATION(application); - // Strip out the first argument as it is the binary name. - self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); +static gboolean my_application_local_command_line( + GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = + MY_APPLICATION(application); + + self->dart_entrypoint_arguments = + g_strdupv(*arguments + 1); g_autoptr(GError) error = nullptr; - if (!g_application_register(application, nullptr, &error)) { - g_warning("Failed to register: %s", error->message); - *exit_status = 1; - return TRUE; + + if (!g_application_register( + application, + nullptr, + &error)) { + g_warning( + "Failed to register: %s", + error->message); + + *exit_status = 1; + return TRUE; } g_application_activate(application); + *exit_status = 0; return FALSE; } -// Implements GApplication::startup. -static void my_application_startup(GApplication* application) { - //MyApplication* self = MY_APPLICATION(object); +static void my_application_startup( + GApplication* application) { + g_message( + "GApplication application-id: %s", + g_application_get_application_id( + application)); - // Perform any actions required at application startup. + g_message( + "GApplication is remote: %d", + g_application_get_is_remote( + application)); - G_APPLICATION_CLASS(my_application_parent_class)->startup(application); + g_message( + "GApplication is registered: %d", + g_application_get_is_registered( + application)); + + g_message( + "prgname: %s", + g_get_prgname()); + + G_APPLICATION_CLASS( + my_application_parent_class) + ->startup(application); } -// Implements GApplication::shutdown. -static void my_application_shutdown(GApplication* application) { - //MyApplication* self = MY_APPLICATION(object); - - // Perform any actions required at application shutdown. - - G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +static void my_application_shutdown( + GApplication* application) { + G_APPLICATION_CLASS( + my_application_parent_class) + ->shutdown(application); } -// Implements GObject::dispose. -static void my_application_dispose(GObject* object) { - MyApplication* self = MY_APPLICATION(object); - g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); - G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +static void my_application_dispose( + GObject* object) { + MyApplication* self = + MY_APPLICATION(object); + + g_clear_pointer( + &self->dart_entrypoint_arguments, + g_strfreev); + + g_clear_pointer( + &self->pending_notification_event_id, + g_free); + + g_clear_object( + &self->notification_channel); + + self->flutter_engine = nullptr; + + G_OBJECT_CLASS( + my_application_parent_class) + ->dispose(object); } -static void my_application_class_init(MyApplicationClass* klass) { - G_APPLICATION_CLASS(klass)->activate = my_application_activate; - G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; - G_APPLICATION_CLASS(klass)->startup = my_application_startup; - G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; - G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +static void my_application_class_init( + MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = + my_application_activate; + + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + + G_APPLICATION_CLASS(klass)->startup = + my_application_startup; + + G_APPLICATION_CLASS(klass)->shutdown = + my_application_shutdown; + + G_OBJECT_CLASS(klass)->dispose = + my_application_dispose; } -static void my_application_init(MyApplication* self) {} +static void my_application_init( + MyApplication* self) { + self->dart_entrypoint_arguments = nullptr; + self->flutter_engine = nullptr; + self->notification_channel = nullptr; + self->pending_notification_event_id = nullptr; + self->flutter_ready = FALSE; + + GSimpleAction* action = + g_simple_action_new( + "event", + G_VARIANT_TYPE_STRING); + + g_signal_connect( + action, + "activate", + G_CALLBACK(notification_event_action), + self); + + g_action_map_add_action( + G_ACTION_MAP(self), + G_ACTION(action)); + + g_object_unref(action); +} MyApplication* my_application_new() { - // Set the program name to the application ID, which helps various systems - // like GTK and desktop environments map this running application to its - // corresponding .desktop file. This ensures better integration by allowing - // the application to be recognized beyond its binary name. g_set_prgname(APPLICATION_ID); - return MY_APPLICATION(g_object_new(my_application_get_type(), - "application-id", APPLICATION_ID, - "flags", G_APPLICATION_HANDLES_COMMAND_LINE | G_APPLICATION_HANDLES_OPEN, - nullptr)); -} + return MY_APPLICATION( + g_object_new( + my_application_get_type(), + "application-id", + APPLICATION_ID, + "flags", + G_APPLICATION_HANDLES_COMMAND_LINE | + G_APPLICATION_HANDLES_OPEN, + nullptr)); +} \ No newline at end of file diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig index c2efd0b..4b81f9b 100644 --- a/macos/Flutter/Flutter-Debug.xcconfig +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig index c2efd0b..5caa9d1 100644 --- a/macos/Flutter/Flutter-Release.xcconfig +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index f18d831..8b6bbb6 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,6 +8,7 @@ import Foundation import app_links import dynamic_color import file_selector_macos +import flutter_local_notifications import media_kit_libs_macos_video import media_kit_video import package_info_plus @@ -15,12 +16,14 @@ import screen_retriever_macos import shared_preferences_foundation import url_launcher_macos import wakelock_plus +import webcrypto import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) @@ -28,5 +31,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) + WebcryptoPlugin.register(with: registry.registrar(forPlugin: "WebcryptoPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..65543e2 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..08cb773 --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,28 @@ +PODS: + - FlutterMacOS (1.0.0) + - media_kit_libs_macos_video (1.0.4): + - FlutterMacOS + - media_kit_video (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - FlutterMacOS (from `Flutter/ephemeral`) + - media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`) + - media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`) + +EXTERNAL SOURCES: + FlutterMacOS: + :path: Flutter/ephemeral + media_kit_libs_macos_video: + :path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos + media_kit_video: + :path: Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos + +SPEC CHECKSUMS: + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65 + media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758 + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.17.0 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index f87523c..fa18d44 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,9 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 34AEA7295333B7F48CD3BE4C /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D76F30AE9CCFCA04C354BD4C /* Pods_RunnerTests.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + BAF79690465DBAC1A1E19D28 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 70FD05041ACDA7E801BB9823 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,11 +63,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 18EDDEF1FB58EC56ABDA1BEB /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* nexus.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "nexus.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* nexus.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = nexus.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -76,8 +80,16 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 5099E6191AE87E21D356D9C8 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 6D9F8F8BBED102075ECF1DF9 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 70FD05041ACDA7E801BB9823 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 8F5ECEC7BF9DD35CB3E7196E /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 9DDE815F827B87E2C55DF94C /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + CBD1D9203BEE9731F981EEF8 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + D76F30AE9CCFCA04C354BD4C /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +97,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 34AEA7295333B7F48CD3BE4C /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,6 +105,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + BAF79690465DBAC1A1E19D28 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -125,6 +140,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + FC780AF47288088B9004ACE2 /* Pods */, ); sourceTree = ""; }; @@ -151,6 +167,7 @@ 33CEB47122A05771004F2AC0 /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, @@ -175,10 +192,26 @@ D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 70FD05041ACDA7E801BB9823 /* Pods_Runner.framework */, + D76F30AE9CCFCA04C354BD4C /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; }; + FC780AF47288088B9004ACE2 /* Pods */ = { + isa = PBXGroup; + children = ( + 6D9F8F8BBED102075ECF1DF9 /* Pods-Runner.debug.xcconfig */, + 9DDE815F827B87E2C55DF94C /* Pods-Runner.release.xcconfig */, + CBD1D9203BEE9731F981EEF8 /* Pods-Runner.profile.xcconfig */, + 18EDDEF1FB58EC56ABDA1BEB /* Pods-RunnerTests.debug.xcconfig */, + 8F5ECEC7BF9DD35CB3E7196E /* Pods-RunnerTests.release.xcconfig */, + 5099E6191AE87E21D356D9C8 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -186,6 +219,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + C413A8A75594D2165C9D56BD /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +238,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 446C82D5FE82BD05F78EDA6A /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + C4D158541D7059C07D3CA9B3 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -216,6 +252,9 @@ 33CC11202044C79F0003C045 /* PBXTargetDependency */, ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 33CC10ED2044A3C60003C045 /* nexus.app */; productType = "com.apple.product-type.application"; @@ -260,6 +299,9 @@ Base, ); mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -329,6 +371,67 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 446C82D5FE82BD05F78EDA6A /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + C413A8A75594D2165C9D56BD /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + C4D158541D7059C07D3CA9B3 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -380,6 +483,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 18EDDEF1FB58EC56ABDA1BEB /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -394,6 +498,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 8F5ECEC7BF9DD35CB3E7196E /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -408,6 +513,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 5099E6191AE87E21D356D9C8 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -461,7 +567,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -543,7 +649,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -593,7 +699,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -700,6 +806,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 33CC10E52044A3C60003C045 /* Project object */; } diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index ff47eeb..746b383 100644 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + + + diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index a2ec33f..96d3fee 100644 --- a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,68 +1,68 @@ { - "images" : [ - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_16.png", - "scale" : "1x" + "info": { + "version": 1, + "author": "xcode" }, - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "2x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "1x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_64.png", - "scale" : "2x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_128.png", - "scale" : "1x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "2x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "1x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "2x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "1x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_1024.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} + "images": [ + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_16.png", + "scale": "1x" + }, + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "2x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "1x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_64.png", + "scale": "2x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_128.png", + "scale": "1x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "2x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "1x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "2x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "1x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_1024.png", + "scale": "2x" + } + ] +} \ No newline at end of file diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index 82b6f9d..1682af0 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png index 13b35eb..c2e488b 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png index 0a3f5fa..e2bf256 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png index bdb5722..983d7fa 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png index f083318..c8f0676 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png index 326c0e7..468be1f 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png index 2f1632c..a77207d 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/pubspec.lock b/pubspec.lock index c989ed4..a0e58e4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,42 +5,42 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c + sha256: fdcd9f70f9eb80df3bc5ed0fa67280df2595fd481948df8a9ab082e6a40ad04b url: "https://pub.dev" source: hosted - version: "99.0.0" + version: "108.0.0" analysis_server_plugin: dependency: transitive description: name: analysis_server_plugin - sha256: "3960b28ee740004df39f85d5ebfc91785f7a90e51fd7c9a185e86a36b2f581b4" + sha256: "3460f040e1f77647fcd82db9bd3bb117de04d04a3d67c39ad361794331147a4e" url: "https://pub.dev" source: hosted - version: "0.3.14" + version: "0.3.23" analyzer: dependency: transitive description: name: analyzer - sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7" + sha256: a51c769bff3b6dfbe9d60199b8606d808290702a296bef0c26a4ca391d414e46 url: "https://pub.dev" source: hosted - version: "12.1.0" + version: "14.4.0" analyzer_buffer: dependency: transitive description: name: analyzer_buffer - sha256: "445b77e2054fa3e8c8a8ef1b5e9e6b23bb8028fffd34b5e60eaef315b7750674" + sha256: f890a2e708e38fbf4238ad2f9fd3d506538acdddb0cd8eb2c3e8c7bd17784b57 url: "https://pub.dev" source: hosted - version: "0.3.3" + version: "0.3.4" analyzer_plugin: dependency: transitive description: name: analyzer_plugin - sha256: "0057a98d64d7bb872b0c87dff6e73d2c2d80c77156e7a03f127a26f8aa240649" + sha256: "9a3518eb84e8e26665874d9867534961d855493e9c5eb0eb55cbd46edd995529" url: "https://pub.dev" source: hosted - version: "0.14.8" + version: "0.14.17" app_links: dependency: "direct main" description: @@ -77,10 +77,10 @@ packages: dependency: transitive description: name: archive - sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4" url: "https://pub.dev" source: hosted - version: "4.0.9" + version: "4.3.0" args: dependency: transitive description: @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" + url: "https://pub.dev" + source: hosted + version: "1.6.5" async: dependency: transitive description: @@ -109,50 +117,50 @@ packages: dependency: transitive description: name: build - sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" + sha256: "8a5c5761af8e31748bba3c82f68925ace40f3225c3eea25be9beb57eca7cd7a8" url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "4.0.11" build_config: dependency: transitive description: name: build_config - sha256: f2c223156a26eea323e6244b85141d76413a80aeee9fe0b380773789fabaf8ae + sha256: d466ed2dc9c6cd1d169948879b84ee061eb5e22c64a7c6089879c6296d272a8d url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.3" build_daemon: dependency: transitive description: name: build_daemon - sha256: fd754058c342243718d5171a95f352cfc9fcf0cba8cfa26df67cb13a5836db78 + sha256: e1d40ef3f7934986d5da2271b1ba07794921ce263e44d622fb6c406d76589e33 url: "https://pub.dev" source: hosted - version: "4.1.2" + version: "4.1.6" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" + sha256: "894c243f6bc32015fec466ce30a6925bd537a77a426ee5bf481120477eb3de67" url: "https://pub.dev" source: hosted - version: "2.15.1" + version: "2.16.1" built_collection: dependency: transitive description: name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + sha256: "44922ecebd8e3d0cef743dadd3b54df4e87e2a4c52a29465a1872aa9e192caa8" url: "https://pub.dev" source: hosted - version: "5.1.1" + version: "5.1.2" built_value: dependency: transitive description: name: built_value - sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7 url: "https://pub.dev" source: hosted - version: "8.12.6" + version: "8.13.0" button_m3e: dependency: transitive description: @@ -169,14 +177,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" - charcode: - dependency: transitive - description: - name: charcode - sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a - url: "https://pub.dev" - source: hosted - version: "1.4.0" checked_yaml: dependency: transitive description: @@ -185,14 +185,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" - cli_config: - dependency: transitive - description: - name: cli_config - sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec - url: "https://pub.dev" - source: hosted - version: "0.2.0" cli_util: dependency: transitive description: @@ -205,18 +197,18 @@ packages: dependency: transitive description: name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.3" code_assets: dependency: "direct main" description: name: code_assets - sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "2.1.0" collection: dependency: "direct main" description: @@ -241,22 +233,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" - coverage: - dependency: transitive - description: - name: coverage - sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" - url: "https://pub.dev" - source: hosted - version: "1.15.1" cross_file: dependency: transitive description: name: cross_file - sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 url: "https://pub.dev" source: hosted - version: "0.3.5+4" + version: "0.3.5+5" crypto: dependency: transitive description: @@ -273,47 +257,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + cupertino_ui: + dependency: transitive + description: + name: cupertino_ui + sha256: c23816577a1280cff409e989541767887011f8f1d8c1863a794ed308af46f2a9 + url: "https://pub.dev" + source: hosted + version: "1.1.1" dart_style: dependency: transitive description: name: dart_style - sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98 + sha256: "82ade9fc4273f29ed673e33166944465225b4f7fc5d4aaef48605cc751c18fc1" url: "https://pub.dev" source: hosted - version: "3.1.8" + version: "3.1.13" dbus: dependency: transitive description: name: dbus - sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 url: "https://pub.dev" source: hosted - version: "0.7.14" + version: "0.7.15" dynamic_color: dependency: "direct main" description: name: dynamic_color - sha256: "43a5a6679649a7731ab860334a5812f2067c2d9ce6452cf069c5e0c25336c17c" + sha256: "869b3bce0100eb519768ecd71c0111f0b5ea679f5f62e1f59c49d80067e730c5" url: "https://pub.dev" source: hosted - version: "1.8.1" + version: "2.1.0" dynamic_polls: dependency: "direct main" description: name: dynamic_polls - sha256: "72ff19cdf041ad8dcfa76adaebb216d005f40b278d955e6e0c7bcb769215fabe" + sha256: "58cd1fafbbaba6b485a61692aa3bd33ded6f0f61734543b6c754511c362d8ae1" url: "https://pub.dev" source: hosted - version: "0.0.7" - emoji_text_field: - dependency: "direct main" + version: "0.0.8" + encrypt: + dependency: transitive description: - path: "." - ref: HEAD - resolved-ref: "5f7baaf8a6f059ec3ab8ff0f5d02339b00bf6997" - url: "https://github.com/Henry-Hiles/emoji_text_field" - source: git - version: "1.0.0" + name: encrypt + sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" + url: "https://pub.dev" + source: hosted + version: "5.0.3" equatable: dependency: transitive description: @@ -366,10 +357,10 @@ packages: dependency: "direct main" description: name: ffigen - sha256: b7803707faeec4ce3c1b0c2274906504b796e3b70ad573577e72333bd1c9b3ba + sha256: "31b2ca630cede89babbbf31688d20b735967c51bf60572b06ec16718e5a7f1ec" url: "https://pub.dev" source: hosted - version: "20.1.1" + version: "22.0.0" file: dependency: transitive description: @@ -390,34 +381,34 @@ packages: dependency: transitive description: name: file_selector_android - sha256: "6a26687fa65cbc28a5345c7ae6f227e89f0b47740978a4c475b1a625da7a331b" + sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c url: "https://pub.dev" source: hosted - version: "0.5.2+8" + version: "0.5.2+11" file_selector_ios: dependency: transitive description: name: file_selector_ios - sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca + sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c" url: "https://pub.dev" source: hosted - version: "0.5.3+5" + version: "0.5.3+6" file_selector_linux: dependency: transitive description: name: file_selector_linux - sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab url: "https://pub.dev" source: hosted - version: "0.9.4" + version: "0.9.4+1" file_selector_macos: dependency: transitive description: name: file_selector_macos - sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 url: "https://pub.dev" source: hosted - version: "0.9.5" + version: "0.9.5+1" file_selector_platform_interface: dependency: transitive description: @@ -438,10 +429,10 @@ packages: dependency: transitive description: name: file_selector_windows - sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec url: "https://pub.dev" source: hosted - version: "0.9.3+5" + version: "0.9.3+6" fixnum: dependency: transitive description: @@ -495,6 +486,46 @@ packages: 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_localizations: dependency: "direct main" description: flutter @@ -512,10 +543,10 @@ packages: dependency: "direct main" description: name: flutter_riverpod - sha256: "9255e1e3ad6e38906a1b4f8287678f95f378744c5b46b1985588543f3f19046e" + sha256: "2b7f9d2a3c730ac1a98221b420cef966b990fca7ab546ede324642ca57a533e3" url: "https://pub.dev" source: hosted - version: "3.3.2" + version: "3.4.3" flutter_svg: dependency: "direct main" description: @@ -538,10 +569,10 @@ packages: dependency: "direct main" description: name: flutter_widget_from_html_core - sha256: "7ff010b116f6abc16429923e616fbc727f3f65ef4cee12ffdb280aeecbc21e7f" + sha256: "530f2e3cc57be1a00f8bfae1b0000064e8a6df0cf1fb4d48a80f85afde9b39b7" url: "https://pub.dev" source: hosted - version: "0.17.2" + version: "0.17.4" fluttertagger: dependency: "direct main" description: @@ -554,10 +585,10 @@ packages: dependency: "direct dev" description: name: freezed - sha256: "8599ba37236328ff6f97269ecce875d251a367eb2929c9bbf9b811b2ce56c74e" + sha256: "3af98c92d7d4dd182ea1393193e69a840fcbaee1f66f1158bd20504e5f3f6f4f" url: "https://pub.dev" source: hosted - version: "3.2.6-dev.1" + version: "4.0.2" freezed_annotation: dependency: "direct main" description: @@ -566,30 +597,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.dev" - source: hosted - version: "4.0.0" get_x_storage: dependency: transitive description: name: get_x_storage - sha256: "69e4412dd70e25a4991623c10bf72e3b12106f2cb4353a2d167353947597f3aa" + sha256: "597cd8f6ef8b01c747a6d95ff7b0310066f6c82ddf5565ca371f047f316fe43e" url: "https://pub.dev" source: hosted - version: "0.0.9" + version: "0.2.0" glob: dependency: transitive description: name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.2.0" graphs: dependency: transitive description: @@ -610,26 +633,26 @@ packages: dependency: "direct main" description: name: hooks - sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.2.0" hooks_riverpod: dependency: "direct main" description: name: hooks_riverpod - sha256: dcaf59f0f41489ff08ce61438ada564d67f40cfa37cc7c8589da78fb600c2edc + sha256: d58e1e14aa112ca9c56d54effb7d0d717b2d3f96dad3cdd1fce16f8740450700 url: "https://pub.dev" source: hosted - version: "3.3.2" + version: "3.4.3" html: dependency: transitive description: name: html - sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" url: "https://pub.dev" source: hosted - version: "0.15.6" + version: "0.15.7" http: dependency: "direct main" description: @@ -666,10 +689,10 @@ packages: dependency: transitive description: name: image - sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89 url: "https://pub.dev" source: hosted - version: "4.8.0" + version: "4.10.1" image_picker: dependency: "direct main" description: @@ -682,10 +705,10 @@ packages: dependency: transitive description: name: image_picker_android - sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a" + sha256: "0a55d645a670d6ae11efa948f955ae904afa472f1344fc15a7ddacc20c1e7219" url: "https://pub.dev" source: hosted - version: "0.8.13+19" + version: "0.8.13+23" image_picker_for_web: dependency: transitive description: @@ -698,10 +721,10 @@ packages: dependency: transitive description: name: image_picker_ios - sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + sha256: "852a7762962f3bb3ad54ef1d342669e41557f466033a676baa8876b367cf4050" url: "https://pub.dev" source: hosted - version: "0.8.13+6" + version: "0.8.13+8" image_picker_linux: dependency: transitive description: @@ -738,18 +761,26 @@ packages: dependency: "direct main" description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" io: dependency: transitive description: name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.1.0" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" json_annotation: dependency: "direct main" description: @@ -762,10 +793,10 @@ packages: dependency: "direct dev" description: name: json_serializable - sha256: ffcd10cde35a93b2abbbcc26bd9971f4ca93763e8abe78d855e3c4177797e501 + sha256: e45aefa0324f08c683caafbb94b72837aa6193c61822799c916e45f4a263113d url: "https://pub.dev" source: hosted - version: "6.14.0" + version: "6.14.1" leak_tracker: dependency: transitive description: @@ -807,6 +838,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + listen: + dependency: transitive + description: + name: listen + sha256: "47501a08016a43fcad79252439d723f50f14f88fa7bfd8a177e0a417e5c9e1f2" + url: "https://pub.dev" + source: hosted + version: "1.0.1" logging: dependency: transitive description: @@ -819,18 +858,18 @@ packages: dependency: "direct main" description: name: m3e_buttons - sha256: af1b19bd5ec7327d20b5a57c6c91e1ed30ee15a4dbade781c0cdbc72e6b04b5e + sha256: dca780e813563754576194ed18e3f6a33422ed136c8d4ccfeca20909edddd728 url: "https://pub.dev" source: hosted - version: "0.0.4" + version: "1.0.3" m3e_card_list: dependency: "direct main" description: name: m3e_card_list - sha256: d4aba0123cccda40ac80789befa8d355e1dc16aa7dcee910157690b0546d78d6 + sha256: ed1cc150f051b8812ffd735915ae59fed23517ffeeed04c4b2b8ef6257cc18b7 url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "1.0.0" m3e_design: dependency: transitive description: @@ -843,10 +882,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -855,6 +894,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" + material_emoji_picker: + dependency: "direct main" + description: + name: material_emoji_picker + sha256: "738e70a8d2f592be05dd9889037cc6098a63e0d8c48723125848865ab305f138" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + material_ui: + dependency: "direct main" + description: + name: material_ui + sha256: "32d98f11cf5942a095a8c3e01b03e4de3b17941437713dcf36b1088fb62e23d2" + url: "https://pub.dev" + source: hosted + version: "1.5.0" measure_size: dependency: "direct main" description: @@ -931,18 +986,18 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mime: dependency: transitive description: name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" motor: dependency: transitive description: @@ -951,39 +1006,39 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "90eaad8059ebc2004a63810a4696ea9acdb02473e47ed3b6b3bafb100ac77e31" + url: "https://pub.dev" + source: hosted + version: "0.19.5" navigation_rail_m3e: dependency: "direct main" description: path: "packages/navigation_rail_m3e" ref: HEAD - resolved-ref: "667b0bc8526fd53296778903b6ef3f22424f3aa4" + resolved-ref: da860a413d5cf78b9c9c967215250bcdbfe5191d url: "https://github.com/Henry-Hiles/material_3_expressive" source: git version: "0.3.5" - node_preamble: - dependency: transitive - description: - name: node_preamble - sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" - url: "https://pub.dev" - source: hosted - version: "2.0.2" objective_c: dependency: transitive description: name: objective_c - sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + sha256: ad56fd53a78ff6b1472fa59ff2a4e8b8ccabafc586fc263a1dfad0b99b5553e3 url: "https://pub.dev" source: hosted - version: "9.4.1" + version: "9.6.0" package_config: dependency: transitive description: name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "3.0.0" package_info_plus: dependency: "direct main" description: @@ -1068,18 +1123,18 @@ packages: dependency: transitive description: name: petitparser - sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + sha256: "2f1564aadb14215e478563b5406cb80729d022158c01dc50c96013c2f7eb4790" url: "https://pub.dev" source: hosted - version: "7.0.2" + version: "7.1.0" platform: dependency: transitive description: name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec url: "https://pub.dev" source: hosted - version: "3.1.6" + version: "3.2.0" plugin_platform_interface: dependency: transitive description: @@ -1088,38 +1143,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" pool: dependency: transitive description: name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" url: "https://pub.dev" source: hosted - version: "1.5.2" + version: "1.5.3" posix: dependency: transitive description: name: posix - sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.5.2" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" pub_semver: dependency: transitive description: name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.1" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + sha256: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796 url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.6.0" quiver: dependency: transitive description: @@ -1132,34 +1203,34 @@ packages: dependency: transitive description: name: record_use - sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "1.1.1" riverpod: dependency: transitive description: name: riverpod - sha256: "17100416c51db7810c71a7bb2c34d1f881faa0074fd452afb0c4db6f8f126c76" + sha256: "484dfc873ea4c4f4240e5635444fa066f87b07862b7279ebd004a2b71fba4b7a" url: "https://pub.dev" source: hosted - version: "3.3.2" + version: "3.4.3" riverpod_analyzer_utils: dependency: transitive description: name: riverpod_analyzer_utils - sha256: "3e275138862ccc22ed61444a1f9a840f753094c367f28f4123f50289cd204d68" + sha256: bb13cb3985815840cc103eda1027158358c5fdc00f308fc6f76574d0594107af url: "https://pub.dev" source: hosted - version: "1.0.0-dev.10" + version: "1.0.0-dev.12" riverpod_lint: dependency: "direct dev" description: name: riverpod_lint - sha256: "166f29492228dc471b6fe294092560ccd5545ed15c9fc155622455b58b2451a9" + sha256: "215cb3cd54a97e09bc3512535fa4e8bedd3bf2a06dc2ae015ac8e0050fc71bd1" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.9" rxdart: dependency: transitive description: @@ -1228,18 +1299,18 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" url: "https://pub.dev" source: hosted - version: "2.4.27" + version: "2.4.28" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" url: "https://pub.dev" source: hosted - version: "2.5.6" + version: "2.5.7" shared_preferences_linux: dependency: transitive description: @@ -1280,22 +1351,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.2" - shelf_packages_handler: - dependency: transitive - description: - name: shelf_packages_handler - sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - shelf_static: - dependency: transitive - description: - name: shelf_static - sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 - url: "https://pub.dev" - source: hosted - version: "1.1.3" shelf_web_socket: dependency: transitive description: @@ -1313,34 +1368,18 @@ packages: dependency: transitive description: name: source_gen - sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + sha256: "11b6047da8e4eb6c643ccac3d0fda3f9c9e11651695f7da24a85ade8aff15a44" url: "https://pub.dev" source: hosted - version: "4.2.3" + version: "4.3.0" source_helper: dependency: transitive description: name: source_helper - sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae" + sha256: "5e6f216fdf6376c9f3852381ae037499797a3385377d388b011dac98d303c67c" url: "https://pub.dev" source: hosted - version: "1.3.12" - source_map_stack_trace: - dependency: transitive - description: - name: source_map_stack_trace - sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b - url: "https://pub.dev" - source: hosted - version: "2.1.2" - source_maps: - dependency: transitive - description: - name: source_maps - sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" - url: "https://pub.dev" - source: hosted - version: "0.10.13" + version: "1.3.13" source_span: dependency: transitive description: @@ -1353,10 +1392,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" url: "https://pub.dev" source: hosted - version: "1.12.1" + version: "1.12.2" state_notifier: dependency: transitive description: @@ -1377,10 +1416,10 @@ packages: dependency: transitive description: name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" string_scanner: dependency: transitive description: @@ -1401,10 +1440,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153" + sha256: "397b146f97613b83d84bdccb439bc8d0f3aebb6c1d14a9641e6f24201c490b2d" url: "https://pub.dev" source: hosted - version: "3.4.1" + version: "3.4.2" term_glyph: dependency: transitive description: @@ -1413,30 +1452,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" - test: - dependency: transitive - description: - name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" - url: "https://pub.dev" - source: hosted - version: "1.31.0" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" - test_core: - dependency: transitive - description: - name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" - url: "https://pub.dev" - source: hosted - version: "0.6.17" + version: "0.7.12" timeago: dependency: "direct main" description: @@ -1445,6 +1468,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.7.1" + timezone: + dependency: transitive + description: + name: timezone + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" + url: "https://pub.dev" + source: hosted + version: "0.11.1" typed_data: dependency: transitive description: @@ -1453,22 +1484,56 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - universal_html: - dependency: transitive + unifiedpush: + dependency: "direct main" description: - name: universal_html - sha256: c0bcae5c733c60f26c7dfc88b10b0fd27cbcc45cb7492311cdaa6067e21c9cd4 + name: unifiedpush + sha256: "8ed9767f750a1dc6159a77e2171641d0cb825dc87682d1ce1b8618689b79f58e" url: "https://pub.dev" source: hosted - version: "2.3.0" - universal_io: + version: "6.2.0" + unifiedpush_android: dependency: transitive description: - name: universal_io - sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + name: unifiedpush_android + sha256: a902e505e91c8e28b54b379bb27f255f66df409274a97af24d6719f12f0d13dc url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "3.5.0" + unifiedpush_linux: + dependency: "direct overridden" + description: + path: unifiedpush_linux + ref: "quad/feat/should-write-service-option" + resolved-ref: "7589b07ef4280820e4adea165d3d86b219f6022c" + url: "https://codeberg.org/Henry-Hiles/flutter-connector" + source: git + version: "1.0.1" + unifiedpush_platform_interface: + dependency: "direct overridden" + description: + path: unifiedpush_platform_interface + ref: "quad/feat/should-write-service-option" + resolved-ref: "7589b07ef4280820e4adea165d3d86b219f6022c" + url: "https://codeberg.org/Henry-Hiles/flutter-connector" + source: git + version: "4.0.0" + unifiedpush_storage_interface: + dependency: transitive + description: + name: unifiedpush_storage_interface + sha256: b8d423a4695efc616aa21d8ab48fb5ef99d6288c68b56282b8faac1579ceabd9 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + unifiedpush_storage_shared_preferences: + dependency: "direct main" + description: + name: unifiedpush_storage_shared_preferences + sha256: eda9c52bac0058f81e0d9c65e33eedc12c5901d2e68ad47fdf68175ddf00a3b4 + url: "https://pub.dev" + source: hosted + version: "1.0.0" universal_platform: dependency: transitive description: @@ -1497,34 +1562,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" url: "https://pub.dev" source: hosted - version: "6.3.32" + version: "6.3.33" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "6.4.2" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.3" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" url: "https://pub.dev" source: hosted - version: "3.2.5" + version: "3.2.6" url_launcher_platform_interface: dependency: transitive description: @@ -1545,10 +1610,10 @@ packages: dependency: transitive description: name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" url: "https://pub.dev" source: hosted - version: "3.1.5" + version: "3.1.6" uuid: dependency: transitive description: @@ -1561,10 +1626,10 @@ packages: dependency: transitive description: name: vector_graphics - sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d" + sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.2.3" vector_graphics_codec: dependency: transitive description: @@ -1577,42 +1642,42 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: "142a9146f447d15b10bdc00e21d5f4d83e5b32bb5f8f8f5a04c75311344923a3" + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" url: "https://pub.dev" source: hosted - version: "1.2.6" + version: "1.3.0" vector_math: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.3" vm_service: dependency: transitive description: name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" url: "https://pub.dev" source: hosted - version: "15.2.0" + version: "15.3.0" wakelock_plus: dependency: transitive description: name: wakelock_plus - sha256: "824c5bba0f800e86d32e57d3d1843c531f090005cc89d9a837933e6601093d53" + sha256: "22b3e7e937721de70e63c85e7139f4ac781dc22863b9262431a53ac030eb074b" url: "https://pub.dev" source: hosted - version: "1.6.1" + version: "1.8.0" wakelock_plus_platform_interface: dependency: transitive description: name: wakelock_plus_platform_interface - sha256: b13f99e992e7ae6a152e16c5559d3c07ff445b13330192662494e614ca3e7d7b + sha256: "764c25504562abc8ac3406f5d175f126b276e6ba916c7c2675690b73368ca384" url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.7.0" watcher: dependency: transitive description: @@ -1645,22 +1710,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" - webkit_inspection_protocol: - dependency: transitive + webcrypto: + dependency: "direct dev" description: - name: webkit_inspection_protocol - sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + name: webcrypto + sha256: "6b43001c4110856ff7fa5e5e65e7b2d44bec1d8b54a4d84d5fa2c7622267c5c1" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "0.6.0" + webpush_encryption: + dependency: transitive + description: + name: webpush_encryption + sha256: "5b83272b91acda6ae515fcd980c94f06bf413702282497c5a68f5dfc64fed27f" + url: "https://pub.dev" + source: hosted + version: "1.0.1" win32: dependency: transitive description: name: win32 - sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.4.0" window_manager: dependency: "direct main" description: @@ -1669,6 +1742,15 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.2" + xdg_desktop_portal: + dependency: "direct main" + description: + path: "." + ref: "quad/nexus" + resolved-ref: "964249996e536b3c5b10c2654d1dc69044693431" + url: "https://github.com/Henry-Hiles/xdg_desktop_portal.dart" + source: git + version: "0.1.14" xdg_directories: dependency: "direct main" description: @@ -1681,18 +1763,18 @@ packages: dependency: transitive description: name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" url: "https://pub.dev" source: hosted - version: "6.6.1" + version: "7.0.1" yaml: dependency: transitive description: name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.4" yaml_edit: dependency: transitive description: @@ -1702,5 +1784,5 @@ packages: source: hosted version: "2.2.4" sdks: - dart: ">=3.12.2 <4.0.0" - flutter: ">=3.44.0" + dart: "3.13.3" + flutter: ">=3.47.0" diff --git a/pubspec.yaml b/pubspec.yaml index 72748b1..7f99ba3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,98 +1,123 @@ name: nexus -description: "Yet another Matrix client" +description: "A simple and user-friendly Matrix client" version: 0.1.0 publish_to: none flutter: - fonts: - - family: fallback-emoji - fonts: - - asset: assets/fonts/NotoColorEmoji.ttf - - family: fallback-sans - fonts: - - asset: assets/fonts/Roboto.ttf - assets: - - assets/ - uses-material-design: true + fonts: + - family: bundled-emoji + fonts: + - asset: assets/fonts/Twemoji.ttf + - family: bundled-sans + fonts: + - asset: assets/fonts/Inter.ttf + - family: bundled-mono + fonts: + - asset: assets/fonts/AtkinsonHyperlegibleMono.ttf + assets: + - assets/bundled/ + uses-material-design: true environment: - sdk: "^3.12.2" + sdk: 3.13.3 dependency_overrides: - path_provider_android: 2.2.23 # Pinned to avoid JNI - linkify: - git: - url: https://github.com/appelladev/linkify - ref: fix/consecutive-periods-loose-url + dynamic_color: 2.1.0 + unifiedpush_linux: + git: + url: https://codeberg.org/Henry-Hiles/flutter-connector + ref: quad/feat/should-write-service-option + path: unifiedpush_linux + unifiedpush_platform_interface: + git: + url: https://codeberg.org/Henry-Hiles/flutter-connector + ref: quad/feat/should-write-service-option + path: unifiedpush_platform_interface + path_provider_android: 2.2.23 # Pinned to avoid JNI + linkify: + git: + url: https://github.com/appelladev/linkify + ref: fix/consecutive-periods-loose-url dependencies: - flutter: - sdk: flutter - flutter_localizations: - sdk: flutter - flutter_riverpod: 3.3.2 - hooks_riverpod: 3.3.2 - intl: 0.20.2 - fast_immutable_collections: 11.2.0 - path_provider: 2.1.6 - url_launcher: 6.3.2 - freezed_annotation: 3.1.0 - image_picker: ^1.2.3 - path: 1.9.1 - dynamic_color: 1.8.1 - collection: 1.19.1 - window_manager: 0.5.2 - color_hash: 1.0.1 - flutter_widget_from_html_core: 0.17.2 - flutter_svg: 2.3.0 - json_annotation: 4.12.0 - shared_preferences: 2.5.5 - fluttertagger: 2.3.2 - dynamic_polls: 0.0.7 - flutter_hooks: 0.21.3+1 - ffi: 2.2.0 - hooks: 2.0.2 - code_assets: 1.2.1 - ffigen: 20.1.1 - timeago: 3.7.1 - http: 1.6.0 - flutter_linkify: 6.0.0 - linkify: 5.0.0 - emoji_text_field: - git: - url: https://github.com/Henry-Hiles/emoji_text_field - flutter_blurhash: 0.9.1 - super_sliver_list: 0.4.1 - media_kit: 1.2.6 - media_kit_video: 2.0.1 - media_kit_libs_video: 1.0.7 - measure_size: 5.0.2 - m3e_buttons: 0.0.4 - navigation_rail_m3e: - git: - url: https://github.com/Henry-Hiles/material_3_expressive - path: packages/navigation_rail_m3e - m3e_card_list: 0.1.0 - xdg_directories: 1.1.0 - package_info_plus: 10.2.1 - app_links: 7.2.1 - file_selector: ^1.1.0 - + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + flutter_riverpod: 3.4.3 + hooks_riverpod: 3.4.3 + intl: 0.20.3 + fast_immutable_collections: 11.2.0 + path_provider: 2.1.6 + url_launcher: 6.3.2 + freezed_annotation: 3.1.0 + image_picker: 1.2.3 + path: 1.9.1 + dynamic_color: 2.1.0 + collection: 1.19.1 + window_manager: 0.5.2 + color_hash: 1.0.1 + flutter_widget_from_html_core: 0.17.4 + flutter_svg: 2.3.0 + json_annotation: 4.12.0 + shared_preferences: 2.5.5 + fluttertagger: 2.3.2 + dynamic_polls: 0.0.8 + flutter_hooks: 0.21.3+1 + ffi: 2.2.0 + hooks: 2.2.0 + code_assets: 2.1.0 + ffigen: 22.0.0 + timeago: 3.7.1 + http: 1.6.0 + flutter_linkify: 6.0.0 + linkify: 5.0.0 + flutter_blurhash: 0.9.1 + super_sliver_list: 0.4.1 + media_kit: 1.2.6 + media_kit_video: 2.0.1 + media_kit_libs_video: 1.0.7 + measure_size: 5.0.2 + m3e_buttons: 1.0.3 + navigation_rail_m3e: + git: + url: https://github.com/Henry-Hiles/material_3_expressive + path: packages/navigation_rail_m3e + m3e_card_list: 1.0.0 + xdg_directories: 1.1.0 + package_info_plus: 10.2.1 + app_links: 7.2.1 + file_selector: 1.1.0 + material_ui: 1.5.0 + unifiedpush: 6.2.0 + unifiedpush_storage_shared_preferences: 1.0.0 + flutter_local_notifications: 22.3.1 + material_emoji_picker: 1.2.0 + xdg_desktop_portal: + git: + url: https://github.com/Henry-Hiles/xdg_desktop_portal.dart + ref: quad/nexus + dev_dependencies: - build_runner: ^2.15.1 - flutter_lints: 6.0.0 - freezed: 3.2.6-dev.1 - riverpod_lint: 3.1.4 - flutter_launcher_icons: 0.14.4 - json_serializable: 6.14.0 + build_runner: 2.16.1 + flutter_lints: 6.0.0 + freezed: 4.0.2 + riverpod_lint: 3.1.9 + flutter_launcher_icons: 0.14.4 + json_serializable: 6.14.1 + # Transitive dep but needed for commands + webcrypto: 0.6.0 flutter_launcher_icons: - ios: true - android: true - image_path: assets/icon.png - adaptive_icon_background: assets/background.png - adaptive_icon_foreground: assets/foreground.png - adaptive_icon_monochrome: assets/monochrome.png - remove_alpha_ios: true - windows: - generate: true \ No newline at end of file + image_path: assets/icon.png + ios: true + image_path_ios: assets/mobile.png + android: true + adaptive_icon_background: assets/background.png + adaptive_icon_foreground: assets/foreground.png + adaptive_icon_monochrome: assets/monochrome.png + windows: + generate: true + macos: + generate: true + image_path: assets/mobile.png diff --git a/scripts/generate.dart b/scripts/generate.dart index 0b0cf7c..d8a8cb1 100644 --- a/scripts/generate.dart +++ b/scripts/generate.dart @@ -1,4 +1,5 @@ import "dart:io"; + import "package:ffigen/ffigen.dart"; import "package:path/path.dart"; import "package:nexus/helpers/extensions/get_xcode_sdk.dart"; @@ -6,25 +7,38 @@ import "package:nexus/helpers/extensions/get_xcode_sdk.dart"; void main(List args) async { final repoDir = Directory.fromUri(Platform.script.resolve("../gomuks")); - print("Generating FFI Bindings..."); + print("Generating FFI bindings..."); final libclangPath = Platform.environment["LIBCLANG_PATH"]; FfiGenerator( - output: Output( - dartFile: Platform.script.resolve("../lib/src/third_party/gomuks.g.dart"), + output: .new( + dart: .new( + path: Platform.script.resolve("../lib/src/third_party/gomuks.g.dart"), + ), ), - headers: Headers( + visitors: [.new(func: (node) => node.isIncluded = true)], + input: .new( entryPoints: [File(join(repoDir.path, "pkg", "ffi", "gomuksffi.h")).uri], compilerOptions: [ "--no-warnings", - if (Platform.isMacOS) "-I${await getXCodeSDK()}/usr/include", + if (Platform.isMacOS) "-I${await getXCodeTool()}/usr/include", ], ), - functions: Functions.includeAll, ).generate( libclangDylib: libclangPath == null ? null - : Uri.file(join(libclangPath, "libclang.so")), + : Uri.file( + join( + libclangPath, + "libclang.${(Platform.isLinux || Platform.isAndroid) + ? "so" + : Platform.isMacOS + ? "dylib" + : Platform.isWindows + ? "dll" + : throw UnsupportedError("Unsupported Platform")}", + ), + ), ); - print("Done!"); + print("FFI bindings generated!"); } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 0694802..5d2b53e 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -13,6 +13,7 @@ #include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -30,6 +31,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WebcryptoPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WebcryptoPlugin")); WindowManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 238ef93..c74b2f5 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -10,10 +10,12 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_video screen_retriever_windows url_launcher_windows + webcrypto window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows ) set(PLUGIN_BUNDLED_LIBRARIES)