diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..a3abae1 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,39 @@ +name: "Build APK" + +on: + push: + branches: ["main"] + tags: ["*"] + workflow_dispatch: + +jobs: + build-apk: + runs-on: ubuntu-latest + + 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: 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" + 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 \ No newline at end of file diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml new file mode 100644 index 0000000..d280bc6 --- /dev/null +++ b/.github/workflows/flatpak.yml @@ -0,0 +1,37 @@ +name: "Build Flatpaks" + +on: + 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 }} + + 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: 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 diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000..eeb4868 --- /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 --release --no-codesign + + # 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..45d04d7 --- /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 --release + + - 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 c8099d1..4cb30cd 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -1,46 +1,64 @@ -name: "Build Windows Version" +name: "Build EXE" on: - workflow_dispatch: + push: + branches: ["main"] + tags: ["*"] + workflow_dispatch: jobs: - build-windows: - runs-on: "windows-latest" + build-exe: + runs-on: windows-latest - steps: - - name: "Checkout repository" - uses: "actions/checkout@v4" + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + submodules: recursive - - name: "Set up Flutter" - uses: "subosito/flutter-action@v2" + - 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 Rust" - uses: "dtolnay/rust-toolchain@stable" - with: - targets: "x86_64-pc-windows-msvc" + - name: Setup MSYS2 + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + install: >- + mingw-w64-x86_64-gcc - - name: "Install Flutter dependencies" - run: flutter pub get + - name: Go build + run: | + cd gomuks/pkg/ffi + go build -tags goolm,sqlite_fts5 -o ../../../libgomuks.dll -buildmode=c-shared - - name: "Run build_runner & build Windows EXE" - run: | - flutter pub run build_runner build --delete-conflicting-outputs - 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 --release - - name: "Upload exe zip" - uses: "actions/upload-artifact@v4" - with: - name: "windows-portable" - path: "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: Install Inno Setup + run: choco install innosetup -y - - name: "Build Inno Setup installer" - run: iscc windows/installer.iss + - name: Build Inno Setup installer + run: iscc windows/installer.iss - - name: "Upload installer artifact" - uses: "actions/upload-artifact@v4" - with: - name: "windows-installer" - path: "windows/dist/Nexus-Setup.exe" + - 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 d6616e1..2bec583 100644 --- a/.gitignore +++ b/.gitignore @@ -36,7 +36,9 @@ key.properties # Generated Files *.g.dart *.freezed.dart -src/ # Devel Password -password.txt \ No newline at end of file +password.txt + +# Nix +/result \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..06dd51a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "gomuks"] + path = gomuks + url = https://github.com/gomuks/gomuks diff --git a/.metadata b/.metadata index 6651909..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: windows - 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 25ea52b..fac2ed8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,8 +2,17 @@ "cSpell.words": [ "Appbar", "Displayname", + "fluttertagger", + "Gomuks", "Homeserver", + "Linkified", + "localpart", + "msgtype", + "muks", "prefs", - "vodozemac" + "unban", + "unifiedpush", + "unredact", + "webpush" ] } diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..d9c7c9d --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,70 @@ +# Development Documentation + +## Build instructions + +Build instructions can be found in [README.md](./README.md#build-it-yourself). + +## Updating Gomuks + +You can run the following command to update the Gomuks submodule: + +```sh +git submodule update --remote +``` + +## Code Style + +See [Effective Dart: Style](https://dart.dev/effective-dart/style) for general rules. There are some extra rules detailed below: + +### Controllers and Helpers ([Riverpod](https://pub.dev/packages/riverpod)) + +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.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. + +```dart +class MyController extends AsyncNotifier { + final SomeInputType input; + MyController(this.input); + + @override + Future build() async { + return input.foo; + } + + static final provider = + AsyncNotifierProvider.family( + AuthorController.new, + ); +} +``` + +Providers which are not controllers, e.g. they expose no data, only methods, should instead live in `lib/helpers/`. For an example, see `lib/helpers/launch_helper.dart`. Other, non-provider helpers, like extensions or helper methods can also go in `lib/helpers/`. + +### Don't use StatefulWidgets ([Flutter Hooks](https://pub.dev/packages/flutter_hooks)) + +This project uses Flutter Hooks to help with boilerplate that StatefulWidgets create. Instead of using a StatefulWidget, we just use hooks like `useState` or `useEffect` in the build method of a `HookWidget`, which is a drop in replacement for `StatelessWidget`. If you need both a `WidgetRef` to watch providers, and access to hooks, use `HookConsumerWidget`. + +### Models ([Freezed](https://pub.dev/packages/freezed)) + +We use Freezed for our models to avoid boilerplate and enforce an immutable style of state and data modeling throughout the code. See their documentation for more info, or see our existing models in `lib/models/`. + +### Immutable Data Collections ([Fast Immutable Collections](https://pub.dev/packages/fast_immutable_collections)) + +When possible, use immutable collections instead of the mutable equivalent. For example, use `IMap` over `Map`, `IList` over `List`, `ISet` over `Set`. This matches the immutable style of Riverpod and Freezed. + +### Don't create globals + +When possible, we prefer not to create global variables or methods. You can usually replace a global variable with a Riverpod controller, and a global method with an extension method. + +## LLM/AI Assisted Contributions + +LLM generated code is NOT allowed. All contributions should be written by humans. Using LLMs for interacting with others, e.g. for Comments, PRs, etc, is also not allowed. + +## Code of Conduct + +All contributions must follow the [Federated Nexus Code of Conduct](https://federated.nexus/code/). diff --git a/README.md b/README.md index 4f73220..1da8adb 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # Nexus Client > [!WARNING] -> Nexus Client is still heavily in development, and is not ready for use! +> Nexus Client is still in development, and doesn't support everything needed for daily use. ## Description -A simple and user-friendly Matrix client made with Flutter and the Matrix Dart SDK. +A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. ## Screenshots @@ -15,135 +15,163 @@ A simple and user-friendly Matrix client made with Flutter and the Matrix Dart S ## Progress -- [ ] New logo -- [ ] Make context menus appear as bottom sheets on mobile -- [x] Move from the Dart SDK to the Gomuks SDK with Dart bindings: https://git.federated.nexus/Henry-Hiles/nexus/pulls/2 - - [ ] Allow using remote gomuks over websocket -- [ ] Platform Support - - [x] Linux - - [x] Windows - - [ ] MacOS - - [ ] Android - - [ ] iOS - - [ ] Web (may not be possible) -- [x] Login - - [x] Username / password auth - - [ ] OAuth / OIDC - - [x] Improve initial sync experience -- [x] Rooms / Spaces - - [x] Displaying and choosing - - [x] Reading, showing unread - - [x] Mark as read button on rooms and spaces - - [ ] Searching - - [ ] Creating (Rooms, Spaces, and DMs) - - [x] Joining - - [ ] Parse vias - - [x] Using a text/uri/link - - [x] Plain text - - [x] `matrix:` Uri - - [x] Matrix.to link - - [ ] From space - - [ ] Exploring - - [x] Leaving - - [x] Subspaces -- [x] Messages - - [x] Encryption - - [x] Restoring crypto identity from a recovery passphrase/key - - [x] Sending - - [x] Plain text - - [x] HTML/Markdown - - [x] Replies - - [x] Choose ping on/off - - [ ] Per message profiles - - [ ] Attachments - - [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391) - - [x] Mentions - - [x] Users - - [x] Rooms - - [ ] Inline emoji picker (Putting this here since it'll be implemented the same way as mentions) - - [ ] Custom emojis/stickers - - [ ] GIFs using Gomuks' GIF proxies - - [x] Recieving - - [x] Plain text - - [x] Per message profiles - - [x] HTML - - [x] Replies - - [x] Viewing - - [ ] Jump to original message - - [x] In loaded timeline - - [ ] Out of loaded timeline - - [x] Edits - - [x] Attachments - - [x] Unencrypted - - [ ] Encrypted - - [x] Blurhashing - - [ ] Downloading attachments - - [x] Opening attachments in their own view - - [ ] Polls: Waiting on https://github.com/SwanFlutter/dynamic_polls/issues/1 - - [x] Mentions - - [x] Users - - [x] Rooms - - [ ] Plain text (not sure if I want to add this or not, I probably won't unless there's interest) - - [x] Matrix URIs - - [x] Matrix.to links - - [ ] Do some fancy fetching to get nice names - - [ ] Make clickable - - [x] Custom emojis/stickers - - [x] History loading - - [x] Backwards - - [ ] Forwards - - [x] Editing - - [x] Deleting -- [ ] Reactions: Waiting on https://github.com/flyerhq/flutter_chat_ui/pull/838 or me doing a custom impl -- [ ] Pins - - [ ] Displaying - - [ ] Creating -- [ ] Threads -- [ ] Profile popouts -- [ ] Copy link to [room, space] -- [ ] Reporting - - [x] Events - - [ ] Rooms -- [ ] Notifications using UnifiedPush -- [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195) -- [ ] Invites -- [ ] Settings - - [ ] Light/Dark mode - - [ ] SSD or CSD - - [ ] Show media by default - - [ ] Dynamic Theming - - [ ] Devices - - [ ] Viewing devices - - [ ] Verifying devices - - [ ] URL preview: Server / Client / None - - [ ] Account changes - - [ ] Display name - - [ ] Profile picture - - [ ] Timezone - - [ ] Pronouns - - [ ] Password - - [ ] About - - [x] Log Out +- [x] Platform Support + - [x] Linux + - [x] Windows + - Currently has some bugs, due to us not having any developers on Windows. If you want to fix these, get in contact with us though the [Nexus Client Matrix room](https://matrix.to/#/#nexus:federated.nexus). + - [x] Android + - [x] MacOS + - [x] Unsigned .ipa + - [ ] App Store + - [x] iOS + - [x] Unsigned .ipa + - [ ] App Store + - [ ] Web (may not be possible) +- [x] Login (via OAuth) +- [x] Rooms / Spaces + - [x] Displaying and choosing + - [x] Reading, showing unread + - [x] Mark as read button on rooms and spaces + - [ ] Searching + - [ ] Creating (Rooms, Spaces, and DMs) + - [x] Joining + - [x] Parse vias + - [x] Using a text/uri/link + - [x] Plain text + - [x] `matrix:` Uri + - [x] Matrix.to link + - [ ] From space + - [ ] From directory + - [x] Leaving + - [x] Subspaces +- [x] Messages + - [x] Encryption + - [x] Restoring crypto identity from a recovery passphrase/key + - [x] Sending + - [x] Plain text + - [x] HTML/Markdown + - [x] Replies + - [x] Choose ping on/off + - [x] Per message profiles + - [x] Attachments + - [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391) + - [x] Tags + - [x] Users + - [x] Rooms + - [x] Emojis + - [ ] Custom emojis/stickers + - [ ] GIFs using Gomuks' GIF proxies + - [x] Receiving + - [x] Plain text + - [x] Per message profiles + - [x] HTML + - [x] URL Previews + - [x] Replies + - [x] Viewing + - [x] Jump to original message + - [x] Edits + - [x] Attachments + - [x] Unencrypted + - [x] Encrypted + - [x] Blurhashing + - [ ] Downloading attachments + - [x] Opening attachments in their own view + - [ ] Polls + - [x] Mentions + - [x] Users + - [x] Clickable + - [x] Rooms + - [ ] Clickable + - [x] Matrix URIs + - [x] Matrix.to links + - [x] Events + - [ ] Render more nicely + - [ ] Clickable + - [x] Custom emojis/stickers + - [x] History loading + - [x] Editing + - [x] Deleting +- [x] Reactions +- [x] Pins + - [x] Displaying + - [x] Pinning/Unpinning +- [ ] Threads +- [x] Profile popouts + - [x] Working actions +- [x] Copy link to: + - [x] Room + - [x] Space + - [x] Message +- [ ] Reporting + - [x] Events + - [ ] Rooms +- [x] Member list + - [x] Sort by power level + - [ ] Colors based off of power level +- [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 -## Build Instructions +## Try it out -First, clone and open the repo: +If you want to try out Nexus, grab one of the following artifacts from CI: -```sh -git clone https://git.federated.nexus/Henry-Hiles/nexus -cd nexus -``` +- [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) +- [NixOS Module](linux/nix/module.nix) + +## Build it yourself ### Prerequisites #### Linux -- With Nix: Either use direnv, or `nix flake develop` -- Without Nix: Install Flutter, Go, Olm, Git, Clang, and GLibc. +- On NixOS: Either use direnv and `direnv allow`, or `nix flake 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 / MacOS +#### Windows -I don't really know. You will need Flutter, Git, Olm, Go, and Visual Studio tools, and otherwise I guess just keep installing stuff until there aren't any errors. I will look into this sometimeTM. +You will need: + +- Flutter +- Git +- Libass +- MPV +- Go +- Visual Studio 2022 (Desktop development with C++) +- [MSYS2/MinGW-w64 GCC](https://www.msys2.org/) (for CGO) +- [LLVM/Clang + libclang](https://clang.llvm.org/get_started.html) (for `ffigen`) + +On Windows, make sure these are available in your shell `PATH`: + +- `C:\msys64\ucrt64\bin` (or your MinGW bin path containing `x86_64-w64-mingw32-gcc.exe`) +- `C:\Program Files\LLVM\bin` (contains `clang.exe` and `libclang.dll`) + +For `dart scripts/generate.dart`, you may also need: + +```powershell +$env:CPATH = "C:\msys64\ucrt64\include" +``` + +#### MacOS + +Similar prerequisites apply (Flutter, Git, Go, C toolchain, LLVM/libclang), but exact setup has not been fully documented yet. + +### Clone repo + +First, clone and open the repo: + +```sh +git clone --recurse-submodules https://git.federated.nexus/Nexus/nexus +cd nexus +``` ### Set up Flutter @@ -153,22 +181,29 @@ Get dependencies: flutter pub get ``` -Get dependencies: +Generate Gomuks bindings: ```sh -flutter pub get +dart scripts/generate.dart ``` -Clone Gomuks and generate bindings: +> [!NOTE] +> If you are having issues with `stddef.h` not being found, try setting CPATH manually: +> +> ```sh +> export CPATH="$(clang -v 2>&1 | grep "Selected GCC installation" | rev | cut -d' ' -f1 | rev)/include" +> ``` + +Build webcrypto: ```sh -scripts/generate.sh +flutter pub run webcrypto:setup ``` Build generated files, and watch for new changes: ```sh -flutter pub run build_runner watch --delete-conflicting-outputs +flutter pub run build_runner watch ``` Run the app: @@ -177,6 +212,13 @@ Run the app: flutter run ``` +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 + +Thank you Hylke Bons (https://planetpeanut.studio) for making the amazing icon for Nexus! +Thank you Tulir Asokan for making [Gomuks](https://github.com/gomuks/gomuks), and helping us integrate it into Nexus! 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 ce5f465..0000000 --- a/android/app/build.gradle +++ /dev/null @@ -1,72 +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 - } - - defaultConfig { - applicationId = "nexus.federated.Nexus" - minSdk = 29 - targetSdkVersion flutter.targetSdkVersion - versionCode = flutterVersionCode.toInteger() - versionName = flutterVersionName - } - - signingConfigs { - release { - keyAlias "key" - storeFile keystoreProperties['path'] ? file(keystoreProperties['path']) : file(System.getenv("KEYSTORE_PATH")) - 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 1c369c9..a0a0bcc 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -10,7 +10,7 @@ android:label="Nexus" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" - android:roundIcon="@mipmap/nexus_round" + android:roundIcon="@mipmap/ic_launcher" android:allowBackup="false" android:fullBackupContent="false"> + + + + + + + + - + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 80efd04..2721cd8 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index b02e5ef..4e9192d 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 54aed69..f18b718 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index eb2221d..2f6a559 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index c5ac464..0118074 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/build.gradle b/android/build.gradle deleted file mode 100644 index 11bc662..0000000 --- a/android/build.gradle +++ /dev/null @@ -1,26 +0,0 @@ -allprojects { - repositories { - google() - mavenCentral() - } -} - -rootProject.buildDir = "../build" -subprojects { - project.buildDir = "${rootProject.buildDir}/${project.name}" - - afterEvaluate {project -> - 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.properties b/android/gradle.properties index 3b5b324..1551eb0 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,7 @@ org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false 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/background.png b/assets/background.png new file mode 100644 index 0000000..9f1d8e7 Binary files /dev/null and b/assets/background.png differ diff --git a/assets/background.svg b/assets/background.svg new file mode 100644 index 0000000..749e03a --- /dev/null +++ b/assets/background.svg @@ -0,0 +1,257 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/bundled/icon.svg b/assets/bundled/icon.svg new file mode 100644 index 0000000..b36fa26 --- /dev/null +++ b/assets/bundled/icon.svg @@ -0,0 +1,334 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/fonts/NotoColorEmoji.ttf b/assets/fonts/NotoColorEmoji.ttf new file mode 100644 index 0000000..b652015 Binary files /dev/null and b/assets/fonts/NotoColorEmoji.ttf differ diff --git a/assets/fonts/Roboto.ttf b/assets/fonts/Roboto.ttf new file mode 100644 index 0000000..01656a3 Binary files /dev/null and b/assets/fonts/Roboto.ttf differ diff --git a/assets/foreground.png b/assets/foreground.png index 4249989..a98eb11 100644 Binary files a/assets/foreground.png and b/assets/foreground.png differ diff --git a/assets/foreground.svg b/assets/foreground.svg index 4f2f2b2..9aad561 100644 --- a/assets/foreground.svg +++ b/assets/foreground.svg @@ -1,20 +1,19 @@ - - + + inkscape:current-layer="svg11" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icon.png b/assets/icon.png index 04b75cb..d6d4906 100644 Binary files a/assets/icon.png and b/assets/icon.png differ diff --git a/assets/icon.svg b/assets/icon.svg deleted file mode 100644 index 0effd9a..0000000 --- a/assets/icon.svg +++ /dev/null @@ -1,150 +0,0 @@ - - - - diff --git a/assets/mobile.png b/assets/mobile.png new file mode 100644 index 0000000..6b1b81c Binary files /dev/null and b/assets/mobile.png differ diff --git a/assets/mobile.svg b/assets/mobile.svg new file mode 100644 index 0000000..7ca0a7d --- /dev/null +++ b/assets/mobile.svg @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/monochrome.png b/assets/monochrome.png new file mode 100644 index 0000000..941c706 Binary files /dev/null and b/assets/monochrome.png differ diff --git a/assets/monochrome.svg b/assets/monochrome.svg new file mode 100644 index 0000000..a86f36e --- /dev/null +++ b/assets/monochrome.svg @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/screenshotDark.png b/assets/screenshotDark.png index ae75dc7..322a64e 100644 Binary files a/assets/screenshotDark.png and b/assets/screenshotDark.png differ diff --git a/assets/screenshotLight.png b/assets/screenshotLight.png index 0b2ce0d..8772bcf 100644 Binary files a/assets/screenshotLight.png and b/assets/screenshotLight.png differ diff --git a/assets/twim/notif.webp b/assets/twim/notif.webp new file mode 100644 index 0000000..d8d9010 Binary files /dev/null and b/assets/twim/notif.webp differ diff --git a/flake.lock b/flake.lock index 7826732..efbcc03 100644 --- a/flake.lock +++ b/flake.lock @@ -5,11 +5,11 @@ "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1767609335, - "narHash": "sha256-feveD98mQpptwrAEggBQKJTYbvwwglSbOv53uCfH9PY=", + "lastModified": 1788450739, + "narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "250481aafeb741edfe23d29195671c19b36b6dca", + "rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993", "type": "github" }, "original": { @@ -18,13 +18,82 @@ "type": "github" } }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nix2flatpak": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1790043059, + "narHash": "sha256-7ID4HL4IrrlalCsxMASkcAr+v0VHTNFp6+sZ+dpvOV8=", + "owner": "Henry-Hiles", + "repo": "nix2flatpak", + "rev": "290dd282e555cab988c021c2be5f2361e58c4d51", + "type": "github" + }, + "original": { + "owner": "Henry-Hiles", + "ref": "quad/nexus", + "repo": "nix2flatpak", + "type": "github" + } + }, "nixpkgs": { "locked": { - "lastModified": 1767640445, - "narHash": "sha256-UWYqmD7JFBEDBHWYcqE6s6c77pWdcU/i+bwD6XxMb8A=", + "lastModified": 1773389992, + "narHash": "sha256-wvfdLLWJ2I9oEpDd9PfMA8osfIZicoQ5MT1jIwNs9Tk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c06b4ae3d6599a672a6210b7021d699c351eebda", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1788057806, + "narHash": "sha256-DTQSMxzDWmT0zhguthvegnVkn7CFqGCv4IHCzk5ZUpM=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "596e2e3940e09b2abbeb03f75fa1828c57fcd72c", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1790185690, + "narHash": "sha256-xJ+X4hBtOcAFGBOe5nAMyMUeF9foJBmIOu3NjBqBycU=", "owner": "nixos", "repo": "nixpkgs", - "rev": "9f0c42f8bc7151b8e7e5840fb3bd454ad850d8c5", + "rev": "4975466d324710c576dc11ad614684e6bd8cad8e", "type": "github" }, "original": { @@ -34,25 +103,26 @@ "type": "github" } }, - "nixpkgs-lib": { - "locked": { - "lastModified": 1765674936, - "narHash": "sha256-k00uTP4JNfmejrCLJOwdObYC9jHRrr/5M/a/8L2EIdo=", - "owner": "nix-community", - "repo": "nixpkgs.lib", - "rev": "2075416fcb47225d9b68ac469a5c4801a9c4dd85", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "nixpkgs.lib", - "type": "github" - } - }, "root": { "inputs": { "flake-parts": "flake-parts", - "nixpkgs": "nixpkgs" + "nix2flatpak": "nix2flatpak", + "nixpkgs": "nixpkgs_2" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index de21b13..97d6068 100644 --- a/flake.nix +++ b/flake.nix @@ -2,8 +2,10 @@ description = "Nexus Flutter Flake"; inputs = { + self.submodules = true; nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; flake-parts.url = "github:hercules-ci/flake-parts"; + nix2flatpak.url = "github:Henry-Hiles/nix2flatpak/quad/nexus"; }; outputs = @@ -14,55 +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 = { - permittedInsecurePackages = [ "olm-3.2.16" ]; - android_sdk.accept_license = true; - allowUnfree = true; - }; - }; - - devShells = - let - packages = with pkgs; [ - go - olm - git - ]; - - env = { - LIBCLANG_PATH = lib.makeLibraryPath [ pkgs.libclang ]; - LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}"; - CPATH = lib.makeSearchPath "include" [ pkgs.glibc.dev ]; - }; - in - { - default = pkgs.mkShell { - inherit env; - packages = packages ++ [ - pkgs.flutter - ]; - }; - - nix = pkgs.mkShell { inherit packages env; }; - }; - }; }; } diff --git a/gomuks b/gomuks new file mode 160000 index 0000000..480938f --- /dev/null +++ b/gomuks @@ -0,0 +1 @@ +Subproject commit 480938f9e01e7eae2cf249657efae03df76599d6 diff --git a/hook/build.dart b/hook/build.dart index 4cb2f91..9315e04 100644 --- a/hook/build.dart +++ b/hook/build.dart @@ -1,40 +1,196 @@ import "dart:io"; + +import "package:collection/collection.dart"; import "package:hooks/hooks.dart"; import "package:code_assets/code_assets.dart"; +import "package:nexus/helpers/extensions/get_xcode_sdk.dart"; +import "package:path/path.dart"; Future main(List args) => build(args, (input, output) async { - final buildDir = input.packageRoot.resolve("src/"); - if (await File(buildDir.resolve("lock").toFilePath()).exists()) return; + if (!input.config.buildCodeAssets) return; + final codeConfig = input.config.code; + final targetOS = codeConfig.targetOS; + final targetArch = codeConfig.targetArchitecture; - final targetOS = input.config.code.targetOS; 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 getXCodeTool(), + "MACOSX_DEPLOYMENT_TARGET": codeConfig.macOS.targetVersion.toString(), + }; break; case OS.windows: libFileName = "libgomuks.dll"; break; + case OS.android: + libFileName = "libgomuks.so"; + + final targetNdkApi = codeConfig.android.targetNdkApi; + + Future findNdkFromSdk() async { + final androidHome = + Platform.environment["ANDROID_HOME"] ?? + Platform.environment["ANDROID_SDK_ROOT"]; + if (androidHome == null) return null; + + final ndkDir = Directory(join(androidHome, "ndk")); + if (!await ndkDir.exists()) return null; + + final versions = await ndkDir.list().toList(); + return versions.sortedBy((file) => file.path).lastOrNull?.path; + } + + final ndkHome = + Platform.environment["ANDROID_NDK_HOME"] ?? + Platform.environment["ANDROID_NDK_ROOT"] ?? + Platform.environment["NDK_HOME"] ?? + await findNdkFromSdk(); + + if (ndkHome == null) { + throw Exception( + "Could not find Android NDK. Set ANDROID_NDK_HOME or install via sdkmanager.", + ); + } + + // TODO: Someone please give me a way to detect host architecture so I can change this + final hostTag = + Platform.environment["ANDROID_HOST_TAG"] ?? + "${Platform.operatingSystem}-x86_64"; + final ccTriple = switch (targetArch) { + Architecture.arm64 => "aarch64-linux-android", + Architecture.arm => "armv7a-linux-androideabi", + Architecture.x64 => "x86_64-linux-android", + Architecture.ia32 => "i686-linux-android", + _ => throw UnsupportedError( + "Unsupported Android architecture: $targetArch", + ), + }; + final cc = + "$ndkHome/toolchains/llvm/prebuilt/$hostTag/bin/$ccTriple$targetNdkApi-clang"; + + extraEnv = {"GOOS": "android", "CC": cc}; + break; default: throw UnsupportedError("Unsupported OS: $targetOS"); } - final gomuksBuildDir = buildDir.resolve("gomuks/"); - final libFile = gomuksBuildDir.resolve(libFileName); + var libFile = input.packageRoot.resolve(libFileName); + final gomuksBuildDir = input.packageRoot.resolve("gomuks/"); - print("Building Gomuks shared library $libFileName from source..."); - final result = await Process.run("go", [ - "build", - "-o", - libFile.path, - "-buildmode=c-shared", - ], workingDirectory: gomuksBuildDir.resolve("source/pkg/ffi/").toFilePath()); + if (!(await File.fromUri(libFile).exists())) { + final buildDir = input.packageRoot.resolve("build/"); + libFile = buildDir.resolve("${targetArch.name}/$libFileName"); - if (result.exitCode != 0) { - throw Exception("Failed to build Gomuks shared library\n${result.stderr}"); + final tags = [ + "sqlite_fts5", + "goolm", + // 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 ${archiveFile.path}...", + ); + final result = await Process.run( + "go", + [ + "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", + "GOARCH": switch (targetArch) { + Architecture.arm64 => "arm64", + Architecture.arm => "arm", + Architecture.x64 => "amd64", + Architecture.ia32 => "386", + _ => throw UnsupportedError("Unsupported architecture: $targetArch"), + }, + ...extraEnv, + }, + ); + + if (result.exitCode != 0) { + throw Exception( + "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/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 6f637da..391a902 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -2,25 +2,23 @@ - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - nexus.federated.Nexus - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - MinimumOSVersion - 15.5 + 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/Podfile b/ios/Podfile index 9c6fa1f..620e46e 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,4 +1,7 @@ -platform :ios, 16 +# 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', { @@ -26,30 +29,15 @@ flutter_ios_podfile_setup target 'Runner' do use_frameworks! - use_modular_headers! - + 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| - target.build_configurations.each do |config| - if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] == '8.0' - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '11.0' - end - end - flutter_additional_ios_build_settings target + flutter_additional_ios_build_settings(target) end - - ################ Awesome Notifications pod modification 1 ################### - awesome_pod_file = File.expand_path(File.join('plugins', 'awesome_notifications', 'ios', 'Scripts', 'AwesomePodFile'), '.symlinks') - require awesome_pod_file - update_awesome_pod_build_settings(installer) - ################ Awesome Notifications pod modification 1 ################### end - -################ Awesome Notifications pod modification 2 ################### -awesome_pod_file = File.expand_path(File.join('plugins', 'awesome_notifications', 'ios', 'Scripts', 'AwesomePodFile'), '.symlinks') -require awesome_pod_file -update_awesome_main_target_settings('Runner', File.dirname(File.realpath(__FILE__)), flutter_root) -################ Awesome Notifications pod modification 2 ################### 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 index d4af5a1..065d08b 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -8,14 +8,28 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 3965FF1D950A2B2E051F6219 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5E598BBDD2ED0112E344B590 /* Pods_Runner.framework */; }; + 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; @@ -32,12 +46,19 @@ /* 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 = ""; }; - 5E598BBDD2ED0112E344B590 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 6A2D47C573B065AD356C7183 /* 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 = ""; }; + 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; }; @@ -45,35 +66,67 @@ 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 = ""; }; - CA00A9F43BB1AB7D908E180A /* 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 = ""; }; - DFFCB3F45BF495277AE4D3EE /* 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 = ""; }; + 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 = ( - 3965FF1D950A2B2E051F6219 /* Pods_Runner.framework in Frameworks */, + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + 88D55A5F1C7625122EBEE29D /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 7226BEAE3D25CA120A434CC9 /* Pods */ = { + 1CFFAE23B502BCCEB2B191A3 /* Pods */ = { isa = PBXGroup; children = ( - DFFCB3F45BF495277AE4D3EE /* Pods-Runner.debug.xcconfig */, - CA00A9F43BB1AB7D908E180A /* Pods-Runner.release.xcconfig */, - 6A2D47C573B065AD356C7183 /* Pods-Runner.profile.xcconfig */, + 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 */, @@ -88,8 +141,9 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, - 7226BEAE3D25CA120A434CC9 /* Pods */, - E396C277672D83FCFDB51CCB /* Frameworks */, + 331C8082294A63A400263BE5 /* RunnerTests */, + 1CFFAE23B502BCCEB2B191A3 /* Pods */, + 594EB1F65336CF9C7958F6FA /* Frameworks */, ); sourceTree = ""; }; @@ -97,6 +151,7 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, ); name = Products; sourceTree = ""; @@ -111,41 +166,55 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; sourceTree = ""; }; - E396C277672D83FCFDB51CCB /* Frameworks */ = { - isa = PBXGroup; - children = ( - 5E598BBDD2ED0112E344B590 /* Pods_Runner.framework */, - ); - name = Frameworks; - 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 = ( - A3B5CB395514F4BF9CDF304E /* [CP] Check Pods Manifest.lock */, + 0830C6758DA3ECA81FB12386 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - C3C8545BDAC52D360D298A29 /* [CP] Embed Pods Frameworks */, - F46F66CC32F585BBF2FB675E /* [CP] Copy Pods Resources */, + 1D165FDFB9D0D939B041966D /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -160,6 +229,10 @@ LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; @@ -175,16 +248,27 @@ 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; @@ -199,6 +283,45 @@ /* 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; @@ -230,7 +353,7 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - A3B5CB395514F4BF9CDF304E /* [CP] Check Pods Manifest.lock */ = { + EE82EC529EB6C0F1FA480659 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -245,61 +368,44 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + "$(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; }; - C3C8545BDAC52D360D298A29 /* [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; - }; - F46F66CC32F585BBF2FB675E /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\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; @@ -324,6 +430,7 @@ 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++"; @@ -353,6 +460,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -374,13 +482,10 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - APPLICATION_EXTENSION_API_ONLY = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = AppIcon; - BUILD_LIBRARY_FOR_DISTRIBUTION = NO; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = P3ZD9TNSKZ; + DEVELOPMENT_TEAM = PK66NJM372; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -395,10 +500,61 @@ }; 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++"; @@ -428,6 +584,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -454,6 +611,7 @@ 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++"; @@ -483,6 +641,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -506,13 +665,10 @@ isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { - APPLICATION_EXTENSION_API_ONLY = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = AppIcon; - BUILD_LIBRARY_FOR_DISTRIBUTION = NO; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = P3ZD9TNSKZ; + DEVELOPMENT_TEAM = PK66NJM372; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -532,13 +688,10 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - APPLICATION_EXTENSION_API_ONLY = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = AppIcon; - BUILD_LIBRARY_FOR_DISTRIBUTION = NO; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = P3ZD9TNSKZ; + DEVELOPMENT_TEAM = PK66NJM372; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -556,6 +709,16 @@ /* 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 = ( @@ -577,6 +740,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 = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e3773d4..c3fedb2 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + Bool { - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) + 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/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index 2b21522..1682af0 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png 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 index 8471cd6..51f4a9b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png 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 index c145b15..97b3b3f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png 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 index 5da5679..17d971e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png 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 index cd2b74f..ca74554 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png 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 index 68cbdbf..6129702 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png 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 index 306efe8..16c3d1e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png 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 index c145b15..97b3b3f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png 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 index 959cc28..f515ebd 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png 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 index d86b69c..2e49a13 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png 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 index 3a5c49b..66b9ef2 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png 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 index e563327..53af9ff 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png 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 index 30ae8c6..455803d 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png 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 index 2fb68c4..7a2fd0f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png 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 index d86b69c..2e49a13 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png 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 index 151862a..c34ad9e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png 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 index c5ca065..5e21545 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png 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 index a5880bd..2140e17 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png 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 index 6ea8156..a27d854 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png 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 index 657cf77..8b4eb75 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png 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 index 87d1ce7..21c424b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index b89f32a..7680b42 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -3,7 +3,7 @@ CADisableMinimumFrameDurationOnPhone - + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -22,30 +22,40 @@ $(FLUTTER_BUILD_NAME) CFBundleSignature ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + nexus.federated.nexus + + + CFBundleVersion $(FLUTTER_BUILD_NUMBER) - LSSupportsOpeningDocumentsInPlace - - LSApplicationQueriesSchemes - - file - https - http - mailto - tel - - UIFileSharingEnabled - - ITSAppUsesNonExemptEncryption - LSRequiresIPhoneOS - NSCameraUsageDescription - This app needs camera access to scan asset QR codes - NSMicrophoneUsageDescription - This app doesn't use your microphone. - NSPhotoLibraryUsageDescription - This app doesn't use your photo library. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + UIApplicationSupportsIndirectInputEvents UILaunchStoryboardName 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 new file mode 100644 index 0000000..c595565 --- /dev/null +++ b/lib/controllers/account_data.dart @@ -0,0 +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 { + @override + AccountData build() => .new(); + + 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, + ); +} diff --git a/lib/controllers/account_data_controller.dart b/lib/controllers/account_data_controller.dart deleted file mode 100644 index 125d7cf..0000000 --- a/lib/controllers/account_data_controller.dart +++ /dev/null @@ -1,16 +0,0 @@ -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> { - @override - IMap build() => const IMap.empty(); - - void update(IMap newData) => - state = IMap({...state.unlock, ...newData.unlock}); - - static final provider = - NotifierProvider>( - AccountDataController.new, - ); -} diff --git a/lib/controllers/attachment.dart b/lib/controllers/attachment.dart new file mode 100644 index 0000000..d4a5e09 --- /dev/null +++ b/lib/controllers/attachment.dart @@ -0,0 +1,36 @@ +import "package:file_selector/file_selector.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/message.dart"; +import "package:path/path.dart"; + +class AttachmentController(final String roomId) + extends Notifier<(String, MessageContent?)?> { + @override + Null build() => null; + + Future add(XFile file) async { + final filename = basename(file.path); + state = (filename, null); + + 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: isEncrypted)); + + state = (filename, content); + } + + static final provider = NotifierProvider.family + .autoDispose( + AttachmentController.new, + ); +} diff --git a/lib/controllers/auth_url.dart b/lib/controllers/auth_url.dart new file mode 100644 index 0000000..963df86 --- /dev/null +++ b/lib/controllers/auth_url.dart @@ -0,0 +1,28 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +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(final Uri homeserver) + extends AsyncNotifier { + @override + Future build() async => ref + .watch(ClientController.provider.notifier) + .getAuthUrl( + .new( + homeserverUrl: homeserver, + redirectUri: .new(scheme: "nexus.federated.nexus", path: "/"), + responseMode: .query, + scopes: .new([Scope.clientApi, Scope.device]), + clientId: await ref.watch( + ClientIdController.provider(homeserver).future, + ), + ), + ); + + static final provider = AsyncNotifierProvider.family + .autoDispose( + AuthUrlController.new, + ); +} diff --git a/lib/controllers/author.dart b/lib/controllers/author.dart new file mode 100644 index 0000000..f9c9167 --- /dev/null +++ b/lib/controllers/author.dart @@ -0,0 +1,28 @@ +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(final Event event) + extends AsyncNotifier { + @override + Future build() async { + final member = await ref.watch( + UserController.provider(.new(roomId: event.roomId, userId: event.sender)) + .future, + ); + + return .new( + status: member.status, + avatarUrl: event.pmp?.avatarUrl ?? member.avatarUrl, + displayName: event.pmp?.displayName ?? member.displayName, + ); + } + + static final provider = + AsyncNotifierProvider.family( + AuthorController.new, + ); +} diff --git a/lib/controllers/author_controller.dart b/lib/controllers/author_controller.dart deleted file mode 100644 index c7e4e05..0000000 --- a/lib/controllers/author_controller.dart +++ /dev/null @@ -1,44 +0,0 @@ -import "dart:async"; -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/members_controller.dart"; -import "package:nexus/models/configs/author_config.dart"; -import "package:nexus/models/membership.dart"; - -class AuthorController extends AsyncNotifier { - final AuthorConfig config; - AuthorController(this.config); - - @override - Future build() async { - var member = await ref.watch( - MembersController.provider(config.room).selectAsync( - (value) => value.firstWhereOrNull( - (membership) => membership.userId == config.message.authorId, - ), - ), - ); - - final pmp = config.message.metadata?["pmp"] == null - ? null - : Membership.fromContent( - IMap(config.message.metadata?["pmp"]), - config.message.authorId, - ); - - return Membership( - avatarUrl: pmp?.avatarUrl ?? member?.avatarUrl, - displayName: - pmp?.displayName ?? - member?.displayName ?? - config.message.authorId.substring(1).split(":").first, - userId: config.message.authorId, - ); - } - - static final provider = AsyncNotifierProvider.family - .autoDispose( - AuthorController.new, - ); -} diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart new file mode 100644 index 0000000..4d0c972 --- /dev/null +++ b/lib/controllers/client.dart @@ -0,0 +1,323 @@ +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/cupertino.dart"; +import "package:intl/intl.dart"; +import "package:nexus/helpers/extensions/gomuks_buffer.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_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/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"; + +class ClientController extends AsyncNotifier { + @override + Future build() async { + 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); + } + } + } + + debugPrint("Initializing Gomuks..."); + final handle = await Isolate.run(() { + final bufferPointer = GomuksConfig( + matrix: .new( + initialDeviceDisplayName: + "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}", + ), + ).toJson().toGomuksBufferPtr(); + + try { + return GomuksInit(bufferPointer.ref); + } finally { + calloc + ..free(bufferPointer.ref.base) + ..free(bufferPointer); + } + }); + + ref.onDispose(() => GomuksDestroy(handle)); + + return handle; + } + + 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.toJson(); + + 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 ( + Event.fromJson(response["event"]), + RoomMetadata.fromJson(response["room"]), + ); + } + + dynamic _sendCommand( + String command, [ + Map data = const {}, + ]) => callGomuksMethod(data, (handle, data) { + final commandPointer = command.toNativeUtf8().cast(); + try { + return GomuksSubmitCommand(handle, commandPointer, data); + } finally { + calloc.free(commandPointer); + } + }); + + Future redactEvent(RedactEventRequest report) => + _sendCommand("redact_event", report.toJson()); + + Future sendMessage(SendMessageRequest request) async => + Event.fromJson(await _sendCommand("send_message", request.toJson())); + + Future sendEvent(SendEventRequest request) async { + final json = request.toJson(); + final content = request.content.toJson(); + + return Event.fromJson( + await _sendCommand("send_event", { + ...json, + "content": { + ...content, + "m.relates_to": { + ...((content["m.relates_to"] as Map?) ?? {}), + "event_id": request.relatesTo, + "rel_type": request.relationType, + }, + }, + }), + ); + } + + Future setState(SetStateRequest request) async => + await _sendCommand("set_state", request.toJson()); + + Future verify(String recoveryKey) async { + try { + await _sendCommand("verify", {"recovery_key": recoveryKey}); + return null; + } catch (error) { + return error.toString(); + } + } + + Future joinRoom(JoinRoomRequest request) async => + (await _sendCommand("join_room", request.toJson()))["room_id"]; + + 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}); + } + + Future> getRoomState(GetRoomStateRequest request) async { + Future getState(GetRoomStateRequest request) async => + (await _sendCommand("get_room_state", request.toJson())) as List?; + final response = await getState(request); + + return .new( + (response ?? await getState(request.copyWith(refetch: true)) ?? []).map( + (event) => .fromJson(event), + ), + ); + } + + Future?> getRelatedEvents( + GetRelatedEventsRequest request, + ) async { + final response = + (await _sendCommand("get_related_events", request.toJson())) as List?; + 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 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 ProfileResponse(profile: .new(id: userId)); + } + } + + Future reportEvent(ReportRequest request) => + _sendCommand("report_event", request.toJson()); + + 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())); + + Future downloadMedia(DownloadMediaRequest request) async => + .new((await _sendCommand("download_media", request.toJson()))["path"]); + + 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) return; + + await _sendCommand("mark_read", { + "room_id": room.metadata!.id, + "receipt_type": "m.read", + "event_id": event.eventId, + }); + } + + Future registerClient(OAuthRegisterClientRequest request) async => + (await _sendCommand( + "oauth_register_client", + request.toJson(), + ))["client_id"]; + + Future getAuthUrl(OAuthGetAuthUrl request) async => + .fromJson( + await _sendCommand("oauth_get_authorization_url", request.toJson()), + ); + + Future exchangeToken(OAuthExchangeTokenRequest request) async => + await _sendCommand("oauth_exchange_token", request.toJson()); + + Future getSpecVersions() async => + .fromJson(await _sendCommand("get_versions")); + + Future getCapabilities() async => Capabilities.fromJson( + (await _sendCommand("get_capabilities"))["capabilities"], + ); + + Future discoverHomeserver(Uri homeserver) async { + try { + final response = await _sendCommand("discover_homeserver", { + "user_id": "@fake-user:${homeserver.authority}", + }); + return Uri.parse(response["m.homeserver"]?["base_url"]); + } catch (error) { + return null; + } + } + + static final provider = AsyncNotifierProvider( + ClientController.new, + ); +} diff --git a/lib/controllers/client_controller.dart b/lib/controllers/client_controller.dart deleted file mode 100644 index de6e909..0000000 --- a/lib/controllers/client_controller.dart +++ /dev/null @@ -1,244 +0,0 @@ -import "dart:developer"; -import "dart:ffi"; -import "dart:isolate"; -import "package:collection/collection.dart"; -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_controller.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/init_complete_controller.dart"; -import "package:nexus/controllers/rooms_controller.dart"; -import "package:nexus/controllers/space_edges_controller.dart"; -import "package:nexus/controllers/sync_status_controller.dart"; -import "package:nexus/controllers/top_level_spaces_controller.dart"; -import "package:nexus/helpers/extensions/gomuks_buffer.dart"; -import "package:nexus/models/client_state.dart"; -import "package:nexus/models/event.dart"; -import "package:nexus/models/paginate.dart"; -import "package:nexus/models/requests/get_event_request.dart"; -import "package:nexus/models/requests/get_related_events_request.dart"; -import "package:nexus/models/requests/get_room_state_request.dart"; -import "package:nexus/models/requests/join_room_request.dart"; -import "package:nexus/models/requests/login_request.dart"; -import "package:nexus/models/profile.dart"; -import "package:nexus/models/requests/paginate_request.dart"; -import "package:nexus/models/requests/redact_event_request.dart"; -import "package:nexus/models/requests/report_request.dart"; -import "package:nexus/models/requests/send_message_request.dart"; -import "package:nexus/models/room.dart"; -import "package:nexus/models/sync_data.dart"; -import "package:nexus/models/sync_status.dart"; -import "package:nexus/src/third_party/gomuks.g.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; - -class ClientController extends AsyncNotifier { - @override - Future build() async { - final handle = await Isolate.run(GomuksInit); - - 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(ClientState.fromJson(decodedMuksEvent)); - break; - case "sync_status": - ref - .watch(SyncStatusController.provider.notifier) - .set(SyncStatus.fromJson(decodedMuksEvent)); - break; - case "init_complete": - ref.watch(InitCompleteController.provider.notifier).complete(); - 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) { - debugger(); - debugPrintStack(stackTrace: stackTrace, label: error.toString()); - } - }); - - ref.onDispose(() => GomuksDestroy(handle)); - ref.onDispose(callable.close); - - final errorCode = GomuksStart(handle, callable.nativeFunction); - - if (errorCode == 0) return handle; - throw Exception("GomuksStart returned error code $errorCode"); - } - - 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 (json is String) throw json; - return json; - } - - Future redactEvent(RedactEventRequest report) => - _sendCommand("redact_event", report.toJson()); - - Future sendMessage(SendMessageRequest request) => - _sendCommand("send_message", request.toJson()); - - Future verify(String recoveryKey) async { - try { - await _sendCommand("verify", {"recovery_key": recoveryKey}); - return true; - } catch (error) { - return false; - } - } - - Future joinRoom(JoinRoomRequest request) async { - final response = await _sendCommand("join_room", request.toJson()); - return response["room_id"]; - } - - Future getAccessToken() async { - final response = await _sendCommand("get_account_info", {}); - return response?["access_token"]; - } - - 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 { - final response = - (await _sendCommand("get_room_state", request.toJson())) as List; - return response.map((event) => Event.fromJson(event)).toIList(); - } - - Future?> getRelatedEvents( - GetRelatedEventsRequest request, - ) async { - final response = - (await _sendCommand("get_related_events", request.toJson())) as List?; - return response?.map((event) => Event.fromJson(event)).toIList(); - } - - Future getEvent(GetEventRequest request) async { - final event = request.room.events.firstWhereOrNull( - (event) => event.eventId == request.eventId, - ); - if (event != null) return event; - - final json = await _sendCommand("get_event", request.toJson()); - return json == null ? null : Event.fromJson(json); - } - - Future paginate(PaginateRequest request) async => - Paginate.fromJson(await _sendCommand("paginate", request.toJson())); - - Future getProfile(String userId) async => - Profile.fromJson(await _sendCommand("get_profile", {"user_id": userId})); - - Future reportEvent(ReportRequest report) => - _sendCommand("report_event", report.toJson()); - - Future markRead(Room room) async { - final event = room.events.firstWhereOrNull( - (event) => event.rowId == room.timeline.last.eventRowId, - ); - if (event == null || room.metadata == null) return; - - await _sendCommand("mark_read", { - "room_id": room.metadata!.id, - "receipt_type": "m.read", - "event_id": event.eventId, - }); - } - - Future login(LoginRequest login) async { - try { - await _sendCommand("login", login.toJson()); - return true; - } catch (error) { - return false; - } - } - - Future discoverHomeserver(Uri homeserver) async { - try { - final response = await _sendCommand("discover_homeserver", { - "user_id": "@fakeuser:${homeserver.host}", - }); - return response["m.homeserver"]?["base_url"]; - } catch (error) { - return null; - } - } - - static final provider = AsyncNotifierProvider( - ClientController.new, - ); -} diff --git a/lib/controllers/client_id.dart b/lib/controllers/client_id.dart new file mode 100644 index 0000000..7748f97 --- /dev/null +++ b/lib/controllers/client_id.dart @@ -0,0 +1,30 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; + +class ClientIdController(final Uri homeserver) extends AsyncNotifier { + @override + Future build() => ref + .watch(ClientController.provider.notifier) + .registerClient( + .new( + clientName: "Nexus", + applicationType: .native, + grantTypes: .new([.authorizationCode, .refreshToken]), + responseTypes: .new([.code]), + logoUri: Uri.https( + "nexus.federated.nexus", + "raw/branch/main/assets/mobile.svg", + ), + homeserverUrl: homeserver, + clientUri: Uri.https("nexus.federated.nexus"), + redirectUris: .new([ + .new(scheme: "nexus.federated.nexus", path: "/"), + ]), + ), + ); + + static final provider = + AsyncNotifierProvider.family( + ClientIdController.new, + ); +} diff --git a/lib/controllers/client_state_controller.dart b/lib/controllers/client_state.dart similarity index 84% rename from lib/controllers/client_state_controller.dart rename to lib/controllers/client_state.dart index 998d4a1..1b77ecb 100644 --- a/lib/controllers/client_state_controller.dart +++ b/lib/controllers/client_state.dart @@ -5,9 +5,7 @@ class ClientStateController extends Notifier { @override Null build() => null; - void set(ClientState newState) { - state = newState; - } + void set(ClientState newState) => state = newState; static final provider = NotifierProvider( ClientStateController.new, diff --git a/lib/controllers/cross_cache_controller.dart b/lib/controllers/cross_cache_controller.dart deleted file mode 100644 index 1d6d4b6..0000000 --- a/lib/controllers/cross_cache_controller.dart +++ /dev/null @@ -1,14 +0,0 @@ -import "package:cross_cache/cross_cache.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; - -class CrossCacheController extends Notifier { - static const String spaceKey = "space"; - static const String roomKey = "room"; - - @override - CrossCache build() => CrossCache(); - - static final provider = NotifierProvider( - CrossCacheController.new, - ); -} diff --git a/lib/controllers/event.dart b/lib/controllers/event.dart new file mode 100644 index 0000000..7a1e5dc --- /dev/null +++ b/lib/controllers/event.dart @@ -0,0 +1,30 @@ +import "package:collection/collection.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"; +import "package:nexus/models/requests/get_event.dart"; + +class EventController(final GetEventRequest request) + extends AsyncNotifier { + @override + Future build() async { + final room = ref.watch( + RoomsController.provider.select((value) => value[request.roomId]), + ); + final event = room?.events.values.firstWhereOrNull( + (event) => event.eventId == request.eventId, + ); + + return event ?? + await ref + .watch(ClientController.provider.notifier) + .getEvent(request) + .onError((_, _) => null); + } + + static final provider = AsyncNotifierProvider.family + .autoDispose( + EventController.new, + ); +} diff --git a/lib/controllers/event_controller.dart b/lib/controllers/event_controller.dart deleted file mode 100644 index 4f72963..0000000 --- a/lib/controllers/event_controller.dart +++ /dev/null @@ -1,20 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/models/event.dart"; -import "package:nexus/models/requests/get_event_request.dart"; - -class EventController extends AsyncNotifier { - final GetEventRequest request; - EventController(this.request); - - @override - Future build() async { - final client = ref.watch(ClientController.provider.notifier); - return await client.getEvent(request).onError((_, _) => null); - } - - static final provider = AsyncNotifierProvider.family - .autoDispose( - EventController.new, - ); -} diff --git a/lib/controllers/gomuks_listener.dart b/lib/controllers/gomuks_listener.dart new file mode 100644 index 0000000..dd75cf3 --- /dev/null +++ b/lib/controllers/gomuks_listener.dart @@ -0,0 +1,124 @@ +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.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); + } + } + }); + + 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_controller.dart b/lib/controllers/header_controller.dart deleted file mode 100644 index 295cf04..0000000 --- a/lib/controllers/header_controller.dart +++ /dev/null @@ -1,20 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/client_state_controller.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/image_picker.dart b/lib/controllers/image_picker.dart new file mode 100644 index 0000000..797c997 --- /dev/null +++ b/lib/controllers/image_picker.dart @@ -0,0 +1,11 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:image_picker/image_picker.dart"; + +class ImagePickerController extends Notifier { + @override + ImagePicker build() => .new(); + + static final provider = NotifierProvider( + ImagePickerController.new, + ); +} diff --git a/lib/controllers/init_complete_controller.dart b/lib/controllers/init_complete.dart similarity index 100% rename from lib/controllers/init_complete_controller.dart rename to lib/controllers/init_complete.dart diff --git a/lib/controllers/jump_to_event.dart b/lib/controllers/jump_to_event.dart new file mode 100644 index 0000000..da42cf9 --- /dev/null +++ b/lib/controllers/jump_to_event.dart @@ -0,0 +1,16 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; + +class JumpToEventController(String? _) extends Notifier { + @override + String? build() => null; + + void set(String? eventId) => state = eventId; + + @override + bool updateShouldNotify(_, _) => true; + + static final provider = NotifierProvider.family + .autoDispose( + JumpToEventController.new, + ); +} diff --git a/lib/controllers/key.dart b/lib/controllers/key.dart new file mode 100644 index 0000000..77b4883 --- /dev/null +++ b/lib/controllers/key.dart @@ -0,0 +1,28 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/shared_prefs.dart"; + +class KeyController(final String key) extends AsyncNotifier { + static const String spaceKey = "space"; + static const String roomKey = "room"; + static const String pushKeyKey = "pushKey"; + + @override + Future build() => + ref.watch(SharedPrefsController.provider).getString(key); + + Future set(String? value) async { + final prefs = ref.watch(SharedPrefsController.provider); + state = .data(value); + + if (value == null) { + await prefs.remove(key); + } else { + await prefs.setString(key, value); + } + } + + static final provider = + AsyncNotifierProvider.family( + KeyController.new, + ); +} diff --git a/lib/controllers/key_controller.dart b/lib/controllers/key_controller.dart deleted file mode 100644 index 946892e..0000000 --- a/lib/controllers/key_controller.dart +++ /dev/null @@ -1,30 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/shared_prefs_controller.dart"; - -class KeyController extends Notifier { - final String key; - KeyController(this.key); - - static const String spaceKey = "space"; - static const String roomKey = "room"; - - @override - String? build() => - ref.watch(SharedPrefsController.provider).requireValue.getString(key); - - Future set(String? id) async { - final prefs = ref.watch(SharedPrefsController.provider).requireValue; - state = id; - - if (id == null) { - prefs.remove(key); - } else { - prefs.setString(key, id); - } - } - - static final provider = - NotifierProvider.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 new file mode 100644 index 0000000..3c287ea --- /dev/null +++ b/lib/controllers/members.dart @@ -0,0 +1,44 @@ +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/content/content.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/requests/get_room_state.dart"; + +class MembersController(final String roomId) + extends AsyncNotifier> { + @override + Future> build() async { + final room = ref.watch( + RoomsController.provider.select((value) => value[roomId]), + ); + + if (room == null) return .new(); + + if (!room.hasFetchedMembers) { + final fetchedState = await ref + .watch(ClientController.provider.notifier) + .getRoomState( + GetRoomStateRequest( + roomId: roomId, + fetchMembers: !(room.metadata?.hasMemberList ?? false), + includeMembers: true, + ), + ); + + await ref + .read(RoomsController.provider.notifier) + .addState(roomId, fetchedState, isMembers: true); + } + + return room.state[EventType.membership.type]?.values + .map((rowId) => room.events[rowId]) + .nonNulls + .toISet() ?? + .new(); + } + + static final provider = AsyncNotifierProvider.autoDispose + .family, String>(MembersController.new); +} diff --git a/lib/controllers/members_by_status.dart b/lib/controllers/members_by_status.dart new file mode 100644 index 0000000..82905aa --- /dev/null +++ b/lib/controllers/members_by_status.dart @@ -0,0 +1,30 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/members.dart"; +import "package:nexus/models/configs/members_by_status.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/event.dart"; + +class MembersByStatusController(final MembersByStatusConfig config) + extends AsyncNotifier> { + @override + Future> build() => ref.watch( + MembersController.provider(config.roomId).selectAsync( + (members) => members + .where( + (membership) => switch (membership.content) { + MembershipContent(:final status) => config.status == status, + _ => false, + }, + ) + .toISet(), + ), + ); + + static final provider = + AsyncNotifierProvider.family< + MembersByStatusController, + ISet, + MembersByStatusConfig + >(MembersByStatusController.new); +} diff --git a/lib/controllers/members_controller.dart b/lib/controllers/members_controller.dart deleted file mode 100644 index 80e73a0..0000000 --- a/lib/controllers/members_controller.dart +++ /dev/null @@ -1,39 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/models/membership.dart"; -import "package:nexus/models/requests/get_room_state_request.dart"; -import "package:nexus/models/room.dart"; - -class MembersController extends AsyncNotifier> { - final Room room; - MembersController(this.room); - - @override - Future> build() async { - if (room.metadata == null) return const IList.empty(); - - final state = await ref - .watch(ClientController.provider.notifier) - .getRoomState( - GetRoomStateRequest( - roomId: room.metadata!.id, - fetchMembers: room.metadata!.hasMemberList == false, - includeMembers: true, - ), - ); - - return state.nonNulls - .where((member) => member.content["membership"] == "join") - .map( - (membership) => - Membership.fromContent(membership.content, membership.stateKey!), - ) - .toIList(); - } - - static final provider = - AsyncNotifierProvider.family, Room>( - MembersController.new, - ); -} diff --git a/lib/controllers/members_grouped.dart b/lib/controllers/members_grouped.dart new file mode 100644 index 0000000..f903ec4 --- /dev/null +++ b/lib/controllers/members_grouped.dart @@ -0,0 +1,61 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/members_by_status.dart"; +import "package:nexus/controllers/room_creators.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/models/configs/members_by_status.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/event.dart"; + +class MembersGroupedController(final MembersByStatusConfig config) + extends AsyncNotifier>>> { + @override + Future>>> build() async { + final room = ref.watch( + RoomsController.provider.select((value) => value[config.roomId]), + ); + + final roomCreators = room == null + ? null + : ref.watch((RoomCreatorsController.provider(room))); + + final powerLevelsRowId = room?.state[EventType.powerLevels.type]?[""]; + final powerLevelsEvent = powerLevelsRowId == null + ? null + : room?.events[powerLevelsRowId]; + + final content = switch (powerLevelsEvent?.content) { + PowerLevelsContent content => content, + _ => PowerLevelsContent(), + }; + + final members = await ref.watch( + MembersByStatusController.provider(config).future, + ); + + return members + .fold>>(.new(), (result, event) { + final groupKey = roomCreators?.contains(event.stateKey!) == true + ? null + : content.users[event.stateKey!] ?? content.usersDefault; + + return result.update( + groupKey, + (value) => value.add(event), + ifAbsent: () => .new({event}), + ); + }) + .toEntryIList( + compare: (a, b) => + (b?.key ?? double.infinity).compareTo(a?.key ?? double.infinity), + ); + } + + static final provider = + AsyncNotifierProvider.family< + MembersGroupedController, + IList>>, + MembersByStatusConfig + >(MembersGroupedController.new); +} diff --git a/lib/controllers/message_controller.dart b/lib/controllers/message_controller.dart deleted file mode 100644 index d84aabb..0000000 --- a/lib/controllers/message_controller.dart +++ /dev/null @@ -1,195 +0,0 @@ -import "package:collection/collection.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/helpers/extensions/mxc_to_https.dart"; -import "package:nexus/models/configs/message_config.dart"; - -class MessageController extends AsyncNotifier { - final MessageConfig config; - MessageController(this.config); - - @override - Future build() async { - try { - if (config.event.relationType == "m.replace" && !config.includeEdits) { - return null; - } - - if (!ref.mounted) return null; - final event = config.event.lastEditRowId == null - ? config.event - : config.room.events.firstWhereOrNull( - (e) => e.rowId == config.event.lastEditRowId, - ) ?? - config.event; - - if (!ref.mounted) return null; - - final content = (event.decrypted ?? event.content); - final type = (config.event.decryptedType ?? config.event.type); - final newContent = content["m.new_content"] as Map?; - - final homeserver = ref - .read(ClientStateController.provider) - ?.homeserverUrl; - final source = homeserver == null || content["url"] == null - ? "null" - : Uri.parse(content["url"]).mxcToHttps(homeserver).toString(); - - final metadata = { - "body": config.event.redactedBy == null - ? (newContent?["body"] ?? content["body"] ?? "") - : "Deleted Message", - "flashing": false, - "timelineId": event.timelineRowId, - "big": event.localContent?.bigEmoji == true, - "eventType": type, - "pmp": event.content["com.beeper.per_message_profile"], - "editSource": - event.localContent?.editSource ?? - newContent?["body"] ?? - content["body"], - "txnId": config.event.transactionId, - }; - - if (!ref.mounted) return null; - - final editedAt = event.relationType == "m.replace" - ? event.timestamp - : null; - - if ((event.redactedBy != null && !config.alwaysReturn) || - (!config.includeEdits && - (config.event.relationType == "m.replace"))) { - return null; - } - - // TODO: Use server-generated preview if enabled - - // final match = Uri.tryParse( - // RegExp(regexLink, caseSensitive: false).firstMatch(body)?.group(0) ?? "", - // ); - - final replyId = - config.event.content["m.relates_to"]?["m.in_reply_to"]?["event_id"]; - - final asText = - Message.text( - metadata: metadata, - id: config.event.eventId, - authorId: event.authorId, - text: - newContent?["formatted_body"] ?? - newContent?["body"] ?? - content["formatted_body"] ?? - content["body"] ?? - "", - replyToMessageId: replyId, - deliveredAt: config.event.timestamp, - editedAt: editedAt, - ) - as TextMessage; - - return switch (type) { - "m.room.encrypted" => asText.copyWith( - text: "Unable to decrypt message.", - metadata: {...metadata, "body": "Unable to decrypt message."}, - ), - // "org.matrix.msc3381.poll.start" => Message.custom( - // metadata: { - // ...metadata, - // "poll": event.parsedPollEventContent.pollStartContent, - // "responses": event.getPollResponses(timeline), - // }, - // id: eventId, - // deliveredAt: originServerTs, - // authorId: senderId, - // ), - ("m.sticker" || "m.room.message") => switch (content["msgtype"]) { - null || "m.image" => Message.image( - id: config.event.eventId, - authorId: event.authorId, - source: source, - replyToMessageId: replyId, - metadata: metadata, - text: asText.text, - deliveredAt: config.event.timestamp, - blurhash: (content["info"] as Map?)?["xyz.amorgan.blurhash"], - ), - "m.audio" || "m.file" => Message.file( - name: content["filename"].toString(), - size: content["info"]["size"], - metadata: metadata, - id: config.event.eventId, - authorId: event.authorId, - source: source, - replyToMessageId: replyId, - deliveredAt: config.event.timestamp, - ), - _ => asText, - }, - "m.room.member" => - content["membership"] == event.unsigned["prev_content"]?["membership"] - ? null - : Message.system( - metadata: { - ...metadata, - "body": - "${content["displayname"] ?? event.stateKey} ${switch (content["membership"]) { - "invite" => "was invited to", - "join" => "joined", - "leave" => "left", - "knock" => "asked to join", - "ban" => "was banned from", - _ => "did something relating to", - }} the room.", - }, - id: config.event.eventId, - authorId: event.authorId, - deliveredAt: config.event.timestamp, - text: - "${content["displayname"] ?? event.stateKey} ${switch (content["membership"]) { - "invite" => "was invited to", - "join" => "joined", - "leave" => "left", - "knock" => "asked to join", - "ban" => "was banned from", - _ => "did something relating to", - }} the room.", - ), - - "m.room.redaction" => - config.alwaysReturn - ? asText.copyWith( - metadata: { - ...(asText.metadata ?? {}), - "body": "Deleted Message", - }, - ) - : null, - _ => - config.alwaysReturn - ? asText - : ( - // Turn this on for debugging purposes - false - // ignore: dead_code - ? Message.unsupported( - metadata: metadata, - id: config.event.eventId, - authorId: event.authorId, - replyToMessageId: replyId, - ) - : null), - }; - } catch (error) { - return null; - } - } - - static final provider = AsyncNotifierProvider.family - .autoDispose( - MessageController.new, - ); -} diff --git a/lib/controllers/messages_controller.dart b/lib/controllers/messages_controller.dart deleted file mode 100644 index 28885fb..0000000 --- a/lib/controllers/messages_controller.dart +++ /dev/null @@ -1,27 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/message_controller.dart"; -import "package:nexus/models/configs/message_config.dart"; -import "package:nexus/models/configs/messages_config.dart"; - -class MessagesController extends AsyncNotifier> { - final MessagesConfig config; - MessagesController(this.config); - - @override - Future> build() async => (await Future.wait( - config.events.map( - (event) => ref.watch( - MessageController.provider( - MessageConfig(event: event, room: config.room), - ).future, - ), - ), - )).nonNulls.toIList(); - - static final provider = AsyncNotifierProvider.family - .autoDispose, MessagesConfig>( - MessagesController.new, - ); -} diff --git a/lib/controllers/multi_provider_controller.dart b/lib/controllers/multi_provider.dart similarity index 67% rename from lib/controllers/multi_provider_controller.dart rename to lib/controllers/multi_provider.dart index e23ecaa..ab37d07 100644 --- a/lib/controllers/multi_provider_controller.dart +++ b/lib/controllers/multi_provider.dart @@ -1,14 +1,14 @@ 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 - FutureOr build() async => await Future.wait( + Future build() => .wait( providers.map((provider) => ref.watch(provider.future)), + eagerError: true, ); static final provider = diff --git a/lib/controllers/new_events_controller.dart b/lib/controllers/new_events_controller.dart deleted file mode 100644 index 215ebd3..0000000 --- a/lib/controllers/new_events_controller.dart +++ /dev/null @@ -1,18 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/models/event.dart"; - -class NewEventsController extends Notifier> { - final String roomId; - NewEventsController(this.roomId); - - @override - IList build() => const IList.empty(); - - void add(IList newEvents) => state = newEvents; - - static final provider = NotifierProvider.autoDispose - .family, String>( - NewEventsController.new, - ); -} diff --git a/lib/controllers/notification.dart b/lib/controllers/notification.dart new file mode 100644 index 0000000..5ee3449 --- /dev/null +++ b/lib/controllers/notification.dart @@ -0,0 +1,162 @@ +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/pages/notifications.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 case final eventId?) { + if (navigatorKey.currentContext case final context?) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => NotificationsPage( + highlightedEventId: eventId, + defaultToAllNotifications: true, + ), + ), + ); + } + } + }, + ); + } + + if (Platform.isLinux) { + const notificationChannel = MethodChannel("nexus/notifications"); + + notificationChannel.setMethodCallHandler((call) async { + if (call.method != "notificationClicked") { + return; + } + + final eventId = call.arguments as String; + + if (navigatorKey.currentContext case final context?) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => NotificationsPage( + highlightedEventId: eventId, + 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, + icon: icon == null ? null : XdgNotificationIconFile(icon.path), + defaultAction: "app.event", + defaultActionTarget: payload, + ); + } 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..da4312d --- /dev/null +++ b/lib/controllers/notifications.dart @@ -0,0 +1,53 @@ +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/models/event.dart"; + +typedef NotificationsRequest = (UnreadType? unreadType, String? roomId); + +class NotificationsController([final NotificationsRequest? request]) + extends AsyncNotifier> { + @override + Future> build() async { + final client = ref.read(ClientController.provider.notifier); + + final (unreadType, roomId) = request ?? (null, null); + + return await client.getMentions( + .new( + maxTimestamp: .now(), + unreadType: unreadType ?? .highlight, + roomId: roomId, + ), + ); + } + + Future loadOlder() async { + final currentNotifications = await future; + state = .loading(); + state = await .guard(() async { + final lastTs = currentNotifications.lastOrNull?.timestamp; + if (lastTs == null) return const .empty(); + + final client = ref.read(ClientController.provider.notifier); + final (unreadType, roomId) = request ?? (null, null); + + final newNotifications = await client.getMentions( + .new( + maxTimestamp: lastTs, + unreadType: unreadType ?? .highlight, + roomId: roomId, + ), + ); + + return currentNotifications.addAll(newNotifications); + }); + } + + static final provider = AsyncNotifierProvider.family + .autoDispose< + NotificationsController, + IList, + NotificationsRequest? + >(NotificationsController.new); +} diff --git a/lib/controllers/pinned_events.dart b/lib/controllers/pinned_events.dart new file mode 100644 index 0000000..323a275 --- /dev/null +++ b/lib/controllers/pinned_events.dart @@ -0,0 +1,27 @@ +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(final String roomId) + extends AsyncNotifier> { + @override + 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, + ), + ), + )).nonNulls.toIList(); + } + + static final provider = AsyncNotifierProvider.family + .autoDispose, String>( + PinnedEventsController.new, + ); +} diff --git a/lib/controllers/pinned_ids.dart b/lib/controllers/pinned_ids.dart new file mode 100644 index 0000000..01358a9 --- /dev/null +++ b/lib/controllers/pinned_ids.dart @@ -0,0 +1,50 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/pinned_events.dart"; + +class PinnedIdsController(final String roomId) extends Notifier> { + @override + IList build() { + final room = ref.watch( + RoomsController.provider.select((rooms) => rooms[roomId]), + ); + + if (room == null) return .new(); + + final pinnedRowId = room.state[EventType.pinnedEvents.type]?[""]; + final pinnedStateEvent = pinnedRowId == null + ? null + : room.events[pinnedRowId]; + + if (pinnedStateEvent?.content case PinnedEventsContent content) { + return content.pinnedEvents; + } + + return .new(); + } + + Future addPin(String eventId) async => + setPinned(.new(pinnedEvents: .new(state.add(eventId)))); + + Future removePin(String eventId) async => + setPinned(.new(pinnedEvents: .new(state.remove(eventId)))); + + Future setPinned(PinnedEventsContent content) => ref + .read(ClientController.provider.notifier) + .setState( + .new( + roomId: roomId, + type: EventType.pinnedEvents.type, + stateKey: "", + content: content, + ), + ); + + static final provider = NotifierProvider.family + .autoDispose, String>( + PinnedIdsController.new, + ); +} 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 new file mode 100644 index 0000000..7111e38 --- /dev/null +++ b/lib/controllers/power_level.dart @@ -0,0 +1,79 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/room_creators.dart"; +import "package:nexus/controllers/rooms.dart"; +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(final PowerLevelConfig config) + extends Notifier { + @override + bool build() { + if (config case EventPowerLevelConfig(:final eventType)) { + assert( + eventType != .redaction, + "Checking power level for a redaction should use [PowerLevelConfig.redaction].", + ); + } + + final room = ref.watch( + RoomsController.provider.select((value) => value[config.roomId]), + ); + + final roomCreators = room == null + ? null + : ref.watch(RoomCreatorsController.provider(room)); + + final eventRowId = room?.state[EventType.powerLevels.type]?[""]; + + final event = eventRowId == null ? null : room?.events[eventRowId]; + final content = event?.content is PowerLevelsContent + ? event!.content + : PowerLevelsContent(); + + final user = ref.watch( + ClientStateController.provider.select((value) => value?.userId), + ); + if (user == null || content is! PowerLevelsContent) return false; + + double powerLevelOf(String userId) => roomCreators?.contains(userId) == true + ? double.infinity + : (content.users[userId] ?? content.usersDefault).toDouble(); + + final userLevel = powerLevelOf(user); + + return switch (config) { + EventPowerLevelConfig(:final eventType) => + userLevel >= (content.events[eventType.type] ?? content.eventsDefault), + + MembershipActionPowerLevelConfig(:final action, :final targetUser) => + switch (action) { + .invite => userLevel >= content.invite, + + .kick => + userLevel >= content.kick && userLevel > powerLevelOf(targetUser), + + .ban => + userLevel >= content.ban && userLevel > powerLevelOf(targetUser), + + .unban => userLevel >= content.ban, + }, + + StatePowerLevelConfig(:final eventType) => + userLevel >= (content.events[eventType.type] ?? content.stateDefault), + + RedactionPowerLevelConfig(:final targetUser) => + userLevel >= + (targetUser == user + ? (content.events[EventType.redaction.type] ?? + content.eventsDefault) + : content.redact), + }; + } + + static final provider = NotifierProvider.autoDispose + .family( + PowerLevelController.new, + ); +} diff --git a/lib/controllers/profile.dart b/lib/controllers/profile.dart new file mode 100644 index 0000000..7a1477c --- /dev/null +++ b/lib/controllers/profile.dart @@ -0,0 +1,17 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/models/profile_response.dart"; + +class ProfileController(final String userId) + extends AsyncNotifier { + @override + Future build() { + final client = ref.read(ClientController.provider.notifier); + return client.getProfile(userId); + } + + static final provider = AsyncNotifierProvider.family + .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 new file mode 100644 index 0000000..9a92843 --- /dev/null +++ b/lib/controllers/reactions.dart @@ -0,0 +1,53 @@ +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/configs/reactions.dart"; +import "package:nexus/models/content/reaction.dart"; + +class ReactionsController(final ReactionsConfig config) + extends AsyncNotifier>> { + @override + Future>> build() async { + final eventInfo = ref.watch( + RoomsController.provider.select((value) { + final event = value[config.roomId]?.events[config.eventRowId]; + return event == null ? null : (event.eventId, event.reactions); + }), + ); + + final reactionEvents = eventInfo?.$2.isNotEmpty == true + ? await ref + .watch(ClientController.provider.notifier) + .getRelatedEvents( + .new( + roomId: config.roomId, + eventId: eventInfo!.$1, + relationType: "m.annotation", + ), + ) + : null; + + return reactionEvents + ?.where((event) => event.redactedBy == null) + .fold>>(.new(), (acc, event) { + if (event.content case ReactionContent(:final key?)) { + return acc.update( + key, + (list) => list.add(event.sender), + ifAbsent: () => .new([event.sender]), + ); + } + + return acc; + }) ?? + .new(); + } + + static final provider = + AsyncNotifierProvider.family< + ReactionsController, + IMap>, + ReactionsConfig + >(ReactionsController.new); +} diff --git a/lib/controllers/recent_emoji.dart b/lib/controllers/recent_emoji.dart new file mode 100644 index 0000000..6cf57f7 --- /dev/null +++ b/lib/controllers/recent_emoji.dart @@ -0,0 +1,44 @@ +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/account_data.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/models/account_data.dart"; + +class RecentEmojiController extends Notifier> { + @override + IList build() => ref.watch( + AccountDataController.provider.select( + (value) => value.recentEmoji.recentEmoji, + ), + ); + + 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 new file mode 100644 index 0000000..91b137a --- /dev/null +++ b/lib/controllers/room_chat.dart @@ -0,0 +1,299 @@ +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"; +import "package:fluttertagger/fluttertagger.dart"; +import "package:nexus/controllers/attachment.dart"; +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(final (String roomId, String? contextualEvent) info) + extends AsyncNotifier { + @override + Future build() async { + final (roomId, eventId) = info; + final client = ref.read(ClientController.provider.notifier); + final room = ref.read( + RoomsController.provider.select((rooms) => rooms[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 timeline = room.timeline + .toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0)) + .map((element) => element.value) + .toIList() + .addAll(room.clientSticky) + .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, + ); + }) + .nonNulls + .toIList(); + + if (info.$2 == null || timeline.map((e) => e.eventId).contains(info.$2)) { + ref.watch(RoomsController.provider.select((rooms) => rooms[roomId])); + + return .new( + timeline: timeline, + hasMoreBackward: room.hasMore, + hasMoreForward: false, + ); + } else { + final context = await client.getEventContext( + .new(roomId: roomId, eventId: info.$2!), + ); + return .new( + timeline: context.before.add(context.event).addAll(context.after), + hasMoreBackward: true, + hasMoreForward: true, + historicalData: .new(start: context.start, end: context.end), + ); + } + } + + Future deleteMessage(Event event, {String? reason}) => ref + .watch(ClientController.provider.notifier) + .redactEvent( + RedactEventRequest( + eventId: event.eventId, + roomId: info.$1, + reason: reason, + ), + ); + + Future paginate(Direction direction) async { + if (state.isLoading) return; + + 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.$1])) + ?.timeline + .keys; + final response = await client.paginate( + .new( + roomId: info.$1, + maxTimelineId: timelineKeys?.isNotEmpty == true + ? timelineKeys?.reduce(min) + : null, + ), + ); + + if (response.events.isEmpty) { + state = .data(state.value); + } + + ref + .read(RoomsController.provider.notifier) + .update( + IMap({ + info.$1: 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(), + ); + } else { + final paginationResponse = await client.paginateManual( + .new( + roomId: info.$1, + direction: direction, + since: direction == .forward + ? chat!.historicalData!.end + : chat!.historicalData!.start, + ), + ); + + state = .data( + .new( + timeline: direction == .forward + ? chat.timeline.addAll(paginationResponse.events) + : paginationResponse.events.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( + String text, { + bool shouldMention = true, + required IList tags, + required RelationType relationType, + Event? relation, + }) async { + Content? baseContent; + if (relationType == .edit) { + baseContent = relation?.content; + } else { + final provider = AttachmentController.provider(info.$1); + baseContent = ref.read(provider)?.$2; + ref.invalidate(provider); + } + + var taggedMessage = text; + + for (final tag in tags) { + final escaped = RegExp.escape(tag.id); + final pattern = RegExp(r"@+(" + escaped + r")(#[^#]*#)?"); + + taggedMessage = taggedMessage.replaceAllMapped( + pattern, + (match) => match.group(1)!, + ); + } + + final client = ref.read(ClientController.provider.notifier); + final event = await client.sendMessage( + SendMessageRequest( + roomId: info.$1, + baseContent: baseContent, + mentions: Mentions( + userIds: [ + if (shouldMention == true && + relation != null && + relationType == RelationType.reply) + relation.sender, + ].toIList(), + room: taggedMessage.contains("@room"), + ), + text: taggedMessage, + relation: relation == null + ? null + : .new(eventId: relation.eventId, relationType: relationType), + ), + ); + + ref + .read(RoomsController.provider.notifier) + .update( + .new({ + info.$1: .new( + events: .new({event.rowId: event}), + clientSticky: .new({event.rowId}), + ), + }), + .new(), + ); + } + + Future removeReaction( + String reaction, + Event event, + String userId, + ) async { + final client = ref.read(ClientController.provider.notifier); + final allReactionEvents = await client.getRelatedEvents( + .new( + roomId: info.$1, + eventId: event.eventId, + relationType: "m.annotation", + ), + ); + + final reactionEvents = allReactionEvents + ?.where((event) => event.redactedBy == null) + .toIList(); + + final reactionEvent = reactionEvents?.firstWhereOrNull( + (event) => switch (event.content) { + ReactionContent(:final key) => + key == reaction && event.sender == userId, + _ => false, + }, + ); + + if (reactionEvent != null) { + await ref + .watch(ClientController.provider.notifier) + .redactEvent(.new(eventId: reactionEvent.eventId, roomId: info.$1)); + } + } + + Future sendReaction(String reaction, Event event) async { + final client = ref.read(ClientController.provider.notifier); + + await client.sendEvent( + .new( + roomId: info.$1, + type: EventType.reaction.type, + content: ReactionContent(key: reaction), + synchronous: true, + disableEncryption: true, + relatesTo: event.eventId, + relationType: "m.annotation", + ), + ); + } + + static final provider = AsyncNotifierProvider.family + .autoDispose( + RoomChatController.new, + ); +} diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart deleted file mode 100644 index d737154..0000000 --- a/lib/controllers/room_chat_controller.dart +++ /dev/null @@ -1,299 +0,0 @@ -import "dart:async"; -import "package:collection/collection.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart" as chat; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:fluttertagger/fluttertagger.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/message_controller.dart"; -import "package:nexus/controllers/messages_controller.dart"; -import "package:nexus/controllers/new_events_controller.dart"; -import "package:nexus/controllers/rooms_controller.dart"; -import "package:nexus/models/configs/messages_config.dart"; -import "package:nexus/models/configs/message_config.dart"; -import "package:nexus/models/requests/get_room_state_request.dart"; -import "package:nexus/models/requests/paginate_request.dart"; -import "package:nexus/models/requests/redact_event_request.dart"; -import "package:nexus/models/relation_type.dart"; -import "package:nexus/models/requests/send_message_request.dart"; -import "package:nexus/models/room.dart"; - -class RoomChatController extends AsyncNotifier { - final String roomId; - RoomChatController(this.roomId); - - @override - Future build() async { - final client = ref.watch(ClientController.provider.notifier); - var room = ref.read(RoomsController.provider)[roomId]; - if (room == null) return InMemoryChatController(); - - final state = await client.getRoomState( - GetRoomStateRequest(roomId: roomId), - ); - - ref - .read(RoomsController.provider.notifier) - .update( - { - roomId: Room( - events: state, - state: state.fold( - const IMap.empty(), - (previousValue, stateEvent) => previousValue.add( - stateEvent.type, - (previousValue[stateEvent.type] ?? const IMap.empty()).addAll( - IMap({ - if (stateEvent.stateKey != null) - stateEvent.stateKey!: stateEvent.rowId, - }), - ), - ), - ), - ), - }.toIMap(), - const ISet.empty(), - ); - - room = ref.read(RoomsController.provider)[roomId]; - if (room == null) return InMemoryChatController(); - - final messages = await ref.watch( - MessagesController.provider( - MessagesConfig( - room: room, - events: room.timeline - .map( - (timelineRowTuple) => room!.events.firstWhereOrNull( - (event) => event.rowId == timelineRowTuple.eventRowId, - ), - ) - .nonNulls - .toIList(), - ), - ).future, - ); - final controller = InMemoryChatController(messages: messages.toList()); - - ref.onDispose( - ref.listen(NewEventsController.provider(roomId), (_, next) async { - final controller = await future; - for (final event in next) { - if (event.type == "m.room.redaction") { - final controller = await future; - final message = controller.messages.firstWhereOrNull( - (message) => message.id == event.content["redacts"], - ); - if (message == null || !ref.mounted) return; - - await controller.removeMessage(message); - } else { - final message = await ref.watch( - MessageController.provider( - MessageConfig(event: event, room: room!, includeEdits: true), - ).future, - ); - if (event.relationType == "m.replace") { - final controller = await future; - final oldMessage = controller.messages.firstWhereOrNull( - (element) => element.id == event.relatesTo, - ); - if (oldMessage == null || message == null || !ref.mounted) return; - - return await controller.updateMessage( - oldMessage, - message.copyWith( - id: oldMessage.id, - replyToMessageId: oldMessage.replyToMessageId, - metadata: { - ...(oldMessage.metadata ?? {}), - ...(message.metadata ?? {}) - .toIMap() - .where((key, value) => value != null) - .unlock, - }, - ), - ); - } - if (message != null && - !controller.messages.any( - (oldMessage) => oldMessage.id == message.id, - ) && - ref.mounted) { - await controller.insertMessage(message); - } - } - } - }, weak: true).close, - ); - - ref.onDispose(controller.dispose); - - // While there are under 20 messages, try up to two times to load more messages. - for (var i = 0; i < 2 && messages.length < 20; i++) { - await loadOlder(controller); - } - - return controller; - } - - Future insertMessage(Message message) async { - final controller = await future; - final oldMessage = message.metadata?["txnId"] == null - ? null - : controller.messages.firstWhereOrNull( - (element) => - element.metadata?["txnId"] == message.metadata?["txnId"], - ); - - return oldMessage == null - ? controller.insertMessage(message) - : controller.updateMessage(oldMessage, message); - } - - Future deleteMessage(Message message, {String? reason}) async { - final controller = await future; - await controller.removeMessage(message); - await ref - .watch(ClientController.provider.notifier) - .redactEvent( - RedactEventRequest( - eventId: message.id, - roomId: roomId, - reason: reason, - ), - ); - } - - Future loadOlder([InMemoryChatController? chatController]) async { - final response = await ref - .watch(ClientController.provider.notifier) - .paginate( - PaginateRequest( - roomId: roomId, - maxTimelineId: ref - .read(RoomsController.provider)[roomId] - ?.timeline - .firstOrNull - ?.timelineRowId, - ), - ); - - ref - .watch(RoomsController.provider.notifier) - .update( - IMap({ - roomId: Room( - events: response.events.addAll(response.relatedEvents), - hasMore: response.hasMore, - timeline: response.events - .map( - (event) => TimelineRowTuple( - timelineRowId: event.timelineRowId, - eventRowId: event.rowId, - ), - ) - .toIList(), - ), - }), - const ISet.empty(), - ); - - final room = ref.read(RoomsController.provider)[roomId]; - if (room == null) return; - - final messages = await ref.watch( - MessagesController.provider( - MessagesConfig(room: room, events: response.events.reversed), - ).future, - ); - - final controller = chatController ?? await future; - await controller.insertAllMessages( - messages - .where( - (newMessage) => !controller.messages.any( - (message) => message.id == newMessage.id, - ), - ) - .toList(), - index: 0, - ); - } - - Future send( - String message, { - bool shouldMention = true, - required Iterable tags, - required RelationType relationType, - Message? relation, - }) async { - var taggedMessage = message; - - for (final tag in tags) { - final escaped = RegExp.escape(tag.id); - final pattern = RegExp(r"@+(" + escaped + r")(#[^#]*#)?"); - - taggedMessage = taggedMessage.replaceAllMapped( - pattern, - (match) => match.group(1)!, - ); - } - - final client = ref.watch(ClientController.provider.notifier); - client.sendMessage( - SendMessageRequest( - roomId: roomId, - mentions: Mentions( - userIds: [ - if (shouldMention == true && - relation != null && - relationType == RelationType.reply) - relation.authorId, - ].toIList(), - room: taggedMessage.contains("@room"), - ), - text: taggedMessage, - relation: relation == null - ? null - : Relation(eventId: relation.id, relationType: relationType), - ), - ); - } - - Future resolveUser(String id) async { - final user = await ref - .watch(ClientController.provider.notifier) - .getProfile(id); - return chat.User( - id: id, - name: user.displayName, - // imageSource: user.avatarUrl == null - // ? null - // : (await ref.watch( - // AvatarController.provider(user.avatarUrl!.toString()).future, - // )).toString(), - ); - } - - Future scrollToMessage(Message message) async { - final controller = await future; - Future setFlashing(bool flashing) => controller.updateMessage( - message, - message.copyWith( - metadata: {...(message.metadata ?? {}), "flashing": flashing}, - ), - ); - - await setFlashing(true); - Timer(Duration(seconds: 1), () => setFlashing(false)); - - return await controller.scrollToMessage(message.id); - } - - static final provider = AsyncNotifierProvider.family - .autoDispose( - RoomChatController.new, - ); -} diff --git a/lib/controllers/room_creators.dart b/lib/controllers/room_creators.dart new file mode 100644 index 0000000..09acdd7 --- /dev/null +++ b/lib/controllers/room_creators.dart @@ -0,0 +1,30 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/create.dart"; +import "package:nexus/models/room.dart"; + +class RoomCreatorsController(final Room room) extends Notifier> { + @override + IList build() { + final createRowId = room.state[EventType.create.type]?[""]; + final createEvent = createRowId == null ? null : room.events[createRowId]; + + if (createEvent == null) return .new(); + + final createEventContent = switch (createEvent.content) { + CreateContent content => content, + _ => null, + }; + + return switch (createEventContent?.additionalCreatorIds) { + IList creators => creators.add(createEvent.sender), + _ => .new([createEvent.sender]), + }; + } + + static final provider = + NotifierProvider.family, Room>( + RoomCreatorsController.new, + ); +} 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 new file mode 100644 index 0000000..ff4d6a2 --- /dev/null +++ b/lib/controllers/rooms.dart @@ -0,0 +1,99 @@ +import "dart:isolate"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/room.dart"; + +class RoomsController extends Notifier> { + @override + IMap build() => .new(); + + Future addState( + String roomId, + IList state, { + bool isMembers = false, + }) async => 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, + ), + ), + ); + return newState; + }), + ), + }), + .new(), + ); + + void update(IMap rooms, ISet leftRooms) { + final merged = rooms.entries.fold(state, (acc, entry) { + final roomId = entry.key; + final incoming = entry.value; + final existing = acc[roomId]; + + return acc.add( + roomId, + existing?.copyWith( + hasMore: incoming.hasMore, + 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 + : existing.events.addAll(incoming.events), + state: incoming.state.entries.fold( + existing.state, + (previousValue, event) => previousValue.add( + event.key, + (previousValue[event.key] ?? .new()).addAll(event.value), + ), + ), + reset: false, + hasFetchedMembers: + incoming.hasFetchedMembers || existing.hasFetchedMembers, + hasFetchedState: + incoming.hasFetchedState || existing.hasFetchedState, + timeline: (incoming.reset + ? incoming.timeline + : existing.timeline.addAll(incoming.timeline)), + receipts: incoming.receipts.entries.fold( + existing.receipts, + (receiptAcc, event) => receiptAcc.add( + event.key, + (receiptAcc[event.key] ?? .new()).addAll(event.value), + ), + ), + ) ?? + incoming, + ); + }); + + final prunedList = leftRooms.fold( + merged, + (acc, roomId) => acc.remove(roomId), + ); + + state = prunedList; + } + + static final provider = NotifierProvider>( + RoomsController.new, + ); +} diff --git a/lib/controllers/rooms_controller.dart b/lib/controllers/rooms_controller.dart deleted file mode 100644 index 3c6e287..0000000 --- a/lib/controllers/rooms_controller.dart +++ /dev/null @@ -1,84 +0,0 @@ -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/new_events_controller.dart"; -import "package:nexus/models/read_receipt.dart"; -import "package:nexus/models/room.dart"; - -class RoomsController extends Notifier> { - @override - IMap build() => const IMap.empty(); - - void update(IMap rooms, ISet leftRooms) { - final merged = rooms.entries.fold(state, (acc, entry) { - final roomId = entry.key; - final incoming = entry.value; - final existing = acc[roomId]; - - final events = existing?.events.updateById( - incoming.events, - (item) => item.eventId, - ); - - ref - .watch(NewEventsController.provider(roomId).notifier) - .add( - incoming.timeline - .map( - (timelineTuple) => events?.firstWhereOrNull( - (event) => timelineTuple.eventRowId == event.rowId, - ), - ) - .nonNulls - .toIList(), - ); - - return acc.add( - roomId, - existing?.copyWith( - hasMore: incoming.hasMore, - metadata: incoming.metadata ?? existing.metadata, - events: events!, - state: incoming.state.entries.fold( - existing.state, - (previousValue, event) => previousValue.add( - event.key, - (previousValue[event.key] ?? const IMap.empty()).addAll( - event.value, - ), - ), - ), - timeline: - (incoming.reset - ? incoming.timeline - : existing.timeline.updateById( - incoming.timeline, - (item) => item.timelineRowId, - )) - .sortedBy((element) => element.timelineRowId) - .toIList(), - receipts: incoming.receipts.entries.fold( - existing.receipts, - (receiptAcc, event) => receiptAcc.add( - event.key, - (receiptAcc[event.key] ?? IList()).addAll( - event.value, - ), - ), - ), - ) ?? - incoming, - ); - }); - - final prunedList = leftRooms.fold( - merged, - (acc, roomId) => acc.remove(roomId), - ); - state = prunedList; - } - - static final provider = NotifierProvider>( - RoomsController.new, - ); -} diff --git a/lib/controllers/selected_room_controller.dart b/lib/controllers/selected_room_controller.dart deleted file mode 100644 index ffba78c..0000000 --- a/lib/controllers/selected_room_controller.dart +++ /dev/null @@ -1,24 +0,0 @@ -import "package:collection/collection.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/key_controller.dart"; -import "package:nexus/controllers/selected_space_controller.dart"; -import "package:nexus/models/room.dart"; - -class SelectedRoomController extends Notifier { - @override - Room? build() { - final space = ref.watch(SelectedSpaceController.provider); - final selectedRoomId = ref.watch( - KeyController.provider(KeyController.roomKey), - ); - - return space.children.firstWhereOrNull( - (room) => room.metadata?.id == selectedRoomId, - ) ?? - space.children.firstOrNull; - } - - static final provider = NotifierProvider( - SelectedRoomController.new, - ); -} diff --git a/lib/controllers/selected_space_controller.dart b/lib/controllers/selected_space_controller.dart deleted file mode 100644 index dbeb71f..0000000 --- a/lib/controllers/selected_space_controller.dart +++ /dev/null @@ -1,22 +0,0 @@ -import "package:collection/collection.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/key_controller.dart"; -import "package:nexus/controllers/spaces_controller.dart"; -import "package:nexus/models/space.dart"; - -class SelectedSpaceController extends Notifier { - @override - Space build() { - final spaces = ref.watch(SpacesController.provider); - final selectedSpaceId = ref.watch( - KeyController.provider(KeyController.spaceKey), - ); - - return spaces.firstWhereOrNull((space) => space.id == selectedSpaceId) ?? - spaces.first; - } - - static final provider = NotifierProvider( - SelectedSpaceController.new, - ); -} diff --git a/lib/controllers/settings.dart b/lib/controllers/settings.dart new file mode 100644 index 0000000..8c107e3 --- /dev/null +++ b/lib/controllers/settings.dart @@ -0,0 +1,27 @@ +import "dart:convert"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/settings_file.dart"; +import "package:nexus/models/settings.dart"; + +class SettingsController extends AsyncNotifier { + @override + Future build() async { + final file = await ref.watch(SettingsFileController.provider.future); + + try { + return .fromJson(json.decode(await file.readAsString())); + } catch (_) { + return .new(); + } + } + + Future set(Settings settings) async { + state = .data(settings); + final file = await ref.watch(SettingsFileController.provider.future); + await file.writeAsString(json.encode(settings.toJson())); + } + + static final provider = AsyncNotifierProvider( + SettingsController.new, + ); +} diff --git a/lib/controllers/settings_file.dart b/lib/controllers/settings_file.dart new file mode 100644 index 0000000..2c050bc --- /dev/null +++ b/lib/controllers/settings_file.dart @@ -0,0 +1,23 @@ +import "dart:io"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:path/path.dart"; +import "package:path_provider/path_provider.dart"; +import "package:xdg_directories/xdg_directories.dart"; + +class SettingsFileController extends AsyncNotifier { + @override + Future build() async { + final directory = await switch (Platform.isLinux) { + true => Directory( + join(configHome.absolute.path, "nexus"), + ).create(recursive: true), + false => getApplicationSupportDirectory(), + }; + + return File(join(directory.absolute.path, "config.json")); + } + + static final provider = AsyncNotifierProvider( + SettingsFileController.new, + ); +} diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart new file mode 100644 index 0000000..7fc1b3f --- /dev/null +++ b/lib/controllers/settings_sections.dart @@ -0,0 +1,287 @@ +import "dart:io"; + +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: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/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"; + +class SettingsSectionsController + extends AsyncNotifier>> { + @override + Future>> build() async { + final settings = await ref.watch(SettingsController.provider.future); + + return .new({ + "General": .new([ + .new( + title: "Appearance", + icon: Icons.brush, + settings: .new([ + .new( + title: "Theme", + description: + "Toggle between Light Mode, Dark Mode, and System themes.", + icon: Icons.contrast, + builder: (title, description, icon) => DialogListTile( + icon: Icon(icon), + title: title, + subtitle: Text(description), + initialValue: settings.theme, + options: ThemeMode.values, + getName: (option) => toBeginningOfSentenceCase(option.name), + onChanged: (value) => ref + .watch(SettingsController.provider.notifier) + .set(settings.copyWith(theme: value)) + .onError(showError), + ), + ), + .new( + title: "Use Dynamic Theme", + icon: Icons.palette, + 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), + secondary: Icon(icon), + value: settings.useDynamicTheming, + onChanged: + (Platform.isAndroid || + Platform.isLinux || + Platform.isMacOS || + Platform.isWindows) + ? (value) => ref + .watch(SettingsController.provider.notifier) + .set(settings.copyWith(useDynamicTheming: value)) + .onError(showError) + : null, + ), + ), + ]), + ), + .new( + title: "Behavior", + icon: Icons.psychology, + settings: .new([ + .new( + title: "Linux Mobile Mode", + 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), + subtitle: Text(description), + secondary: Icon(icon), + value: settings.linuxMobileMode, + onChanged: Platform.isLinux + ? (value) => ref + .watch(SettingsController.provider.notifier) + .set(settings.copyWith(linuxMobileMode: value)) + .onError(showError) + : null, + ), + ), + ]), + ), + ]), + 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, + settings: .new([ + .new( + title: "Log Out", + description: + "Log out of your account, returning you to the login page.", + builder: (title, description, icon) => Builder( + builder: (context) { + final colorScheme = Theme.of(context).colorScheme; + return M3EButton.icon( + onPressed: () async { + await ref + .watch(UnifiedPushController.provider.notifier) + .deregister() + .onError(showError); + await ref + .watch(ClientController.provider.notifier) + .logout() + .onError(showError); + }, + label: Text(title), + icon: Icon(icon), + tooltip: description, + decoration: .styleFrom( + backgroundColor: colorScheme.errorContainer, + foregroundColor: colorScheme.onErrorContainer, + ), + ); + }, + ), + icon: Icons.logout, + ), + ]), + ), + ]), + }); + } + + static final provider = + AsyncNotifierProvider.autoDispose< + SettingsSectionsController, + IMap> + >(SettingsSectionsController.new); +} diff --git a/lib/controllers/shared_prefs.dart b/lib/controllers/shared_prefs.dart new file mode 100644 index 0000000..9ecb34a --- /dev/null +++ b/lib/controllers/shared_prefs.dart @@ -0,0 +1,12 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +class SharedPrefsController extends Notifier { + @override + SharedPreferencesAsync build() => SharedPreferencesAsync(); + + static final provider = + NotifierProvider( + SharedPrefsController.new, + ); +} diff --git a/lib/controllers/shared_prefs_controller.dart b/lib/controllers/shared_prefs_controller.dart deleted file mode 100644 index f4dcdae..0000000 --- a/lib/controllers/shared_prefs_controller.dart +++ /dev/null @@ -1,12 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:shared_preferences/shared_preferences.dart"; - -class SharedPrefsController extends AsyncNotifier { - @override - Future build() => SharedPreferences.getInstance(); - - static final provider = - AsyncNotifierProvider( - SharedPrefsController.new, - ); -} diff --git a/lib/controllers/space_edges_controller.dart b/lib/controllers/space_edges.dart similarity index 88% rename from lib/controllers/space_edges_controller.dart rename to lib/controllers/space_edges.dart index 12694d6..81347c5 100644 --- a/lib/controllers/space_edges_controller.dart +++ b/lib/controllers/space_edges.dart @@ -4,7 +4,7 @@ import "package:nexus/models/space_edge.dart"; class SpaceEdgesController extends Notifier>> { @override - IMap> build() => const IMap.empty(); + IMap> build() => .new(); void set(IMap> newEdges) => state = state.addAll(newEdges); diff --git a/lib/controllers/spaces.dart b/lib/controllers/spaces.dart new file mode 100644 index 0000000..7dc14b0 --- /dev/null +++ b/lib/controllers/spaces.dart @@ -0,0 +1,149 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.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/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> { + @override + IList build() { + final rooms = ref.watch(RoomsController.provider); + final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider); + final spaceEdges = ref.watch(SpaceEdgesController.provider); + final accountData = ref.watch(AccountDataController.provider); + + final childrenById = { + for (final entry in spaceEdges.entries) + entry.key: entry.value.map((e) => e.childId).toList(), + }; + + Set collectDescendants(String startId) { + final visited = {}; + final stack = [startId]; + + while (stack.isNotEmpty) { + final current = stack.removeLast(); + final children = childrenById[current] ?? const []; + + for (final child in children) { + if (visited.add(child)) { + stack.add(child); + } + } + } + + return visited; + } + + Space buildSpace(String spaceId) { + final space = rooms[spaceId]; + final directChildrenIds = childrenById[spaceId] ?? const []; + + final directRooms = []; + final subSpaces = []; + + for (final childId in directChildrenIds) { + final room = rooms[childId]; + if (room == null) continue; + + if (childrenById.containsKey(childId)) { + final descendants = collectDescendants(childId); + + subSpaces.add( + .new( + room: room, + children: .new(descendants.map((id) => rooms[id]).nonNulls), + ), + ); + } else { + directRooms.add(room); + } + } + + return .new( + id: spaceId, + room: space, + title: space?.metadata?.name ?? "Unnamed Space", + children: .new(directRooms), + subSpaces: .new(subSpaces), + ); + } + + final spaces = topLevelSpaceIds.map(buildSpace).toIList(); + + final usedRoomIds = { + for (final space in spaces) ...[ + ...space.children.map((r) => r.metadata?.id), + ...space.subSpaces.expand((s) => s.children.map((r) => r.metadata?.id)), + ], + }.nonNulls.toISet(); + + final directMessages = accountData.directMessages.values.flattened; + + final otherRooms = rooms.entries + .where( + (e) => + !usedRoomIds.contains(e.key) && + !topLevelSpaceIds.contains(e.key) && + !childrenById.containsKey(e.key), + ) + .map((e) => e.value) + .toIList(); + + final homeRooms = otherRooms + .where((r) => !directMessages.contains(r.metadata?.id)) + .toIList(); + + final dmRooms = otherRooms + .where((r) => directMessages.contains(r.metadata?.id)) + .toIList(); + + final allSpaces = [ + .new( + id: "home", + title: "Home", + icon: Icons.home, + children: homeRooms, + subSpaces: .new(), + ), + .new( + id: "dms", + title: "Direct Messages", + icon: Icons.people, + children: dmRooms, + subSpaces: .new(), + ), + ...spaces, + ]; + + return allSpaces + .map( + (space) => space.copyWith( + children: .new( + space.children + .sortedBy( + (element) => + element + .metadata + ?.sortingTimestamp + .millisecondsSinceEpoch ?? + 0, + ) + .sortedBy((room) => room.metadata?.unreadMessages ?? 0) + .reversed, + ), + ), + ) + .toIList(); + } + + static final provider = NotifierProvider>( + SpacesController.new, + ); +} diff --git a/lib/controllers/spaces_controller.dart b/lib/controllers/spaces_controller.dart deleted file mode 100644 index ca217a5..0000000 --- a/lib/controllers/spaces_controller.dart +++ /dev/null @@ -1,116 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/account_data_controller.dart"; -import "package:nexus/controllers/rooms_controller.dart"; -import "package:nexus/controllers/top_level_spaces_controller.dart"; -import "package:nexus/controllers/space_edges_controller.dart"; -import "package:nexus/models/space.dart"; -import "package:nexus/models/room.dart"; -import "package:nexus/models/space_edge.dart"; - -class SpacesController extends Notifier> { - @override - IList build() { - final rooms = ref.watch(RoomsController.provider); - final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider); - final spaceEdges = ref.watch(SpaceEdgesController.provider); - - final childRoomsBySpaceId = IMap.fromEntries( - topLevelSpaceIds.map((spaceId) { - ISet walk(String currentId) { - final children = spaceEdges[currentId] ?? IList(); - - return children.fold>(const ISet.empty(), (acc, edge) { - final childId = edge.childId; - final isSpace = spaceEdges.containsKey(childId); - - return acc - .addAll(!isSpace ? ISet([childId]) : const ISet.empty()) - .addAll(isSpace ? walk(childId) : const ISet.empty()); - }); - } - - return MapEntry( - spaceId, - walk(spaceId).map((id) => rooms[id]).nonNulls.toIList(), - ); - }), - ); - - final allNestedRoomIds = childRoomsBySpaceId.values - .expand((l) => l) - .map( - (room) => rooms.entries - .firstWhere( - (entry) => entry.value.metadata?.id == room.metadata?.id, - ) - .key, - ) - .toISet(); - - final otherRooms = rooms.entries - .where( - (e) => - !allNestedRoomIds.contains(e.key) && - !topLevelSpaceIds.contains(e.key) && - !spaceEdges.containsKey(e.key), - ) - .map((e) => e.value); - - final accountData = ref.watch(AccountDataController.provider); - - final directMessages = IMap( - accountData["m.direct"]?.content ?? {}, - ).values.expand((element) => element); - - final homeRooms = otherRooms - .where( - (room) => - directMessages.any( - (directMessage) => directMessage == room.metadata?.id, - ) == - false, - ) - .toIList(); - - final dmRooms = otherRooms - .where( - (room) => directMessages.any( - (directMessage) => directMessage == room.metadata?.id, - ), - ) - .toIList(); - - final topLevelSpacesList = topLevelSpaceIds - .map((id) { - final room = rooms[id]; - if (room == null) return null; - - final children = childRoomsBySpaceId[id] ?? IList(); - return Space( - id: id, - title: room.metadata?.name ?? "Unnamed Room", - room: room, - children: children, - ); - }) - .nonNulls - .toIList(); - - return [ - Space(id: "home", title: "Home", icon: Icons.home, children: homeRooms), - Space( - id: "dms", - title: "Direct Messages", - icon: Icons.people, - children: dmRooms, - ), - ...topLevelSpacesList, - ].toIList(); - } - - static final provider = NotifierProvider>( - 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/sync_status_controller.dart b/lib/controllers/sync_status.dart similarity index 60% rename from lib/controllers/sync_status_controller.dart rename to lib/controllers/sync_status.dart index fe65732..256c8e2 100644 --- a/lib/controllers/sync_status_controller.dart +++ b/lib/controllers/sync_status.dart @@ -1,11 +1,17 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/main.dart"; import "package:nexus/models/sync_status.dart"; class SyncStatusController extends Notifier { @override Null build() => null; - void set(SyncStatus newStatus) => state = newStatus; + void set(SyncStatus newStatus) { + if (newStatus.type == .permanentlyFailed) { + showError(newStatus.error ?? "Syncing failed"); + } + state = newStatus; + } static final provider = NotifierProvider( SyncStatusController.new, diff --git a/lib/controllers/top_level_spaces_controller.dart b/lib/controllers/top_level_spaces.dart similarity index 89% rename from lib/controllers/top_level_spaces_controller.dart rename to lib/controllers/top_level_spaces.dart index e1f9c88..321e29d 100644 --- a/lib/controllers/top_level_spaces_controller.dart +++ b/lib/controllers/top_level_spaces.dart @@ -3,7 +3,7 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; class TopLevelSpacesController extends Notifier> { @override - IList build() => const IList.empty(); + IList build() => .new(); void set(IList newSpaces) => state = newSpaces; diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart new file mode 100644 index 0000000..db99868 --- /dev/null +++ b/lib/controllers/unified_push.dart @@ -0,0 +1,178 @@ +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 { + @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 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); + }, + 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 (event, roomMetadata) = await ref + .read(ClientController.provider.notifier) + .handlePush(json.decode(String.fromCharCodes(message.content))); + + if (event.unreadType?.shouldNotify() != 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.eventId, + body: switch (event.content) { + MessageContent(:final body?) || + StickerContent(:final body) => body, + _ => null, + }, + ); + + if (isInBackground) exit(0); + }, + onUnregistered: deregister, + ); + + ref.listen( + ClientStateController.provider.select((value) => value?.deviceId), + (_, _) => register(), + ); + + if (registered) { + // Needs to be registered every startup + await register(); + } + + return registered; + } + + Future register() async { + state = .loading(); + try { + final deviceId = ref.read( + ClientStateController.provider.select((value) => value?.deviceId), + ); + if (deviceId == null) return; + + 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."); + } + + await UnifiedPush.register( + instance: deviceId, + vapid: capabilities.webpush?.vapid, + ); + } 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..8f7fd8e --- /dev/null +++ b/lib/controllers/unified_push_allowed.dart @@ -0,0 +1,33 @@ +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()) { + 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 new file mode 100644 index 0000000..41c3ef4 --- /dev/null +++ b/lib/controllers/url_preview.dart @@ -0,0 +1,26 @@ +import "package:flutter/widgets.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/models/open_graph_data.dart"; + +class UrlPreviewController(final Uri url) + extends AsyncNotifier { + @override + Future build() async { + if (url.host == "matrix.to") return null; + + try { + return await ref + .watch(ClientController.provider.notifier) + .getUrlPreview(url); + } catch (error, stackTrace) { + debugPrintStack(label: error.toString(), stackTrace: stackTrace); + return null; + } + } + + static final provider = + AsyncNotifierProvider.family( + UrlPreviewController.new, + ); +} diff --git a/lib/controllers/user.dart b/lib/controllers/user.dart new file mode 100644 index 0000000..c69ebd0 --- /dev/null +++ b/lib/controllers/user.dart @@ -0,0 +1,48 @@ +import "dart:async"; +import "package:collection/collection.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/members.dart"; +import "package:nexus/controllers/profile.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/models/configs/user.dart"; +import "package:nexus/models/content/membership.dart"; + +class UserController extends AsyncNotifier { + final UserConfig config; + UserController(this.config); + + @override + Future build() async { + final member = config.roomId == null + ? null + : await ref.watch( + MembersController.provider(config.roomId!).selectAsync( + (value) => value.firstWhereOrNull( + (membership) => membership.stateKey == config.userId, + ), + ), + ); + + if (member?.content case final MembershipContent content) { + return content; + } + + final profileResponse = await ref.watch( + ProfileController.provider(config.userId).future, + ); + + return .new( + status: .leave, + avatarUrl: profileResponse.profile.avatarUrl, + displayName: + profileResponse.profile.displayName ?? config.userId.localpart, + ); + } + + static final provider = + AsyncNotifierProvider.family< + UserController, + MembershipContent, + UserConfig + >(UserController.new); +} diff --git a/lib/controllers/via.dart b/lib/controllers/via.dart new file mode 100644 index 0000000..39db682 --- /dev/null +++ b/lib/controllers/via.dart @@ -0,0 +1,60 @@ +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_state.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/room.dart"; + +class ViaController(final Room room) extends Notifier { + @override + String build() { + final servers = {}; + + void addUserId(String? userId) { + final server = userId?.split(":").lastOrNull; + if (server != null) { + servers.add(server); + } + } + + addUserId(ref.watch(ClientStateController.provider)?.userId); + + final powerLevelsEventId = room.state[EventType.powerLevels.type]?[""]; + final powerLevels = powerLevelsEventId == null + ? null + : room.events[powerLevelsEventId]; + + if (powerLevels?.content case PowerLevelsContent(:final users)) { + for (final userId in users.keys) { + addUserId(userId); + if (servers.length >= 5) break; + } + } + + final members = room.state[EventType.membership.type]?.values.toIList(); + for (var i = 0; servers.length < 5; i++) { + final membershipEventId = members?.getOrNull(i); + final member = membershipEventId == null + ? null + : room.events[membershipEventId]; + + if (member?.content case MembershipContent(:final status)) { + if (status == .join) { + addUserId(member?.stateKey); + } + } + + if (members?.getOrNull(i) == null) break; + } + + return servers.isEmpty + ? "" + : "?${servers.map((server) => "via=$server").join("&")}"; + } + + static final provider = NotifierProvider.family( + ViaController.new, + ); +} diff --git a/lib/helpers/extensions/build_event_options.dart b/lib/helpers/extensions/build_event_options.dart new file mode 100644 index 0000000..a95da6d --- /dev/null +++ b/lib/helpers/extensions/build_event_options.dart @@ -0,0 +1,266 @@ +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/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(Event, RelationType) onRelation, + }) { + final theme = Theme.of(context); + final danger = theme.colorScheme.error; + + final notifier = ref.read( + RoomChatController.provider((roomId, null)).notifier, + ); + final client = ref.read(ClientController.provider.notifier); + + final isPinned = ref + .watch(PinnedIdsController.provider(roomId)) + .contains(eventId); + + Future sendReaction(String emoji) async { + await notifier.sendReaction(emoji, this).onError(showError); + + await ref + .read(RecentEmojiController.provider.notifier) + .add(emoji) + .onError(showError); + } + + 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( + children: [ + for (final emoji in { + ...ref + .watch(RecentEmojiController.provider) + .map((entry) => entry.emoji), + "👍", + "🤣", + "😭", + "🤔", + }.take(4)) + IconButton( + icon: Text(emoji), + onPressed: () { + Navigator.of(context).pop(); + sendReaction(emoji); + }, + ), + 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(); + sendReaction(emoji); + }, + ), + ); + }, + ), + ], + ), + ), + ), + + if (ref.watch( + PowerLevelController.provider( + .new(eventType: .message, roomId: roomId), + ), + )) + PopupMenuItem( + onTap: () => onRelation(this, .reply), + child: const ListTile( + leading: Icon(Icons.reply), + title: Text("Reply"), + ), + ), + + if (content is MessageContent && sender == userId) + PopupMenuItem( + onTap: () => onRelation(this, .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$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), + ), + )) + PopupMenuItem( + onTap: () => showReasonDialog( + title: "Delete Message", + description: + "Are you sure you want to delete this message? " + "This cannot be reversed.", + action: "Delete", + onConfirm: (reason) => notifier.deleteMessage(this, reason: reason), + ), + child: ListTile( + leading: Icon(Icons.delete, color: danger), + title: Text("Delete", 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) => client.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/get_headers.dart b/lib/helpers/extensions/get_headers.dart deleted file mode 100644 index e1bb5f3..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_controller.dart"; - -extension GetHeaders on WidgetRef { - Map get headers => - watch(HeaderController.provider).requireValue; -} diff --git a/lib/helpers/extensions/get_localpart.dart b/lib/helpers/extensions/get_localpart.dart new file mode 100644 index 0000000..fa3d285 --- /dev/null +++ b/lib/helpers/extensions/get_localpart.dart @@ -0,0 +1,3 @@ +extension GetLocalpart on String { + String get localpart => length > 1 ? substring(1).split(":").first : "?"; +} diff --git a/lib/helpers/extensions/get_xcode_sdk.dart b/lib/helpers/extensions/get_xcode_sdk.dart new file mode 100644 index 0000000..58920c8 --- /dev/null +++ b/lib/helpers/extensions/get_xcode_sdk.dart @@ -0,0 +1,14 @@ +import "dart:io"; + +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 ${sdkType ?? "XCode"} ${findTool ?? "SDK"}"); + } + + return result.stdout.toString().trim(); +} diff --git a/lib/helpers/extensions/gomuks_buffer.dart b/lib/helpers/extensions/gomuks_buffer.dart index 88cfd5a..cc16b46 100644 --- a/lib/helpers/extensions/gomuks_buffer.dart +++ b/lib/helpers/extensions/gomuks_buffer.dart @@ -7,8 +7,8 @@ import "package:nexus/src/third_party/gomuks.g.dart"; extension GomuksOwnedBufferToX on GomuksOwnedBuffer { Uint8List toBytes() { try { - if (base == nullptr || length <= 0) return Uint8List(0); - return Uint8List.fromList(base.asTypedList(length)); + if (base == nullptr || length <= 0) return .new(0); + return .fromList(base.asTypedList(length)); } finally { calloc.free(base); } diff --git a/lib/helpers/extensions/join_room_with_snackbars.dart b/lib/helpers/extensions/join_room_with_snackbars.dart deleted file mode 100644 index 05b045d..0000000 --- a/lib/helpers/extensions/join_room_with_snackbars.dart +++ /dev/null @@ -1,90 +0,0 @@ -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:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/key_controller.dart"; -import "package:nexus/controllers/spaces_controller.dart"; -import "package:nexus/helpers/extensions/link_to_mention.dart"; -import "package:nexus/models/requests/join_room_request.dart"; - -extension JoinRoomWithSnackbars on ClientController { - Future joinRoomWithSnackBars( - BuildContext context, - String roomAlias, - WidgetRef ref, - ) async { - final roomIdOrAlias = roomAlias.mention ?? roomAlias; - - final scaffoldMessenger = ScaffoldMessenger.of(context); - - final snackbar = scaffoldMessenger.showSnackBar( - SnackBar( - content: Text("Joining room $roomIdOrAlias."), - duration: Duration(days: 999), - ), - ); - - try { - final id = await joinRoom( - JoinRoomRequest( - roomIdOrAlias: roomIdOrAlias, - via: IList(Uri.tryParse(roomAlias)?.queryParametersAll["via"] ?? []), - ), - ); - - snackbar.close(); - - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text("Room $roomIdOrAlias successfully joined."), - action: SnackBarAction( - 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, - ), - ) - .id, - ); - - if (space == null) { - await ref - .watch( - KeyController.provider(KeyController.roomKey).notifier, - ) - .set(id); - } - }, - ), - ), - ); - } catch (error) { - snackbar.close(); - if (context.mounted) { - scaffoldMessenger.showSnackBar( - SnackBar( - backgroundColor: Theme.of(context).colorScheme.errorContainer, - content: Text( - error.toString(), - style: TextStyle( - color: Theme.of(context).colorScheme.onErrorContainer, - ), - ), - ), - ); - } - } - } -} diff --git a/lib/helpers/extensions/link_to_mention.dart b/lib/helpers/extensions/link_to_mention.dart index b0e62aa..8595669 100644 --- a/lib/helpers/extensions/link_to_mention.dart +++ b/lib/helpers/extensions/link_to_mention.dart @@ -9,6 +9,7 @@ extension LinkToMention on String { /// /// Returns the decoded identifier (e.g. "#room:matrix.org") /// or null if this is not a Matrix link. + /// TODO: Needs to be reworked to handle event links. Might be worth just rewriting, I don't like this code. String? get mention { final trimmed = trim(); @@ -30,7 +31,8 @@ extension LinkToMention on String { final identifier = uri.pathSegments.last; if (identifier.isNotEmpty) { return "${switch (uri.pathSegments.firstOrNull) { - "r" || "roomid" => "#", + "r" => "#", + "roomid" => "!", "u" => "@", _ => "", }}${Uri.decodeComponent(identifier)}"; diff --git a/lib/helpers/extensions/mxc_to_https.dart b/lib/helpers/extensions/mxc_to_https.dart deleted file mode 100644 index 468da12..0000000 --- a/lib/helpers/extensions/mxc_to_https.dart +++ /dev/null @@ -1,4 +0,0 @@ -extension MxcToHttps on Uri { - Uri mxcToHttps(String homeserver) => - Uri.parse("${homeserver}_matrix/client/v1/media/download/$host$path"); -} diff --git a/lib/helpers/extensions/scheme_to_theme.dart b/lib/helpers/extensions/scheme_to_theme.dart index aff5d52..6beca04 100644 --- a/lib/helpers/extensions/scheme_to_theme.dart +++ b/lib/helpers/extensions/scheme_to_theme.dart @@ -1,18 +1,36 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; extension SchemeToTheme on ColorScheme { - ThemeData get theme => ThemeData.from(colorScheme: this).copyWith( - cardTheme: CardThemeData(color: primaryContainer), - appBarTheme: AppBarTheme( - titleSpacing: 0, - backgroundColor: surfaceContainerLow, - ), - textTheme: ThemeData( - fontFamilyFallback: ["sans"], + ThemeData get theme { + final textTheme = ThemeData( + fontFamilyFallback: ["sans", "emoji", "fallback-sans", "fallback-emoji"], brightness: brightness, - ).textTheme, - inputDecorationTheme: const InputDecorationTheme( - border: OutlineInputBorder(), - ), - ); + ).textTheme; + return .from(colorScheme: this).copyWith( + cardTheme: .new(color: primaryContainer), + popupMenuTheme: .new( + shape: RoundedRectangleBorder(borderRadius: .circular(16)), + color: surfaceContainerHigh, + ), + appBarTheme: AppBarTheme( + titleSpacing: 0, + backgroundColor: surfaceContainerLow, + ), + tooltipTheme: .new( + textStyle: textTheme.labelLarge?.copyWith( + fontSize: 16, + fontWeight: .w600, + ), + padding: .all(8), + decoration: BoxDecoration( + color: surfaceContainerHighest, + borderRadius: .circular(8), + ), + ), + 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 new file mode 100644 index 0000000..6ccba4c --- /dev/null +++ b/lib/helpers/extensions/show_about_dialog.dart @@ -0,0 +1,83 @@ +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 ShowAboutDialog on BuildContext { + Future showAboutDialog(WidgetRef ref) async { + final packageInfo = await PackageInfo.fromPlatform(); + + if (mounted) { + showDialog( + context: this, + builder: (context) => AlertDialog( + content: Column( + mainAxisSize: .min, + spacing: 16, + children: [ + Row( + spacing: 12, + children: [ + SvgPicture.asset("assets/bundled/icon.svg", width: 64), + Expanded( + child: Column( + crossAxisAlignment: .start, + children: [ + Wrap( + crossAxisAlignment: .center, + spacing: 4, + children: [ + Text( + "Nexus", + style: Theme.of(context).textTheme.headlineMedium, + ), + Text("(${packageInfo.version})"), + ], + ), + + Text( + "A simple and user-friendly Matrix client", + overflow: .ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + ), + ], + ), + M3ECardColumn( + onTap: (index) => + ref.watch(LaunchHelper.provider).launchUrl(switch (index) { + 0 => .https("git.federated.nexus", "nexus/nexus"), + _ => .https("liberapay.com", "QuadRadical"), + }), + children: [ + ListTile( + leading: Icon(Icons.commit), + title: Text("Source Code"), + ), + ListTile( + leading: Icon(Icons.favorite, color: Colors.pinkAccent), + title: Text("Donate"), + ), + ], + ), + ], + ), + actions: [ + TextButton( + onPressed: () => showLicensePage(context: context), + child: Text("View licenses"), + ), + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Close"), + ), + ], + ), + ); + } + } +} diff --git a/lib/helpers/extensions/show_context_menu.dart b/lib/helpers/extensions/show_context_menu.dart index f4762c3..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,13 +9,13 @@ extension ShowContextMenu on BuildContext { showMenu( context: this, - position: RelativeRect.fromLTRB( + constraints: .loose(.infinite), + position: .fromLTRB( globalPosition.dx, globalPosition.dy, overlay.size.width - globalPosition.dx, overlay.size.height - globalPosition.dy, ), - color: Theme.of(this).colorScheme.surfaceContainerHighest, items: children, ); } diff --git a/lib/helpers/extensions/show_user_popover.dart b/lib/helpers/extensions/show_user_popover.dart new file mode 100644 index 0000000..f42cd57 --- /dev/null +++ b/lib/helpers/extensions/show_user_popover.dart @@ -0,0 +1,18 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/widgets/user_bottom_sheet.dart"; + +extension ShowUserPopover on BuildContext { + void showUserPopover( + MembershipContent member, + String userId, { + String? roomId, + }) => showModalBottomSheet( + constraints: BoxConstraints.loose( + Size(500, View.of(this).physicalSize.height - 80), + ), + isScrollControlled: true, + context: this, + builder: (context) => UserBottomSheet(member, userId, roomId: roomId), + ); +} diff --git a/lib/helpers/extensions/size_to_string.dart b/lib/helpers/extensions/size_to_string.dart new file mode 100644 index 0000000..a9db345 --- /dev/null +++ b/lib/helpers/extensions/size_to_string.dart @@ -0,0 +1,15 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; + +extension SizeToString on int { + String get sizeAsString { + const suffixes = IListConst(["B", "KB", "MB", "GB", "TB", "PB"]); + + var i = 0; + var size = toDouble(); + while (size > 1024 && i < suffixes.length - 1) { + size /= 1024; + i++; + } + return "${size.toStringAsFixed(2)} ${suffixes[i]}"; + } +} diff --git a/lib/helpers/extensions/string_to_color.dart b/lib/helpers/extensions/string_to_color.dart new file mode 100644 index 0000000..a21ca82 --- /dev/null +++ b/lib/helpers/extensions/string_to_color.dart @@ -0,0 +1,6 @@ +import "package:color_hash/color_hash.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 new file mode 100644 index 0000000..41567c2 --- /dev/null +++ b/lib/helpers/font_licenses.dart @@ -0,0 +1,196 @@ +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. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +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.""", + ), + LicenseEntryWithLineBreaks( + ["Roboto"], + """Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic) + +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 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.""", + ), +]; diff --git a/lib/helpers/hooks/chat_scroll.dart b/lib/helpers/hooks/chat_scroll.dart new file mode 100644 index 0000000..3b1531e --- /dev/null +++ b/lib/helpers/hooks/chat_scroll.dart @@ -0,0 +1,203 @@ +import "dart:async"; + +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/event.dart"; +import "package:nexus/models/room_chat.dart"; + +final class ChatScroll({ + required final IList historyItems, + required final IList liveItems, + required final GlobalKey centerKey, + required final ScrollController scrollController, + required final bool atBottom, + required final Future Function(String id) jumpToId, + required final Future Function() jumpToBottom, + required final GlobalKey Function(String eventId) keyFor, +}) { + factory use({ + required AsyncValue controllerData, + required Future Function(Direction direction) paginate, + required Future Function() markRead, + required ValueNotifier contextualEvent, + }) { + final anchorId = useState(null); + + final itemKeys = useMemoized(() => {}, []); + GlobalKey keyFor(String eventId) => + itemKeys.putIfAbsent(eventId, GlobalKey.new); + + final scrollController = useScrollController(); + final centerKey = useMemoized(GlobalKey.new); + + final atBottom = useState(true); + + final pendingAnchorTarget = useState(null); + final anchorMountedCompleter = useRef?>(null); + + useEffect(() { + if (anchorId.value == null) { + if (controllerData case AsyncData(:final value?) + when value.timeline.isNotEmpty) { + final hasContextualEvent = value.timeline.any( + (event) => event.eventId == contextualEvent.value, + ); + + anchorId.value = hasContextualEvent + ? contextualEvent.value + : value.timeline.last.eventId; + } + } + + return null; + }, [controllerData, contextualEvent.value]); + + useEffect(() { + final target = pendingAnchorTarget.value; + if (target == null) return null; + + final found = + controllerData.value?.timeline.any( + (event) => event.eventId == target, + ) ?? + false; + + if (found || controllerData is AsyncError) { + if (found) { + anchorId.value = target; + + WidgetsBinding.instance.addPostFrameCallback((_) { + final context = keyFor(target).currentContext; + if (context != null && context.mounted) { + anchorMountedCompleter.value?.complete(context); + anchorMountedCompleter.value = null; + } + }); + } else { + anchorMountedCompleter.value?.completeError( + StateError("Failed to load context for $target"), + ); + anchorMountedCompleter.value = null; + } + pendingAnchorTarget.value = null; + } + + return null; + }, [controllerData, pendingAnchorTarget.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.indexWhere((item) => item.eventId == 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]); + + useEffect( + () { + const loadThreshold = 500.0; + const readThreshold = 50.0; + + Future checkPosition() async { + if (!scrollController.hasClients) return; + + final position = scrollController.position; + + final isAtBottom = position.extentBefore <= readThreshold; + 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 <= readThreshold) { + await markRead(); + } + } + + scrollController.addListener(checkPosition); + + WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition()); + + return () => scrollController.removeListener(checkPosition); + }, + [ + scrollController, + controllerData, + paginate, + markRead, + contextualEvent.value, + ], + ); + + return .new( + historyItems: split.history, + liveItems: split.live, + centerKey: centerKey, + scrollController: scrollController, + atBottom: atBottom.value, + jumpToId: (String itemId) async { + if (!scrollController.hasClients) return; + + final existing = keyFor(itemId).currentContext; + if (existing != null && existing.mounted) { + // Already mounted, just scroll + await Scrollable.ensureVisible( + existing, + alignment: 0.5, + duration: const .new(milliseconds: 700), + curve: Curves.easeInOut, + ); + } else { + final completer = Completer(); + anchorMountedCompleter.value = completer; + pendingAnchorTarget.value = itemId; + contextualEvent.value = itemId; + + final context = await completer.future; + if (!context.mounted) return; + + await Scrollable.ensureVisible(context, alignment: 10); + if (!context.mounted) return; + await Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const .new(milliseconds: 700), + curve: Curves.easeOutCirc, + ); + } + }, + 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, + ); + }, + keyFor: keyFor, + ); + } +} diff --git a/lib/helpers/launch_helper.dart b/lib/helpers/launch_helper.dart index f872ef7..89c7015 100644 --- a/lib/helpers/launch_helper.dart +++ b/lib/helpers/launch_helper.dart @@ -2,17 +2,12 @@ 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( url, - mode: useWebview - ? ul.LaunchMode.inAppBrowserView - : ul.LaunchMode.externalApplication, + mode: useWebview ? .inAppBrowserView : .externalApplication, ); } on PlatformException catch (_) { return false; diff --git a/lib/helpers/mxc_image.dart b/lib/helpers/mxc_image.dart new file mode 100644 index 0000000..83a345a --- /dev/null +++ b/lib/helpers/mxc_image.dart @@ -0,0 +1,33 @@ +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(final WidgetRef ref, final DownloadMediaRequest request) + extends ImageProvider { + @override + Future obtainKey(ImageConfiguration configuration) => + Future.value(this); + + @override + ImageStreamCompleter loadImage(MxcImage key, ImageDecoderCallback decode) => + MultiFrameImageStreamCompleter(codec: _loadAsync(decode), scale: 1.0); + + Future _loadAsync(ImageDecoderCallback decode) async { + final file = await ref + .read(ClientController.provider.notifier) + .downloadMedia(request); + final buffer = await ImmutableBuffer.fromFilePath(file.path); + + return decode(buffer); + } + + @override + bool operator ==(Object other) => + other is MxcImage && other.request == request; + + @override + int get hashCode => request.hashCode; +} diff --git a/lib/helpers/required_validator_helper.dart b/lib/helpers/required_validator_helper.dart new file mode 100644 index 0000000..d243684 --- /dev/null +++ b/lib/helpers/required_validator_helper.dart @@ -0,0 +1,2 @@ +String? requiredValidator(String? value) => + value == null || value.isEmpty ? "This field is required" : null; diff --git a/lib/main.dart b/lib/main.dart index 5ad6c24..5586cbf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,26 +1,31 @@ import "dart:io"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; + +import "package:dynamic_color/dynamic_color.dart"; import "package:flutter/foundation.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/header_controller.dart"; -import "package:nexus/controllers/init_complete_controller.dart"; -import "package:nexus/controllers/multi_provider_controller.dart"; -import "package:nexus/controllers/shared_prefs_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; +import "package:media_kit/media_kit.dart"; +import "package:nexus/controllers/client_state.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/unified_push.dart"; import "package:nexus/helpers/extensions/scheme_to_theme.dart"; -import "package:nexus/pages/chat_page.dart"; -import "package:nexus/pages/login_page.dart"; -import "package:nexus/pages/verify_page.dart"; +import "package:nexus/helpers/font_licenses.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:dynamic_system_colors/dynamic_system_colors.dart"; -import "package:window_size/window_size.dart"; +import "package:material_ui/material_ui.dart"; final GlobalKey navigatorKey = GlobalKey(); +late final bool isInBackground; final class Logger extends ProviderObserver { @override @@ -33,15 +38,13 @@ Time: ${DateTime.now().toIso8601String()} Provider: ${context.provider} Previous Value: ${previousValue is AsyncData ? previousValue.value : previousValue} New Value: ${newValue is AsyncData ? newValue.value : newValue} -}"""); +"""); } void showError(Object error, [StackTrace? stackTrace]) { - if (error.toString().contains("DioException")) return; - if (error.toString().contains("Invalid source")) return; - if (error.toString().contains("UTF-16")) return; - if (error.toString().contains("HTTP request failed")) return; - if (error.toString().contains("Invalid image data")) return; + if (error.toString().contains("'_nextFrame != null': is not true.")) { + return; + } debugPrintStack(stackTrace: stackTrace, label: error.toString()); if (navigatorKey.currentContext != null) { @@ -56,88 +59,138 @@ void showError(Object error, [StackTrace? stackTrace]) { } } -void main() async { +void main(List args) async { WidgetsFlutterBinding.ensureInitialized(); + MediaKit.ensureInitialized(); - await windowManager.ensureInitialized(); - await windowManager.waitUntilReadyToShow( - WindowOptions(titleBarStyle: TitleBarStyle.hidden), - ); - - if (Platform.isLinux) { - setWindowMinSize(const Size.square(500)); - } else { - await windowManager.setMinimumSize(Size.square(500)); - } + LicenseRegistry.addLicense(() => .fromIterable(fontLicenses)); FlutterError.onError = (FlutterErrorDetails details) => showError(details.exception.toString(), details.stack); - runApp( - ProviderScope( - 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) => MaterialApp( - navigatorKey: navigatorKey, - debugShowCheckedModeBanner: false, - // Use indigo to work around bugs in theme generation - theme: (lightDynamic ?? ColorScheme.fromSeed(seedColor: Colors.indigo)) - .theme, - darkTheme: - (darkDynamic ?? - ColorScheme.fromSeed( - seedColor: Colors.indigo, - brightness: Brightness.dark, - )) - .theme, - home: Scaffold( + builder: (lightDynamic, darkDynamic) => Consumer( + builder: (context, ref, child) => MaterialApp( + navigatorKey: navigatorKey, + debugShowCheckedModeBanner: false, + theme: + (ref + .watch(SettingsController.provider) + .maybeWhen( + orElse: () => lightDynamic, + data: (settings) => + settings.useDynamicTheming ? lightDynamic : null, + ) ?? + ThemeData.light().colorScheme) + .theme, + darkTheme: + (ref + .watch(SettingsController.provider) + .maybeWhen( + orElse: () => darkDynamic, + data: (settings) => + settings.useDynamicTheming ? darkDynamic : null, + ) ?? + ThemeData.dark().colorScheme) + .theme, + themeMode: ref + .watch(SettingsController.provider) + .maybeWhen( + data: (settings) => settings.theme, + orElse: () => ThemeMode.system, + ), + home: child, + ), + child: Scaffold( body: Consumer( - builder: (_, ref, _) => ref - .watch( - MultiProviderController.provider( - IListConst([ - SharedPrefsController.provider, - ClientController.provider, - HeaderController.provider, - ]), - ), - ) - .betterWhen( - data: (_) => Consumer( - builder: (_, ref, _) { - final clientState = ref.watch( - ClientStateController.provider, - ); + builder: (_, ref, _) => switch (ref.watch( + MultiProviderController.provider( + .new([ + GomuksListenerController.provider, + NotificationController.provider, + UnifiedPushController.provider, + MemberListOpenedController.provider, + KeyController.provider(KeyController.roomKey), + KeyController.provider(KeyController.spaceKey), + ]), + ), + )) { + AsyncData(value: _) || AsyncLoading(value: _?) => Consumer( + builder: (_, ref, _) { + final clientState = ref.watch(ClientStateController.provider); - if (clientState == null || !clientState.isInitialized) { - return Loading(); - } + if (clientState == null || !clientState.isInitialized) { + return Loading(); + } - if (!clientState.isLoggedIn) { - return LoginPage(); - } else if (!clientState.isVerified) { - return VerifyPage(); - } else { - return ref.watch(InitCompleteController.provider) - ? ChatPage() - : 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), + ), + ]), ), + 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/author_config.dart b/lib/models/configs/author_config.dart deleted file mode 100644 index af63c63..0000000 --- a/lib/models/configs/author_config.dart +++ /dev/null @@ -1,14 +0,0 @@ -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/room.dart"; -part "author_config.freezed.dart"; -part "author_config.g.dart"; - -@freezed -abstract class AuthorConfig with _$AuthorConfig { - const factory AuthorConfig({required Message message, required Room room}) = - _AuthorConfig; - - factory AuthorConfig.fromJson(Map json) => - _$AuthorConfigFromJson(json); -} diff --git a/lib/models/configs/members_by_status.dart b/lib/models/configs/members_by_status.dart new file mode 100644 index 0000000..6b4aec4 --- /dev/null +++ b/lib/models/configs/members_by_status.dart @@ -0,0 +1,17 @@ +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(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/message_config.dart b/lib/models/configs/message_config.dart deleted file mode 100644 index 9020f78..0000000 --- a/lib/models/configs/message_config.dart +++ /dev/null @@ -1,28 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/event.dart"; -import "package:nexus/models/room.dart"; -part "message_config.freezed.dart"; -part "message_config.g.dart"; - -@freezed -abstract class MessageConfig with _$MessageConfig { - const MessageConfig._(); - const factory MessageConfig({ - @Default(false) bool alwaysReturn, - @Default(false) bool includeEdits, - required Room room, - required Event event, - }) = _MessageConfig; - - @override - bool operator ==(Object other) => - other.runtimeType == runtimeType && - other is MessageConfig && - other.event.eventId == event.eventId; - - @override - int get hashCode => Object.hash(runtimeType, event.eventId); - - factory MessageConfig.fromJson(Map json) => - _$MessageConfigFromJson(json); -} diff --git a/lib/models/configs/messages_config.dart b/lib/models/configs/messages_config.dart deleted file mode 100644 index b33a71c..0000000 --- a/lib/models/configs/messages_config.dart +++ /dev/null @@ -1,17 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/event.dart"; -import "package:nexus/models/room.dart"; -part "messages_config.freezed.dart"; -part "messages_config.g.dart"; - -@freezed -abstract class MessagesConfig with _$MessagesConfig { - const factory MessagesConfig({ - required Room room, - required IList events, - }) = _MessagesConfig; - - factory MessagesConfig.fromJson(Map json) => - _$MessagesConfigFromJson(json); -} diff --git a/lib/models/configs/power_level.dart b/lib/models/configs/power_level.dart new file mode 100644 index 0000000..5722db7 --- /dev/null +++ b/lib/models/configs/power_level.dart @@ -0,0 +1,29 @@ +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 +sealed class PowerLevelConfig with _$PowerLevelConfig { + const factory PowerLevelConfig({ + required EventType eventType, + required String roomId, + }) = EventPowerLevelConfig; + + const factory PowerLevelConfig.membershipAction({ + required MembershipAction action, + required String targetUser, + required String roomId, + }) = MembershipActionPowerLevelConfig; + + const factory PowerLevelConfig.state({ + required EventType eventType, + required String roomId, + }) = StatePowerLevelConfig; + + const factory PowerLevelConfig.redaction({ + required String targetUser, + required String roomId, + }) = RedactionPowerLevelConfig; +} diff --git a/lib/models/configs/reactions.dart b/lib/models/configs/reactions.dart new file mode 100644 index 0000000..b9de793 --- /dev/null +++ b/lib/models/configs/reactions.dart @@ -0,0 +1,16 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "reactions.freezed.dart"; +part "reactions.g.dart"; + +@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 new file mode 100644 index 0000000..bbbc83d --- /dev/null +++ b/lib/models/configs/user.dart @@ -0,0 +1,14 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "user.freezed.dart"; +part "user.g.dart"; + +@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 new file mode 100644 index 0000000..25167e9 --- /dev/null +++ b/lib/models/content/avatar.dart @@ -0,0 +1,14 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/info/image.dart"; +part "avatar.freezed.dart"; +part "avatar.g.dart"; + +@freezed +sealed class AvatarContent extends Content with _$AvatarContent { + AvatarContent._(); + factory AvatarContent({ImageInfo? info, Uri? url}) = _AvatarContent; + + factory AvatarContent.fromJson(Map json) => + _$AvatarContentFromJson(json); +} diff --git a/lib/models/content/canonical_alias.dart b/lib/models/content/canonical_alias.dart new file mode 100644 index 0000000..1a35d92 --- /dev/null +++ b/lib/models/content/canonical_alias.dart @@ -0,0 +1,18 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "canonical_alias.freezed.dart"; +part "canonical_alias.g.dart"; + +@freezed +sealed class CanonicalAliasContent extends Content + with _$CanonicalAliasContent { + CanonicalAliasContent._(); + factory CanonicalAliasContent({ + String? alias, + @Default(ISet.empty()) ISet altAliases, + }) = _CanonicalAliasContent; + + factory CanonicalAliasContent.fromJson(Map json) => + _$CanonicalAliasContentFromJson(json); +} diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart new file mode 100644 index 0000000..e28ccbf --- /dev/null +++ b/lib/models/content/content.dart @@ -0,0 +1,69 @@ +import "package:collection/collection.dart"; +import "package:nexus/models/content/avatar.dart"; +import "package:nexus/models/content/canonical_alias.dart"; +import "package:nexus/models/content/create.dart"; +import "package:nexus/models/content/encryption.dart"; +import "package:nexus/models/content/join_rules.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/content/name.dart"; +import "package:nexus/models/content/pinned_events.dart"; +import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/content/reaction.dart"; +import "package:nexus/models/content/encrypted.dart"; +import "package:nexus/models/content/redaction.dart"; +import "package:nexus/models/content/server_acl.dart"; +import "package:nexus/models/content/topic.dart"; +import "package:nexus/models/content/sticker.dart"; +import "package:nexus/models/content/history_visibility.dart"; + +class Content { + final Error? parseError; + + const Content({this.parseError}); + + factory Content.fromJson(Map json) => const Content(); + + Map toJson() => {}; + + static Map readValue(Map json, _) => + json["decrypted"] ?? json["content"]; + + static Content fromEventJson(Map json, String type) { + try { + return (EventType.values + .firstWhereOrNull((eventType) => eventType.type == type) + ?.contentFromJson ?? + Content.fromJson)(json); + } catch (error) { + if (error is Error) return Content(parseError: error); + rethrow; + } + } +} + +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), + membership("m.room.member", MembershipContent.fromJson), + create("m.room.create", CreateContent.fromJson), + historyVisibility( + "m.room.history_visibility", + HistoryVisibilityContent.fromJson, + ), + canonicalAlias("m.room.canonical_alias", CanonicalAliasContent.fromJson), + sticker("m.sticker", StickerContent.fromJson), + joinRules("m.room.join_rules", JoinRulesContent.fromJson), + powerLevels("m.room.power_levels", PowerLevelsContent.fromJson), + serverACL("m.room.server_acl", ServerACLContent.fromJson), + avatar("m.room.avatar", AvatarContent.fromJson), + topic("m.room.topic", TopicContent.fromJson), + name("m.room.name", NameContent.fromJson), + reaction("m.reaction", ReactionContent.fromJson), + pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson), + message("m.room.message", MessageContent.fromJson); +} diff --git a/lib/models/content/create.dart b/lib/models/content/create.dart new file mode 100644 index 0000000..b937a94 --- /dev/null +++ b/lib/models/content/create.dart @@ -0,0 +1,40 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "create.freezed.dart"; +part "create.g.dart"; + +@freezed +sealed class CreateContent extends Content with _$CreateContent { + CreateContent._(); + factory CreateContent({ + @JsonKey(name: "additional_creators") + @Default(IList.empty()) + IList additionalCreatorIds, + + PreviousRoom? predecessor, + + @JsonKey(name: "m.federate") @Default(true) bool federated, + + @Default("1") String roomVersion, + @JsonKey(unknownEnumValue: RoomType.room) RoomType? type, + }) = _CreateContent; + + factory CreateContent.fromJson(Map json) => + _$CreateContentFromJson(json); +} + +enum RoomType { + room, + @JsonValue("m.space") + space, +} + +@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 new file mode 100644 index 0000000..8658a71 --- /dev/null +++ b/lib/models/content/encrypted.dart @@ -0,0 +1,13 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "encrypted.freezed.dart"; +part "encrypted.g.dart"; + +@freezed +sealed class EncryptedContent extends Content with _$EncryptedContent { + EncryptedContent._(); + factory EncryptedContent() = _EncryptedContent; + + factory EncryptedContent.fromJson(Map json) => + _$EncryptedContentFromJson(json); +} diff --git a/lib/models/content/encryption.dart b/lib/models/content/encryption.dart new file mode 100644 index 0000000..52c7b34 --- /dev/null +++ b/lib/models/content/encryption.dart @@ -0,0 +1,23 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "encryption.freezed.dart"; +part "encryption.g.dart"; + +@freezed +sealed class EncryptionContent extends Content with _$EncryptionContent { + EncryptionContent._(); + factory EncryptionContent({ + required String algorithm, + + @JsonKey(name: "rotation_period_ms") + @Default(604800000) + int rotationPeriodMS, + + @JsonKey(name: "rotation_period_msgs") + @Default(100) + int rotationPeriodMessages, + }) = _EncryptionContent; + + factory EncryptionContent.fromJson(Map json) => + _$EncryptionContentFromJson(json); +} diff --git a/lib/models/content/history_visibility.dart b/lib/models/content/history_visibility.dart new file mode 100644 index 0000000..3cfc011 --- /dev/null +++ b/lib/models/content/history_visibility.dart @@ -0,0 +1,19 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "history_visibility.freezed.dart"; +part "history_visibility.g.dart"; + +@freezed +sealed class HistoryVisibilityContent extends Content + with _$HistoryVisibilityContent { + HistoryVisibilityContent._(); + factory HistoryVisibilityContent({ + required HistoryVisibility historyVisibility, + }) = _HistoryVisibilityContent; + + factory HistoryVisibilityContent.fromJson(Map json) => + _$HistoryVisibilityContentFromJson(json); +} + +@JsonEnum(fieldRename: FieldRename.snake) +enum HistoryVisibility { invited, joined, shared, worldReadable } diff --git a/lib/models/content/join_rules.dart b/lib/models/content/join_rules.dart new file mode 100644 index 0000000..3f1c17b --- /dev/null +++ b/lib/models/content/join_rules.dart @@ -0,0 +1,35 @@ +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/join_rule.dart"; +part "join_rules.freezed.dart"; +part "join_rules.g.dart"; + +@freezed +sealed class JoinRulesContent extends Content with _$JoinRulesContent { + JoinRulesContent._(); + factory JoinRulesContent({ + required JoinRule joinRule, + @Default(IList.empty()) IList allow, + }) = _JoinRulesContent; + + factory JoinRulesContent.fromJson(Map json) => + _$JoinRulesContentFromJson(json); +} + +@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); +} + +enum AllowConditionType { + @JsonValue("m.room_membership") + membership, +} diff --git a/lib/models/content/membership.dart b/lib/models/content/membership.dart new file mode 100644 index 0000000..46be974 --- /dev/null +++ b/lib/models/content/membership.dart @@ -0,0 +1,27 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/membership_status.dart"; +part "membership.freezed.dart"; +part "membership.g.dart"; + +@freezed +sealed class MembershipContent extends Content with _$MembershipContent { + MembershipContent._(); + + static String? displaynameFromJson(String? displayName) => + displayName?.isEmpty == true ? null : displayName; + + factory MembershipContent({ + @JsonKey( + name: "displayname", + fromJson: MembershipContent.displaynameFromJson, + ) + required String? displayName, + @JsonKey(name: "membership") required MembershipStatus status, + Uri? avatarUrl, + String? reason, + }) = _MembershipContent; + + factory MembershipContent.fromJson(Map json) => + _$MembershipContentFromJson(json); +} diff --git a/lib/models/content/message.dart b/lib/models/content/message.dart new file mode 100644 index 0000000..36bd725 --- /dev/null +++ b/lib/models/content/message.dart @@ -0,0 +1,97 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/info/audio.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/info/file.dart"; +import "package:nexus/models/info/image.dart"; +import "package:nexus/models/info/video.dart"; +part "message.freezed.dart"; +part "message.g.dart"; + +typedef EncryptedFile = Map; + +@Freezed(unionKey: "msgtype", fallbackUnion: "default") +sealed class MessageContent extends Content with _$MessageContent { + MessageContent._(); + static String? mediaUrlFromJson(Map json, String key) => + json[key] ?? json["file"]?[key]; + + factory MessageContent({String? body}) = UnknownMessageContent; + + @FreezedUnionValue("m.text") + factory MessageContent.text({ + required String body, + MessageFormat? format, + String? formattedBody, + }) = TextMessageContent; + + @FreezedUnionValue("m.notice") + factory MessageContent.notice({ + required String body, + MessageFormat? format, + String? formattedBody, + }) = NoticeMessageContent; + + @FreezedUnionValue("m.emote") + factory MessageContent.emote({ + required String body, + MessageFormat? format, + String? formattedBody, + }) = EmoteMessageContent; + + @FreezedUnionValue("m.image") + factory MessageContent.image({ + required String body, + MessageFormat? format, + String? formattedBody, + EncryptedFile? file, + String? filename, + ImageInfo? info, + @JsonKey(readValue: MessageContent.mediaUrlFromJson) required Uri url, + }) = ImageMessageContent; + + @FreezedUnionValue("m.file") + factory MessageContent.file({ + required String body, + MessageFormat? format, + String? formattedBody, + EncryptedFile? file, + String? filename, + FileInfo? info, + @JsonKey(readValue: MessageContent.mediaUrlFromJson) required Uri url, + }) = FileMessageContent; + + @FreezedUnionValue("m.audio") + factory MessageContent.audio({ + required String body, + MessageFormat? format, + String? formattedBody, + EncryptedFile? file, + String? filename, + AudioInfo? info, + @JsonKey(readValue: MessageContent.mediaUrlFromJson) required Uri url, + }) = AudioMessageContent; + + @FreezedUnionValue("m.video") + factory MessageContent.video({ + required String body, + MessageFormat? format, + String? formattedBody, + EncryptedFile? file, + String? filename, + VideoInfo? info, + @JsonKey(readValue: MessageContent.mediaUrlFromJson) required Uri url, + }) = VideoMessageContent; + + @FreezedUnionValue("m.location") + factory MessageContent.location({required String body, required Uri geoUri}) = + LocationMessageContent; + + factory MessageContent.fromJson(Map json) => + _$MessageContentFromJson(json); +} + +@JsonEnum() +enum MessageFormat { + @JsonValue("org.matrix.custom.html") + html, +} diff --git a/lib/models/content/name.dart b/lib/models/content/name.dart new file mode 100644 index 0000000..c451d64 --- /dev/null +++ b/lib/models/content/name.dart @@ -0,0 +1,13 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "name.freezed.dart"; +part "name.g.dart"; + +@freezed +sealed class NameContent extends Content with _$NameContent { + NameContent._(); + factory NameContent({required String name}) = _NameContent; + + factory NameContent.fromJson(Map json) => + _$NameContentFromJson(json); +} diff --git a/lib/models/content/pinned_events.dart b/lib/models/content/pinned_events.dart new file mode 100644 index 0000000..9dac5de --- /dev/null +++ b/lib/models/content/pinned_events.dart @@ -0,0 +1,16 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "pinned_events.freezed.dart"; +part "pinned_events.g.dart"; + +@freezed +sealed class PinnedEventsContent extends Content with _$PinnedEventsContent { + PinnedEventsContent._(); + factory PinnedEventsContent({ + @Default(IList.empty()) @JsonKey(name: "pinned") IList pinnedEvents, + }) = _PinnedEventsContent; + + factory PinnedEventsContent.fromJson(Map json) => + _$PinnedEventsContentFromJson(json); +} diff --git a/lib/models/content/power_levels.dart b/lib/models/content/power_levels.dart new file mode 100644 index 0000000..5ae6561 --- /dev/null +++ b/lib/models/content/power_levels.dart @@ -0,0 +1,37 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "power_levels.freezed.dart"; +part "power_levels.g.dart"; + +@freezed +sealed class PowerLevelsContent extends Content with _$PowerLevelsContent { + PowerLevelsContent._(); + factory PowerLevelsContent({ + @Default(IMap.empty()) IMap events, + @Default(IMap.empty()) IMap users, + Notifications? notifications, + @Default(50) int ban, + @Default(0) int eventsDefault, + @Default(0) int invite, + @Default(50) int kick, + @Default(50) int redact, + @Default(50) int stateDefault, + @Default(0) int usersDefault, + }) = _PowerLevelsContent; + + factory PowerLevelsContent.fromJson(Map json) => + _$PowerLevelsContentFromJson(json); +} + +@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 new file mode 100644 index 0000000..93c563d --- /dev/null +++ b/lib/models/content/reaction.dart @@ -0,0 +1,23 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "reaction.freezed.dart"; +part "reaction.g.dart"; + +@Freezed(toJson: false) +sealed class ReactionContent extends Content with _$ReactionContent { + ReactionContent._(); + static String? keyJsonFromJson(Map json, String key) => + json["m.relates_to"]?["key"]; + + factory ReactionContent({ + @JsonKey(readValue: ReactionContent.keyJsonFromJson) String? key, + }) = _ReactionContent; + + @override + Map toJson() => { + "m.relates_to": {"key": key}, + }; + + factory ReactionContent.fromJson(Map json) => + _$ReactionContentFromJson(json); +} diff --git a/lib/models/content/redaction.dart b/lib/models/content/redaction.dart new file mode 100644 index 0000000..2ed6436 --- /dev/null +++ b/lib/models/content/redaction.dart @@ -0,0 +1,14 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "redaction.freezed.dart"; +part "redaction.g.dart"; + +@freezed +sealed class RedactionContent extends Content with _$RedactionContent { + RedactionContent._(); + factory RedactionContent({String? reason, String? redacts}) = + _RedactionContent; + + factory RedactionContent.fromJson(Map json) => + _$RedactionContentFromJson(json); +} diff --git a/lib/models/content/server_acl.dart b/lib/models/content/server_acl.dart new file mode 100644 index 0000000..3873bf3 --- /dev/null +++ b/lib/models/content/server_acl.dart @@ -0,0 +1,18 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "server_acl.freezed.dart"; +part "server_acl.g.dart"; + +@freezed +sealed class ServerACLContent extends Content with _$ServerACLContent { + ServerACLContent._(); + factory ServerACLContent({ + @Default(IList.empty()) IList allow, + @Default(IList.empty()) IList deny, + @Default(true) allowIpLiterals, + }) = _ServerACLContent; + + factory ServerACLContent.fromJson(Map json) => + _$ServerACLContentFromJson(json); +} diff --git a/lib/models/content/sticker.dart b/lib/models/content/sticker.dart new file mode 100644 index 0000000..c6fa893 --- /dev/null +++ b/lib/models/content/sticker.dart @@ -0,0 +1,18 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/info/image.dart"; +part "sticker.freezed.dart"; +part "sticker.g.dart"; + +@freezed +sealed class StickerContent extends Content with _$StickerContent { + StickerContent._(); + factory StickerContent({ + required String body, + required ImageInfo info, + required Uri url, + }) = _StickerContent; + + factory StickerContent.fromJson(Map json) => + _$StickerContentFromJson(json); +} diff --git a/lib/models/content/topic.dart b/lib/models/content/topic.dart new file mode 100644 index 0000000..c8256e3 --- /dev/null +++ b/lib/models/content/topic.dart @@ -0,0 +1,41 @@ +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 +sealed class TopicContent extends Content with _$TopicContent { + TopicContent._(); + factory TopicContent({ + required String topic, + @JsonKey(name: "m.topic") TopicContentBlock? content, + }) = _TopicContent; + + factory TopicContent.fromJson(Map json) => + _$TopicContentFromJson(json); +} + +@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(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 new file mode 100644 index 0000000..75faf08 --- /dev/null +++ b/lib/models/emoji.dart @@ -0,0 +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(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 734f667..dc3dd46 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -1,70 +1,100 @@ 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_response.dart"; + part "event.freezed.dart"; part "event.g.dart"; -@freezed -abstract class Event with _$Event { - const factory Event({ - @JsonKey(name: "rowid") required int rowId, - @JsonKey(name: "timeline_rowid") required int timelineRowId, - required String roomId, - required String eventId, - @JsonKey(name: "sender") required String authorId, - required String type, - String? stateKey, - @EpochDateTimeConverter() required DateTime timestamp, - required IMap content, - IMap? decrypted, - String? decryptedType, - @Default(IMap.empty()) IMap unsigned, - LocalContent? localContent, - String? transactionId, - String? redactedBy, - String? relatesTo, - String? relationType, - String? decryptionError, - String? sendError, - @Default(IMap.empty()) IMap reactions, - @JsonKey(name: "last_edit_rowid") int? lastEditRowId, - @UnreadTypeConverter() UnreadType? unreadType, - }) = _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); - factory Event.fromJson(Map json) => _$EventFromJson(json); + static String typeJsonFromJson(Map json, _) => + json["decrypted_type"] ?? json["type"]; + + static Map getContentFromJson(Map json) { + final content = json["decrypted"] ?? json["content"]; + + return content["m.new_content"] ?? content; + } + + 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: replyToFromJson(getContentFromJson(json)), + pmp: json["content"]?["com.beeper.per_message_profile"] == null + ? null + : Profile.fromJsonWithCatch( + json["content"]?["com.beeper.per_message_profile"], + ), + content: Content.fromEventJson( + getContentFromJson(json), + json["decrypted_type"] ?? json["type"], + ), + previousContent: json["unsigned"]?["prev_content"] == null + ? null + : Content.fromEventJson( + json["unsigned"]?["prev_content"], + json["decrypted_type"] ?? json["type"], + ), + ); } -@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); @@ -72,9 +102,9 @@ 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 isNone() => value == 0; + bool isNormal() => (value & 1) != 0; + bool shouldNotify() => (value & 2) != 0; + bool isHighlighted() => (value & 4) != 0; + bool playsSound() => (value & 8) != 0; } 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 new file mode 100644 index 0000000..ac90282 --- /dev/null +++ b/lib/models/info/audio.dart @@ -0,0 +1,18 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/ms_duration.dart"; + +part "audio.freezed.dart"; +part "audio.g.dart"; + +@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 new file mode 100644 index 0000000..ce0aa84 --- /dev/null +++ b/lib/models/info/file.dart @@ -0,0 +1,16 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "file.freezed.dart"; +part "file.g.dart"; + +@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 new file mode 100644 index 0000000..266fb01 --- /dev/null +++ b/lib/models/info/image.dart @@ -0,0 +1,19 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "image.freezed.dart"; +part "image.g.dart"; + +@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 new file mode 100644 index 0000000..31b1fb6 --- /dev/null +++ b/lib/models/info/video.dart @@ -0,0 +1,20 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/ms_duration.dart"; + +part "video.freezed.dart"; +part "video.g.dart"; + +@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 new file mode 100644 index 0000000..44fb9bb --- /dev/null +++ b/lib/models/join_rule.dart @@ -0,0 +1,4 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +@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..f9a814e 100644 --- a/lib/models/lazy_load_summary.dart +++ b/lib/models/lazy_load_summary.dart @@ -1,15 +1,17 @@ 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({ + required final IList? heroes, + required final int? joinedMemberCount, + required final int? invitedMemberCount, +}) with _$LazyLoadSummary { + Map toJson() => _$LazyLoadSummaryToJson(this); factory LazyLoadSummary.fromJson(Map json) => _$LazyLoadSummaryFromJson(json); diff --git a/lib/models/membership.dart b/lib/models/membership.dart deleted file mode 100644 index ec18be7..0000000 --- a/lib/models/membership.dart +++ /dev/null @@ -1,22 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -part "membership.freezed.dart"; - -@freezed -abstract class Membership with _$Membership { - const Membership._(); - const factory Membership({ - required Uri? avatarUrl, - required String displayName, - required String userId, - }) = _Membership; - - factory Membership.fromContent( - IMap content, - String userId, - ) => Membership( - avatarUrl: Uri.tryParse(content["avatar_url"] ?? ""), - userId: userId, - displayName: content["displayname"] ?? userId.substring(1).split(":").first, - ); -} diff --git a/lib/models/membership_action.dart b/lib/models/membership_action.dart new file mode 100644 index 0000000..f03ca99 --- /dev/null +++ b/lib/models/membership_action.dart @@ -0,0 +1 @@ +enum MembershipAction { ban, kick, unban, invite } diff --git a/lib/models/membership_status.dart b/lib/models/membership_status.dart new file mode 100644 index 0000000..7eb5f72 --- /dev/null +++ b/lib/models/membership_status.dart @@ -0,0 +1 @@ +enum MembershipStatus { leave, invite, ban, join, knock } diff --git a/lib/models/ms_duration.dart b/lib/models/ms_duration.dart new file mode 100644 index 0000000..de12943 --- /dev/null +++ b/lib/models/ms_duration.dart @@ -0,0 +1,11 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +class MSDuration implements JsonConverter { + const MSDuration(); + + @override + Duration fromJson(int ms) => Duration(milliseconds: ms); + + @override + int toJson(Duration duration) => duration.inMilliseconds; +} diff --git a/lib/models/oauth_auth_code_response.dart b/lib/models/oauth_auth_code_response.dart new file mode 100644 index 0000000..3074335 --- /dev/null +++ b/lib/models/oauth_auth_code_response.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "oauth_auth_code_response.freezed.dart"; +part "oauth_auth_code_response.g.dart"; + +@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 new file mode 100644 index 0000000..a183ef7 --- /dev/null +++ b/lib/models/open_graph_data.dart @@ -0,0 +1,19 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "open_graph_data.freezed.dart"; +part "open_graph_data.g.dart"; + +@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 d92b4f6..0000000 --- a/lib/models/profile.dart +++ /dev/null @@ -1,29 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -part "profile.freezed.dart"; -part "profile.g.dart"; - -@freezed -abstract class Profile with _$Profile { - const factory Profile({ - String? avatarUrl, - @JsonKey(name: "displayname") String? displayName, - @JsonKey(name: "us.cloke.msc4175.tz") String? timezone, - - @Default(IList.empty()) - @JsonKey(name: "io.fsky.nyx.pronouns") - IList pronouns, - }) = _Profile; - - factory Profile.fromJson(Map json) => - _$ProfileFromJson(json); -} - -@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 new file mode 100644 index 0000000..c9751f8 --- /dev/null +++ b/lib/models/requests/download_media.dart @@ -0,0 +1,18 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "download_media.freezed.dart"; +part "download_media.g.dart"; + +@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 new file mode 100644 index 0000000..b4692ee --- /dev/null +++ b/lib/models/requests/get_event.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "get_event.freezed.dart"; +part "get_event.g.dart"; + +@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_event_request.dart b/lib/models/requests/get_event_request.dart deleted file mode 100644 index 9374f3a..0000000 --- a/lib/models/requests/get_event_request.dart +++ /dev/null @@ -1,32 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/room.dart"; -part "get_event_request.freezed.dart"; -part "get_event_request.g.dart"; - -@Freezed(toJson: false) -abstract class GetEventRequest with _$GetEventRequest { - const GetEventRequest._(); - const factory GetEventRequest({ - required Room room, - required String eventId, - @Default(false) bool unredact, - }) = _GetEventRequest; - - Map toJson() => { - "room_id": room.metadata?.id, - "event_id": eventId, - "unredact": unredact, - }; - - @override - bool operator ==(Object other) => - other.runtimeType == runtimeType && - other is GetEventRequest && - other.eventId == eventId; - - @override - int get hashCode => Object.hash(runtimeType, eventId); - - factory GetEventRequest.fromJson(Map json) => - _$GetEventRequestFromJson(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 new file mode 100644 index 0000000..08be426 --- /dev/null +++ b/lib/models/requests/get_related_events.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "get_related_events.freezed.dart"; +part "get_related_events.g.dart"; + +@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_related_events_request.dart b/lib/models/requests/get_related_events_request.dart deleted file mode 100644 index 7e2244f..0000000 --- a/lib/models/requests/get_related_events_request.dart +++ /dev/null @@ -1,15 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "get_related_events_request.freezed.dart"; -part "get_related_events_request.g.dart"; - -@freezed -abstract class GetRelatedEventsRequest with _$GetRelatedEventsRequest { - const factory GetRelatedEventsRequest({ - required String roomId, - required String eventId, - required String relationType, - }) = _GetRelatedEventsRequest; - - 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 new file mode 100644 index 0000000..66504da --- /dev/null +++ b/lib/models/requests/get_room_state.dart @@ -0,0 +1,18 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "get_room_state.freezed.dart"; +part "get_room_state.g.dart"; + +@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/get_room_state_request.dart b/lib/models/requests/get_room_state_request.dart deleted file mode 100644 index de66b72..0000000 --- a/lib/models/requests/get_room_state_request.dart +++ /dev/null @@ -1,15 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "get_room_state_request.freezed.dart"; -part "get_room_state_request.g.dart"; - -@freezed -abstract class GetRoomStateRequest with _$GetRoomStateRequest { - const factory GetRoomStateRequest({ - required String roomId, - @Default(false) bool fetchMembers, - @Default(false) bool includeMembers, - }) = _GetRoomStateRequest; - - factory GetRoomStateRequest.fromJson(Map json) => - _$GetRoomStateRequestFromJson(json); -} diff --git a/lib/models/requests/join_room.dart b/lib/models/requests/join_room.dart new file mode 100644 index 0000000..350b762 --- /dev/null +++ b/lib/models/requests/join_room.dart @@ -0,0 +1,17 @@ +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(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/join_room_request.dart b/lib/models/requests/join_room_request.dart deleted file mode 100644 index d6b411e..0000000 --- a/lib/models/requests/join_room_request.dart +++ /dev/null @@ -1,15 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -part "join_room_request.freezed.dart"; -part "join_room_request.g.dart"; - -@freezed -abstract class JoinRoomRequest with _$JoinRoomRequest { - const factory JoinRoomRequest({ - required String roomIdOrAlias, - required IList via, - }) = _JoinRoomRequest; - - factory JoinRoomRequest.fromJson(Map json) => - _$JoinRoomRequestFromJson(json); -} diff --git a/lib/models/requests/login_request.dart b/lib/models/requests/login_request.dart deleted file mode 100644 index b3704fa..0000000 --- a/lib/models/requests/login_request.dart +++ /dev/null @@ -1,15 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "login_request.freezed.dart"; -part "login_request.g.dart"; - -@freezed -abstract class LoginRequest with _$LoginRequest { - const factory LoginRequest({ - required String username, - required String password, - required String homeserverUrl, - }) = _LoginRequest; - - factory LoginRequest.fromJson(Map json) => - _$LoginRequestFromJson(json); -} diff --git a/lib/models/requests/oauth/exchange_token.dart b/lib/models/requests/oauth/exchange_token.dart new file mode 100644 index 0000000..fb47358 --- /dev/null +++ b/lib/models/requests/oauth/exchange_token.dart @@ -0,0 +1,19 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "exchange_token.freezed.dart"; +part "exchange_token.g.dart"; + +@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 new file mode 100644 index 0000000..1ff166a --- /dev/null +++ b/lib/models/requests/oauth/get_auth_url.dart @@ -0,0 +1,45 @@ +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(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); +} + +sealed class Scope { + static final openid = "openid"; + static final email = "email"; + static final clientApi = "urn:matrix:client:api:*"; + + static final _deviceChars = IList( + ("ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "0123456789" + "-._~") + .split(""), + ); + + static String get _deviceId => + _deviceChars.shuffle(Random.secure()).sublist(0, 10).join(); + + static String get device => "urn:matrix:client:device:$_deviceId"; +} + +@JsonEnum(fieldRename: .snake) +enum ResponseMode { query, fragment } diff --git a/lib/models/requests/oauth/register_client.dart b/lib/models/requests/oauth/register_client.dart new file mode 100644 index 0000000..e5c1a70 --- /dev/null +++ b/lib/models/requests/oauth/register_client.dart @@ -0,0 +1,51 @@ +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(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, + + @JsonKey(name: "token_endpoint_auth_method") + final AuthMethod? authMethod = AuthMethod.none, +}) with _$OAuthRegisterClientRequest { + Map toJson() => _$OAuthRegisterClientRequestToJson(this); + + factory OAuthRegisterClientRequest.fromJson(Map json) => + _$OAuthRegisterClientRequestFromJson(json); +} + +enum ApplicationType { native, web } + +@JsonEnum(fieldRename: .snake) +enum ResponseType { code, idToken } + +@JsonEnum(fieldRename: .snake) +enum AuthMethod { + clientSecretPost, + clientSecretBasic, + clientSecretJwt, + privateKeyJwt, + none, +} + +@JsonEnum(fieldRename: .snake) +enum GrantType { + authorizationCode, + refreshToken, + clientCredentials, + @JsonValue("urn:ietf:params:oauth:grant-type:device_code") + deviceCode, +} diff --git a/lib/models/requests/paginate.dart b/lib/models/requests/paginate.dart new file mode 100644 index 0000000..b3903c6 --- /dev/null +++ b/lib/models/requests/paginate.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "paginate.freezed.dart"; +part "paginate.g.dart"; + +@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/paginate_request.dart b/lib/models/requests/paginate_request.dart deleted file mode 100644 index 44cf8ec..0000000 --- a/lib/models/requests/paginate_request.dart +++ /dev/null @@ -1,15 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "paginate_request.freezed.dart"; -part "paginate_request.g.dart"; - -@freezed -abstract class PaginateRequest with _$PaginateRequest { - const factory PaginateRequest({ - required String roomId, - required int? maxTimelineId, - @Default(20) int limit, - }) = _PaginateRequest; - - factory PaginateRequest.fromJson(Map json) => - _$PaginateRequestFromJson(json); -} diff --git a/lib/models/requests/redact_event.dart b/lib/models/requests/redact_event.dart new file mode 100644 index 0000000..3a01673 --- /dev/null +++ b/lib/models/requests/redact_event.dart @@ -0,0 +1,3 @@ +import "package:nexus/models/requests/report.dart"; + +typedef RedactEventRequest = ReportRequest; diff --git a/lib/models/requests/redact_event_request.dart b/lib/models/requests/redact_event_request.dart deleted file mode 100644 index fed2255..0000000 --- a/lib/models/requests/redact_event_request.dart +++ /dev/null @@ -1,3 +0,0 @@ -import "package:nexus/models/requests/report_request.dart"; - -typedef RedactEventRequest = ReportRequest; 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 new file mode 100644 index 0000000..ed05715 --- /dev/null +++ b/lib/models/requests/report.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "report.freezed.dart"; +part "report.g.dart"; + +@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/report_request.dart b/lib/models/requests/report_request.dart deleted file mode 100644 index 749ad60..0000000 --- a/lib/models/requests/report_request.dart +++ /dev/null @@ -1,15 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "report_request.freezed.dart"; -part "report_request.g.dart"; - -@freezed -abstract class ReportRequest with _$ReportRequest { - const factory ReportRequest({ - required String roomId, - required String eventId, - String? reason, - }) = _ReportRequest; - - factory ReportRequest.fromJson(Map json) => - _$ReportRequestFromJson(json); -} diff --git a/lib/models/requests/send_event.dart b/lib/models/requests/send_event.dart new file mode 100644 index 0000000..acad57f --- /dev/null +++ b/lib/models/requests/send_event.dart @@ -0,0 +1,22 @@ +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(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 new file mode 100644 index 0000000..9907439 --- /dev/null +++ b/lib/models/requests/send_message.dart @@ -0,0 +1,56 @@ +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(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(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, 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: + return { + "m.in_reply_to": {"event_id": eventId}, + }; + + case RelationType.edit: + return {"rel_type": "m.replace", "event_id": eventId}; + } + } + + factory Relation.fromJson(Map json) => + _$RelationFromJson(json); +} diff --git a/lib/models/requests/send_message_request.dart b/lib/models/requests/send_message_request.dart deleted file mode 100644 index 883c585..0000000 --- a/lib/models/requests/send_message_request.dart +++ /dev/null @@ -1,54 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/relation_type.dart"; -part "send_message_request.freezed.dart"; -part "send_message_request.g.dart"; - -@freezed -abstract class SendMessageRequest with _$SendMessageRequest { - const factory SendMessageRequest({ - required String roomId, - required String text, - @Default(Mentions()) @JsonKey(name: "mentions") Mentions mentions, - @JsonKey(name: "relates_to") Relation? relation, - }) = _SendMessageRequest; - - 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; - - 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; - - Map toJson() { - switch (relationType) { - case RelationType.reply: - return { - "m.in_reply_to": {"event_id": eventId}, - }; - - case RelationType.edit: - return {"rel_type": "m.replace", "event_id": eventId}; - } - } - - factory Relation.fromJson(Map json) => - _$RelationFromJson(json); -} 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 new file mode 100644 index 0000000..9d88da2 --- /dev/null +++ b/lib/models/requests/set_membership.dart @@ -0,0 +1,20 @@ +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(toJson: false, fromJson: false) +@JsonSerializable() +class const SetMembershipRequest({ + required final String userId, + required final String roomId, + final String? reason, + @JsonKey(name: "action") 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 new file mode 100644 index 0000000..d0e3e95 --- /dev/null +++ b/lib/models/requests/set_state.dart @@ -0,0 +1,24 @@ +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(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() + 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 new file mode 100644 index 0000000..d6e69ac --- /dev/null +++ b/lib/models/requests/upload_media.dart @@ -0,0 +1,27 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "upload_media.freezed.dart"; +part "upload_media.g.dart"; + +@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 3c3eec0..96b29b4 100644 --- a/lib/models/room.dart +++ b/lib/models/room.dart @@ -3,34 +3,63 @@ 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 { - const factory Room({ - @JsonKey(name: "meta") RoomMetadata? metadata, - @Default(IList.empty()) IList timeline, - @Default(false) bool reset, - @Default(IMap.empty()) IMap> state, - // required IMap accountData, - @Default(IList.empty()) IList events, - @Default(IMap.empty()) IMap> receipts, - @Default(false) bool dismissNotifications, - @Default(true) bool hasMore, - // required IList notifications, - }) = _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 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( + (timelineTuple) => MapEntry( + timelineTuple["timeline_rowid"], + timelineTuple["event_rowid"], + ), + ), + ); + + static IMap eventsJsonToIMap(List json) => + IMap.fromEntries( + json.map((eventJson) { + final event = Event.fromJson(eventJson); + return MapEntry(event.rowId, event); + }), + ); + + Map toJson() => _$RoomToJson(this); factory Room.fromJson(Map json) => _$RoomFromJson(json); } - -@freezed -abstract class TimelineRowTuple with _$TimelineRowTuple { - const factory TimelineRowTuple({ - @JsonKey(name: "timeline_rowid") required int timelineRowId, - @JsonKey(name: "event_rowid") int? eventRowId, - }) = _TimelineRowTuple; - - factory TimelineRowTuple.fromJson(Map json) => - _$TimelineRowTupleFromJson(json); -} diff --git a/lib/models/room_chat.dart b/lib/models/room_chat.dart new file mode 100644 index 0000000..a038a88 --- /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"; +import "package:nexus/models/event.dart"; + +part "room_chat.freezed.dart"; +part "room_chat.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RoomChat({ + 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..3aafd0e 100644 --- a/lib/models/room_metadata.dart +++ b/lib/models/room_metadata.dart @@ -1,29 +1,31 @@ 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, +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RoomMetadata({ + @JsonKey(name: "room_id") required final 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; + // CreateEventContent creationContent, + // TombstoneEventContent tombstoneEventContent, + 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..7c2d6d2 --- /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") required 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 new file mode 100644 index 0000000..2ff541d --- /dev/null +++ b/lib/models/setting.dart @@ -0,0 +1,13 @@ +import "package:material_ui/material_ui.dart"; + +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 new file mode 100644 index 0000000..7f73375 --- /dev/null +++ b/lib/models/settings.dart @@ -0,0 +1,18 @@ +import "package:material_ui/material_ui.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "settings.freezed.dart"; +part "settings.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Settings({ + final ThemeMode theme = ThemeMode.system, + final bool useDynamicTheming = true, + final bool linuxMobileMode = 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 new file mode 100644 index 0000000..2dc7076 --- /dev/null +++ b/lib/models/settings_category.dart @@ -0,0 +1,13 @@ +import "package:fast_immutable_collections/fast_immutable_collections.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 +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 631759a..97efe7a 100644 --- a/lib/models/space.dart +++ b/lib/models/space.dart @@ -2,15 +2,16 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; 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, - }) = _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 new file mode 100644 index 0000000..97d1de2 --- /dev/null +++ b/lib/models/subspace.dart @@ -0,0 +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 +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 42c5f2a..57bd882 100644 --- a/lib/models/sync_status.dart +++ b/lib/models/sync_status.dart @@ -1,18 +1,20 @@ 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); } -@JsonEnum(fieldRename: FieldRename.snake) +@JsonEnum(fieldRename: FieldRename.kebab) enum SyncStatusType { ok, waiting, erroring, permanentlyFailed } diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart deleted file mode 100644 index ee2f4d0..0000000 --- a/lib/pages/chat_page.dart +++ /dev/null @@ -1,33 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/widgets/chat_page/sidebar.dart"; -import "package:nexus/widgets/chat_page/room_chat.dart"; - -class ChatPage extends ConsumerWidget { - const ChatPage({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder( - builder: (context, constraints) { - final isDesktop = constraints.maxWidth > 650; - final showMembersByDefault = constraints.maxWidth > 1000; - - return Scaffold( - body: Builder( - builder: (context) => Row( - children: [ - if (isDesktop) Sidebar(), - Expanded( - child: RoomChat( - isDesktop: isDesktop, - showMembersByDefault: showMembersByDefault, - ), - ), - ], - ), - ), - drawer: isDesktop ? null : Sidebar(), - ); - }, - ); -} diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart deleted file mode 100644 index bd41d51..0000000 --- a/lib/pages/login_page.dart +++ /dev/null @@ -1,210 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:flutter_svg/flutter_svg.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/helpers/launch_helper.dart"; -import "package:nexus/models/homeserver.dart"; -import "package:nexus/models/requests/login_request.dart"; -import "package:nexus/widgets/appbar.dart"; -import "package:nexus/widgets/divider_text.dart"; -import "package:nexus/widgets/loading.dart"; - -class LoginPage extends HookConsumerWidget { - const LoginPage({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final client = ref.watch(ClientController.provider.notifier); - - final isLoading = useState(false); - final homeserver = useState(null); - - final launch = ref.watch(LaunchHelper.provider).launchUrl; - - Future setHomeserver(Uri? newHomeserver) async { - isLoading.value = true; - - homeserver.value = newHomeserver == null - ? null - : await client.discoverHomeserver( - newHomeserver.hasScheme - ? newHomeserver - : Uri.https(newHomeserver.path), - ); - - if (homeserver.value == null && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - "Homeserver verification failed. Is your homeserver down?", - style: TextStyle(color: theme.colorScheme.onErrorContainer), - ), - backgroundColor: theme.colorScheme.errorContainer, - ), - ); - } - isLoading.value = false; - } - - final homeserverUrl = useTextEditingController(); - final username = useTextEditingController(); - final password = useTextEditingController(); - - return Scaffold( - appBar: Appbar(), - body: Center( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: 600), - child: ListView( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 64), - children: [ - Row( - children: [ - SvgPicture.asset("assets/icon.svg"), - SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("Nexus", style: theme.textTheme.displayMedium), - Text( - "A Simple Matrix Client", - style: theme.textTheme.headlineMedium, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - Padding( - padding: EdgeInsetsGeometry.symmetric(vertical: 12), - child: Divider(), - ), - - DividerText("Enter a homeserver domain:"), - Row( - spacing: 8, - children: [ - Expanded( - child: TextField( - controller: homeserverUrl, - decoration: InputDecoration( - labelText: "Homeserver URL (e.g. matrix.org)", - ), - ), - ), - IconButton.filled( - tooltip: "Confirm homeserver choice", - onPressed: isLoading.value - ? null - : () => setHomeserver(Uri.tryParse(homeserverUrl.text)), - icon: Icon(Icons.check), - ), - ], - ), - - DividerText("Or, choose from some popular homeservers:"), - ...([ - Homeserver( - 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.", - url: Uri.https("matrix.org"), - iconUrl: - "https://raw.githubusercontent.com/element-hq/logos/refs/heads/master/matrix/matrix-favicon${Theme.brightnessOf(context) == Brightness.dark ? "-white" : ""}.png", - ), - Homeserver( - 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.", - url: Uri.https("federated.nexus"), - iconUrl: "https://federated.nexus/images/icon.png", - ), - Homeserver( - 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: Uri.https("unredacted.org", "services/si/matrix"), - iconUrl: "https://unredacted.org/favicon.ico", - ), - ].map( - (homeserver) => Card( - child: ListTile( - title: Text(homeserver.name), - leading: Image.network( - homeserver.iconUrl, - errorBuilder: (_, _, _) => SizedBox.shrink(), - height: 32, - ), - subtitle: Text(homeserver.description), - onTap: isLoading.value - ? null - : () => setHomeserver(homeserver.url), - trailing: IconButton( - tooltip: "Launch homeserver info page", - onPressed: () => launch(homeserver.url), - icon: Icon(Icons.info_outline), - ), - ), - ), - )), - SizedBox(height: 8), - TextButton( - onPressed: () => launch(Uri.https("servers.joinmatrix.org")), - child: Text("See more homeservers..."), - ), - if (isLoading.value) - Padding(padding: EdgeInsets.only(top: 32), child: Loading()) - else if (homeserver.value != null) ...[ - DividerText("Then, sign in:"), - SizedBox(height: 4), - TextField( - decoration: InputDecoration(label: Text("Username")), - controller: username, - ), - SizedBox(height: 12), - TextField( - decoration: InputDecoration(label: Text("Password")), - controller: password, - obscureText: true, - ), - SizedBox(height: 12), - ElevatedButton( - onPressed: () async { - isLoading.value = true; - final succeeded = await client.login( - LoginRequest( - username: username.text, - password: password.text, - homeserverUrl: homeserver.value!, - ), - ); - - if (!succeeded && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - "Login failed. Is your password right?", - style: TextStyle( - color: theme.colorScheme.onErrorContainer, - ), - ), - backgroundColor: theme.colorScheme.errorContainer, - ), - ); - isLoading.value = false; - } - }, - child: Text("Sign In"), - ), - ], - ], - ), - ), - ), - ); - } -} diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart deleted file mode 100644 index 505904c..0000000 --- a/lib/pages/settings_page.dart +++ /dev/null @@ -1,11 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; - -class SettingsPage extends ConsumerWidget { - const SettingsPage({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return Placeholder(); - } -} diff --git a/lib/pages/verify_page.dart b/lib/pages/verify_page.dart deleted file mode 100644 index 1011f80..0000000 --- a/lib/pages/verify_page.dart +++ /dev/null @@ -1,82 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/widgets/form_text_input.dart"; - -class VerifyPage extends HookConsumerWidget { - const VerifyPage({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final passphraseController = useTextEditingController(); - final isVerifying = useState(false); - return AlertDialog( - title: Text("Verify"), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Enter your recovery key or passphrase below to unlock encrypted messages.\nYour passphrase is usually not the same as your password.", - ), - SizedBox(height: 12), - FormTextInput( - required: false, - autofocus: true, - capitalize: true, - controller: passphraseController, - obscure: true, - title: "Recovery Key or Passphrase", - ), - ], - ), - actions: [ - TextButton( - onPressed: isVerifying.value - ? null - : () async { - final scaffoldMessenger = ScaffoldMessenger.of(context); - final snackbar = scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - "Attempting to verify with recovery key...", - ), - duration: Duration(days: 999), - ), - ); - - isVerifying.value = true; - - final success = await ref - .watch(ClientController.provider.notifier) - .verify(passphraseController.text); - - snackbar.close(); - if (!success) { - isVerifying.value = false; - if (context.mounted) { - scaffoldMessenger.showSnackBar( - SnackBar( - backgroundColor: Theme.of( - context, - ).colorScheme.errorContainer, - content: Text( - "Verification failed. Is your passphrase correct?", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onErrorContainer, - ), - ), - ), - ); - } - } - }, - child: Text("Verify"), - ), - ], - ); - } -} diff --git a/lib/widgets/appbar.dart b/lib/widgets/appbar.dart index 5b14244..33727ac 100644 --- a/lib/widgets/appbar.dart +++ b/lib/widgets/appbar.dart @@ -1,29 +1,25 @@ 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 StatelessWidget implements PreferredSizeWidget { - final Widget? leading; - final Widget? title; - final Color? backgroundColor; - final double? scrolledUnderElevation; - final IList actions; - - const Appbar({ - super.key, - this.title, - this.backgroundColor, - this.scrolledUnderElevation, - this.leading, - this.actions = const IList.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); @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); - - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { Future maximize() async { final isMaximized = await windowManager.isMaximized(); @@ -35,15 +31,23 @@ class Appbar extends StatelessWidget implements PreferredSizeWidget { } return GestureDetector( - behavior: HitTestBehavior.translucent, - onDoubleTap: maximize, - onPanStart: (_) => windowManager.startDragging(), + onPanStart: ref + .watch(SettingsController.provider) + .whenOrNull( + data: (settings) => settings.linuxMobileMode + ? null + : (_) => windowManager.startDragging(), + ), child: AppBar( - leading: leading, + leading: leading == null ? null : InkWell(onTap: onTap, child: leading), backgroundColor: backgroundColor, scrolledUnderElevation: scrolledUnderElevation, - actionsPadding: const EdgeInsets.symmetric(horizontal: 8), - title: title, + actionsPadding: const .symmetric(horizontal: 8), + title: InkWell( + onTap: onTap, + child: IgnorePointer(child: title), + ), + flexibleSpace: GestureDetector(onDoubleTap: maximize), actions: [ ...actions, if (!(Platform.isAndroid || Platform.isIOS)) ...[ diff --git a/lib/widgets/avatar_or_hash.dart b/lib/widgets/avatar_or_hash.dart index 8e93b6b..982d56e 100644 --- a/lib/widgets/avatar_or_hash.dart +++ b/lib/widgets/avatar_or_hash.dart @@ -1,70 +1,40 @@ import "package:color_hash/color_hash.dart"; -import "package:cross_cache/cross_cache.dart"; -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/cross_cache_controller.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; -import "package:nexus/helpers/extensions/mxc_to_https.dart"; - -class AvatarOrHash extends ConsumerWidget { - final Uri? avatar; - final String title; - final Widget? fallback; - final bool hasBadge; - final int badgeNumber; - final double height; - const AvatarOrHash( - this.avatar, - this.title, { - this.fallback, - this.badgeNumber = 0, - this.hasBadge = false, - this.height = 24, - super.key, - }); +import "package:nexus/helpers/mxc_image.dart"; +final class const AvatarOrHash( + final Uri? avatar, + final String title, { + final Widget? fallback, + final double height = 24, + super.key, +}) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final box = ColoredBox( color: ColorHash(title).color, - child: Center(child: Text(title.isEmpty ? "" : title[0])), + child: Center(child: Icon(Icons.person, size: height / 2)), ); + return SizedBox( width: height, height: height, child: Center( - child: Badge( - isLabelVisible: hasBadge, - label: badgeNumber != 0 ? Text(badgeNumber.toString()) : null, - smallSize: 12, - backgroundColor: Theme.of(context).colorScheme.primary, - child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular((height - 8) / 2.5)), - child: SizedBox( - width: height, - height: height, - child: avatar == null - ? fallback ?? box - : Image( - image: CachedNetworkImage( - avatar! - .mxcToHttps( - ref.watch( - ClientStateController.provider.select( - (value) => value?.homeserverUrl, - ), - ) ?? - "", - ) - .toString(), - ref.watch(CrossCacheController.provider), - headers: ref.headers, - ), - fit: BoxFit.contain, - errorBuilder: (_, _, _) => box, - ), - ), + child: ClipRRect( + borderRadius: .all(.circular((height - 8) / 2.5)), + child: SizedBox( + width: height, + height: height, + child: avatar == null + ? fallback ?? box + : Image( + image: MxcImage(ref, .new(mxc: avatar!, isAvatar: true)), + fit: .cover, + loadingBuilder: (_, child, loadingProgress) => + loadingProgress == null ? child : fallback ?? box, + errorBuilder: (_, _, _) => fallback ?? box, + ), ), ), ), diff --git a/lib/widgets/chat_page/composer/chat_box.dart b/lib/widgets/chat_page/composer/chat_box.dart deleted file mode 100644 index 7f07de2..0000000 --- a/lib/widgets/chat_page/composer/chat_box.dart +++ /dev/null @@ -1,179 +0,0 @@ -import "dart:io"; -import "package:flutter/material.dart"; -import "package:flutter/services.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:fluttertagger/fluttertagger.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/room_chat_controller.dart"; -import "package:nexus/models/relation_type.dart"; -import "package:nexus/models/room.dart"; -import "package:nexus/widgets/chat_page/composer/mention_overlay.dart"; -import "package:nexus/widgets/chat_page/composer/relation_preview.dart"; - -class ChatBox extends HookConsumerWidget { - final Message? relatedMessage; - final RelationType relationType; - final VoidCallback onDismiss; - final Room room; - const ChatBox({ - required this.relatedMessage, - required this.relationType, - required this.onDismiss, - required this.room, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final controller = useRef(FlutterTaggerController()); - final triggerCharacter = useState(""); - final shouldMention = useState(true); - final query = useState(""); - - if (relationType == RelationType.edit && - relatedMessage is TextMessage && - controller.value.text.isEmpty) { - controller.value.text = relatedMessage?.metadata?["editSource"] ?? ""; - } - - void send() { - if (controller.value.text.trim().isEmpty || room.metadata == null) return; - ref - .watch(RoomChatController.provider(room.metadata!.id).notifier) - .send( - controller.value.formattedText, - shouldMention: shouldMention.value, - relation: relatedMessage, - relationType: relationType, - tags: controller.value.tags, - ); - onDismiss(); - controller.value.text = ""; - } - - final node = useFocusNode( - onKeyEvent: (_, event) { - if (event is KeyDownEvent && !Platform.isAndroid && !Platform.isIOS) { - if (event.logicalKey == LogicalKeyboardKey.enter && - !HardwareKeyboard.instance.isShiftPressed) { - send(); - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.escape) { - onDismiss(); - return KeyEventResult.handled; - } - } - - return KeyEventResult.ignored; - }, - )..requestFocus(); - - final style = TextStyle( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, - ); - - return Positioned( - bottom: 0, - left: 0, - right: 0, - child: Padding( - padding: EdgeInsetsGeometry.all(12), - child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(12)), - child: Column( - children: [ - RelationPreview( - relatedMessage, - room: room, - shouldMention: shouldMention.value, - toggleShouldMention: () => - shouldMention.value = !shouldMention.value, - relationType: relationType, - onDismiss: onDismiss, - ), - Container( - color: theme.colorScheme.surfaceContainerHighest, - padding: EdgeInsets.symmetric(horizontal: 8), - child: Row( - spacing: 8, - children: [ - PopupMenuButton( - tooltip: "Add media", - itemBuilder: (context) => [ - PopupMenuItem( - child: ListTile( - title: Text("Camera"), - leading: Icon(Icons.add_a_photo), - ), - ), - PopupMenuItem( - child: ListTile( - title: Text("Gallery"), - leading: Icon(Icons.add_photo_alternate), - ), - ), - PopupMenuItem( - child: ListTile( - title: Text("Files"), - leading: Icon(Icons.attachment), - ), - ), - ], - icon: Icon(Icons.add), - // enabled: room.canSendDefaultMessages, TODO: Permissions check - ), - Expanded( - child: FlutterTagger( - triggerStrategy: TriggerStrategy.eager, - overlay: MentionOverlay( - room, - query: query.value, - triggerCharacter: triggerCharacter.value, - addTag: ({required id, required name}) { - controller.value.addTag(id: id, name: name); - node.requestFocus(); - }, - ), - controller: controller.value, - onSearch: (newQuery, newTriggerCharacter) { - triggerCharacter.value = newTriggerCharacter; - query.value = newQuery; - }, - triggerCharacterAndStyles: {"@": style, "#": style}, - builder: (context, key) => TextFormField( - // enabled: room.canSendDefaultMessages, - maxLines: 12, - minLines: 1, - decoration: InputDecoration( - hintText: - true // TODO: room.canSendDefaultMessages - ? "Your message here..." - : "You don't have permission to send messages in this room...", - border: InputBorder.none, - ), - controller: controller.value, - key: key, - autofocus: true, - focusNode: node, - ), - ), - ), - IconButton( - onPressed: send, - // onPressed: room.canSendDefaultMessages ? send : null, - icon: Icon(Icons.send), - tooltip: "Send message", - ), - ], - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/widgets/chat_page/composer/mention_overlay.dart b/lib/widgets/chat_page/composer/mention_overlay.dart deleted file mode 100644 index d95253d..0000000 --- a/lib/widgets/chat_page/composer/mention_overlay.dart +++ /dev/null @@ -1,117 +0,0 @@ -import "package:flutter/material.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/members_controller.dart"; -import "package:nexus/controllers/rooms_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/models/room.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 Room room; - final void Function({required String id, required String name}) addTag; - const MentionOverlay( - this.room, { - 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: EdgeInsets.all(8), - child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(12)), - child: Container( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - padding: EdgeInsets.all(8), - child: switch (triggerCharacter) { - "@" => - ref - .watch(MembersController.provider(room)) - .betterWhen( - data: (members) => ListView( - children: - (query.isEmpty - ? members - : members.where( - (member) => - member.userId.toLowerCase().contains( - query.toLowerCase(), - ) == - true || - member.displayName - .toLowerCase() - .contains( - query.toLowerCase(), - ) == - true, - )) - .map( - (member) => ListTile( - leading: AvatarOrHash( - member.avatarUrl, - member.displayName, - ), - title: Text(member.displayName), - subtitle: Text(member.userId), - onTap: () => addTag( - id: "[@${member.displayName}](https://matrix.to/#/${member.userId})", - name: member.userId - .substring(1) - .split(":") - .first, - ), - ), - ) - .toList(), - ), - ), - "#" => ListView( - children: - (query.isEmpty - ? rooms.values - : rooms.values.where( - (room) => (room.metadata?.name ?? "Unnamed Room") - .toLowerCase() - .contains(query.toLowerCase()), - )) - .map( - (room) => ListTile( - leading: AvatarOrHash( - room.metadata?.avatar, - room.metadata?.name ?? "Unnamed Room", - fallback: Icon(Icons.numbers), - ), - title: Text(room.metadata?.name ?? "Unnamed Room"), - subtitle: room.metadata?.topic == null - ? null - : Text(room.metadata!.topic!, maxLines: 1), - onTap: () => addTag( - id: "[#${room.metadata?.name ?? "Unnamed Room"}](https://matrix.to/#/${room.metadata?.id})", - name: - (room.metadata?.canonicalAlias ?? - room.metadata?.id) - ?.substring(1) - .split(":") - .first ?? - "", - ), - ), - ) - .toList(), - ), - - _ => Loading(), - }, - ), - ), - ); - } -} diff --git a/lib/widgets/chat_page/composer/relation_preview.dart b/lib/widgets/chat_page/composer/relation_preview.dart deleted file mode 100644 index 7fded20..0000000 --- a/lib/widgets/chat_page/composer/relation_preview.dart +++ /dev/null @@ -1,84 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/models/relation_type.dart"; -import "package:nexus/models/room.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; - -class RelationPreview extends ConsumerWidget { - final Message? relatedMessage; - final RelationType relationType; - final VoidCallback onDismiss; - final bool shouldMention; - final VoidCallback toggleShouldMention; - final Room room; - - const RelationPreview( - this.relatedMessage, { - required this.room, - required this.relationType, - required this.onDismiss, - required this.shouldMention, - required this.toggleShouldMention, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - if (relatedMessage == null) return SizedBox.shrink(); - final theme = Theme.of(context); - - return Container( - color: theme.colorScheme.surfaceContainerHigh, - padding: EdgeInsets.symmetric(horizontal: 8), - child: Row( - spacing: 8, - children: [ - SizedBox(width: 4), - if (relationType == RelationType.edit) - Text( - "Editing message:", - style: TextStyle(fontWeight: FontWeight.bold), - ), - MessageAvatar(relatedMessage!, room), - MessageDisplayname( - relatedMessage!, - room, - style: theme.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - Expanded( - child: Text( - relatedMessage?.metadata?["body"] ?? - relatedMessage?.metadata?["eventType"], - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelMedium, - maxLines: 1, - ), - ), - - if (relationType == RelationType.reply) - TextButton( - onPressed: toggleShouldMention, - child: Text( - shouldMention ? "@On" : "@Off", - style: TextStyle( - fontWeight: FontWeight.w900, - color: shouldMention ? null : Theme.of(context).disabledColor, - ), - ), - ), - IconButton( - tooltip: - "Cancel ${relationType == RelationType.edit ? "edit" : "reply"}", - onPressed: onDismiss, - icon: Icon(Icons.close), - iconSize: 20, - ), - ], - ), - ); - } -} diff --git a/lib/widgets/chat_page/html/html.dart b/lib/widgets/chat_page/html/html.dart deleted file mode 100644 index dcc1d49..0000000 --- a/lib/widgets/chat_page/html/html.dart +++ /dev/null @@ -1,143 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; -import "package:nexus/helpers/extensions/link_to_mention.dart"; -import "package:nexus/helpers/extensions/mxc_to_https.dart"; -import "package:nexus/helpers/launch_helper.dart"; -import "package:nexus/widgets/chat_page/html/mention_chip.dart"; -import "package:nexus/widgets/chat_page/html/spoiler_text.dart"; -import "package:nexus/widgets/chat_page/html/code_block.dart"; -import "package:nexus/widgets/chat_page/html/quoted.dart"; - -class Html extends ConsumerWidget { - final String html; - final TextStyle? textStyle; - const Html(this.html, {this.textStyle, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) => HtmlWidget( - html, - 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)); - } - - final height = int.tryParse(element.attributes["height"] ?? "") ?? 300; - final width = int.tryParse(element.attributes["width"] ?? ""); - - return switch (element.localName) { - "code" => - element.parent?.localName == "pre" - ? element.outerHtml.contains("
") - ? Html( - """
${element.outerHtml.replaceAll("
", "\n")}
""", - ) - : CodeBlock( - element.text, - lang: element.className.replaceAll("language-", ""), - ) - : null, - - "blockquote" => Quoted(Html(element.innerHtml)), - - "a" => - element.attributes["href"]?.mention == null - ? null - : InlineCustomWidget(child: MentionChip(element.text)), - - "img" => - element.attributes["src"] == null - ? SizedBox.shrink() - : InlineCustomWidget( - alignment: PlaceholderAlignment.middle, - child: Image.network( - Uri.parse(element.attributes["src"]!) - .mxcToHttps( - ref.watch( - ClientStateController.provider.select( - (value) => value?.homeserverUrl, - ), - ) ?? - "", - ) - .toString(), - headers: ref.headers, - errorBuilder: (_, error, _) => Text( - "Image Failed to Load", - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ), - height: height.toDouble(), - width: width?.toDouble(), - loadingBuilder: (_, child, loadingProgress) => - loadingProgress == null - ? child - : CircularProgressIndicator(), - ), - ), - ("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" => MapEntry("color", value), - "data-mx-bg-color" => MapEntry("background-color", value), - _ => null, - }, - ) - .nonNulls, - ), - }, - onTapUrl: (url) => - ref.watch(LaunchHelper.provider).launchUrl(Uri.parse(url)), - ); -} diff --git a/lib/widgets/chat_page/html/mention_chip.dart b/lib/widgets/chat_page/html/mention_chip.dart deleted file mode 100644 index c2b832d..0000000 --- a/lib/widgets/chat_page/html/mention_chip.dart +++ /dev/null @@ -1,25 +0,0 @@ -import "package:flutter/material.dart"; -import "package:nexus/helpers/extensions/link_to_mention.dart"; - -class MentionChip extends StatelessWidget { - final String label; - const MentionChip(this.label, {super.key}); - - @override - Widget build(BuildContext context) => ActionChip( - label: Text( - label.mention ?? label, - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onPrimary, - ), - ), - backgroundColor: Theme.of(context).colorScheme.primary, - onPressed: () => showDialog( - context: context, - builder: (_) => Dialog( - child: Text("TODO: Open room or join room dialog, or user popover"), - ), - ), - ); -} diff --git a/lib/widgets/chat_page/html/quoted.dart b/lib/widgets/chat_page/html/quoted.dart deleted file mode 100644 index 6640118..0000000 --- a/lib/widgets/chat_page/html/quoted.dart +++ /dev/null @@ -1,16 +0,0 @@ -import "package:flutter/material.dart"; - -class Quoted extends StatelessWidget { - final Widget child; - const Quoted(this.child, {super.key}); - - @override - Widget build(BuildContext context) => Container( - decoration: BoxDecoration( - border: Border( - left: BorderSide(width: 4, color: Theme.of(context).dividerColor), - ), - ), - child: Padding(padding: EdgeInsets.only(left: 8), child: child), - ); -} diff --git a/lib/widgets/chat_page/image_message.dart b/lib/widgets/chat_page/image_message.dart deleted file mode 100644 index 103fdd2..0000000 --- a/lib/widgets/chat_page/image_message.dart +++ /dev/null @@ -1,58 +0,0 @@ -import "dart:math"; -import "package:cross_cache/cross_cache.dart"; -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:flyer_chat_image_message/flyer_chat_image_message.dart"; -import "package:nexus/controllers/cross_cache_controller.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; - -class ExpandableImageMessage extends ConsumerWidget { - final ImageMessage message; - final int index; - - const ExpandableImageMessage(this.message, {required this.index, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) => InkWell( - onTap: () => showDialog( - context: context, - builder: (_) => LayoutBuilder( - builder: (context, constraints) => Dialog( - backgroundColor: Colors.transparent, - insetPadding: EdgeInsets.all(constraints.maxWidth / 100), - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: min(constraints.maxWidth, 1000), - ), - child: InteractiveViewer( - child: Image( - fit: BoxFit.contain, - image: CachedNetworkImage( - message.source, - ref.watch(CrossCacheController.provider), - headers: ref.headers, - ), - ), - ), - ), - ), - ), - ), - child: FlyerChatImageMessage( - customImageProvider: CachedNetworkImage( - message.source, - ref.watch(CrossCacheController.provider), - headers: ref.headers, - ), - errorBuilder: (context, error, stackTrace) => Center( - child: Text( - "Image Failed to Load", - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ), - message: message, - index: index, - ), - ); -} diff --git a/lib/widgets/chat_page/lazy_loading/message_avatar.dart b/lib/widgets/chat_page/lazy_loading/message_avatar.dart deleted file mode 100644 index 71fcf84..0000000 --- a/lib/widgets/chat_page/lazy_loading/message_avatar.dart +++ /dev/null @@ -1,30 +0,0 @@ -import "package:flutter/widgets.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/author_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/models/configs/author_config.dart"; -import "package:nexus/models/room.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; - -class MessageAvatar extends ConsumerWidget { - final Message message; - final Room room; - final double height; - const MessageAvatar(this.message, this.room, {this.height = 16, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) => ref - .watch( - AuthorController.provider(AuthorConfig(room: room, message: message)), - ) - .betterWhen( - data: (membership) => AvatarOrHash( - membership.avatarUrl, - membership.displayName, - height: height, - ), - loading: () => - AvatarOrHash(null, message.authorId.substring(1), height: height), - ); -} diff --git a/lib/widgets/chat_page/lazy_loading/message_displayname.dart b/lib/widgets/chat_page/lazy_loading/message_displayname.dart deleted file mode 100644 index 7053655..0000000 --- a/lib/widgets/chat_page/lazy_loading/message_displayname.dart +++ /dev/null @@ -1,28 +0,0 @@ -import "package:flutter/widgets.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/author_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/models/configs/author_config.dart"; -import "package:nexus/models/room.dart"; - -class MessageDisplayname extends ConsumerWidget { - final Message message; - final Room room; - final TextStyle? style; - const MessageDisplayname(this.message, this.room, {this.style, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) => ref - .watch( - AuthorController.provider(AuthorConfig(room: room, message: message)), - ) - .betterWhen( - data: (membership) => Text( - "${membership.displayName} ${message.metadata?["pmp"] == null ? "" : "(via ${message.authorId})"}", - style: style, - overflow: TextOverflow.ellipsis, - ), - loading: SizedBox.shrink, - ); -} diff --git a/lib/widgets/chat_page/member_list.dart b/lib/widgets/chat_page/member_list.dart deleted file mode 100644 index 8cdbbb9..0000000 --- a/lib/widgets/chat_page/member_list.dart +++ /dev/null @@ -1,68 +0,0 @@ -import "package:flutter/material.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/members_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/models/room.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; - -class MemberList extends ConsumerWidget { - final Room room; - const MemberList(this.room, {super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final membersProvider = ref.watch(MembersController.provider(room)); - return Drawer( - shape: Border(), - child: Column( - children: [ - AppBar( - scrolledUnderElevation: 0, - leading: Icon(Icons.people), - title: Text( - "Members ${membersProvider.when(data: (members) => "${members.length}", error: (_, _) => "", loading: () => "")}", - ), - actionsPadding: EdgeInsets.only(right: 4), - actions: [ - if (Scaffold.of(context).hasEndDrawer) - IconButton( - onPressed: Scaffold.of(context).closeEndDrawer, - icon: Icon(Icons.close), - tooltip: "Close member list", - ), - ], - ), - membersProvider.betterWhen( - data: (members) => Expanded( - child: ListView( - children: members - .map( - (member) => ListTile( - onTap: () => showDialog( - context: context, - builder: (context) => - Dialog(child: Text("TODO: Open member popover")), - ), - leading: AvatarOrHash( - member.avatarUrl, - member.displayName, - ), - title: Text( - member.displayName, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - member.userId, - overflow: TextOverflow.ellipsis, - ), - ), - ) - .toList(), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/widgets/chat_page/reply_widget.dart b/lib/widgets/chat_page/reply_widget.dart deleted file mode 100644 index b9fa2e1..0000000 --- a/lib/widgets/chat_page/reply_widget.dart +++ /dev/null @@ -1,101 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/event_controller.dart"; -import "package:nexus/controllers/message_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/models/configs/message_config.dart"; -import "package:nexus/models/requests/get_event_request.dart"; -import "package:nexus/models/room.dart"; -import "package:nexus/widgets/chat_page/html/quoted.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; - -typedef OnTapReply = void Function(Message message)?; - -class ReplyWidget extends ConsumerWidget { - final Message message; - final bool alwaysShow; - final Room room; - final MessageGroupStatus? groupStatus; - final OnTapReply onTapReply; - const ReplyWidget( - this.message, { - required this.room, - required this.groupStatus, - this.onTapReply, - this.alwaysShow = false, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) => - message.replyToMessageId == null - ? SizedBox.shrink() - : Padding( - padding: EdgeInsets.only(bottom: 12), - child: Quoted( - ref - .watch( - EventController.provider( - GetEventRequest( - room: room, - eventId: message.replyToMessageId!, - ), - ), - ) - .betterWhen( - loading: () => Text("Fetching event..."), - data: (event) => event == null - ? SizedBox.shrink() - : ref - .watch( - MessageController.provider( - MessageConfig(room: room, event: event), - ), - ) - .betterWhen( - loading: () => Text("Parsing message..."), - data: (replyMessage) { - if (replyMessage == null) { - return SizedBox.shrink(); - } - - return InkWell( - onTap: () => onTapReply?.call(replyMessage), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - MessageAvatar(replyMessage, room), - Flexible( - child: MessageDisplayname( - replyMessage, - room, - style: Theme.of(context) - .textTheme - .labelMedium - ?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - Flexible( - child: Text( - replyMessage.metadata!["body"], - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.labelMedium, - maxLines: 1, - ), - ), - ], - ), - ); - }, - ), - ), - ), - ); -} diff --git a/lib/widgets/chat_page/room_appbar.dart b/lib/widgets/chat_page/room_appbar.dart deleted file mode 100644 index 436bcb9..0000000 --- a/lib/widgets/chat_page/room_appbar.dart +++ /dev/null @@ -1,68 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; -import "package:nexus/models/room.dart"; -import "package:nexus/widgets/appbar.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/chat_page/room_menu.dart"; - -class RoomAppbar extends StatelessWidget implements PreferredSizeWidget { - final bool isDesktop; - final Room room; - final void Function(BuildContext context) onOpenMemberList; - final void Function(BuildContext context) onOpenDrawer; - const RoomAppbar( - this.room, { - required this.isDesktop, - required this.onOpenMemberList, - required this.onOpenDrawer, - super.key, - }); - - @override - Size get preferredSize => AppBar().preferredSize; - - @override - Widget build(BuildContext context) => Appbar( - leading: isDesktop - ? AvatarOrHash( - room.metadata?.avatar, - room.metadata?.name ?? "Unnamed Rooms", - height: 24, - fallback: Icon(Icons.numbers), - ) - : DrawerButton(onPressed: () => onOpenDrawer(context)), - scrolledUnderElevation: 0, - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - room.metadata?.name ?? "Unnamed Room", - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - if (room.metadata?.topic?.isNotEmpty == true) - Text( - room.metadata!.topic!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - actions: [ - IconButton( - onPressed: null, - icon: Icon(Icons.push_pin), - tooltip: "Open pinned messages", - ), - IconButton( - onPressed: () => onOpenMemberList(context), - tooltip: "Open member list", - icon: Icon(Icons.people), - ), - RoomMenu(room), - ].toIList(), - ); -} diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart deleted file mode 100644 index bb19e03..0000000 --- a/lib/widgets/chat_page/room_chat.dart +++ /dev/null @@ -1,369 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_chat_ui/flutter_chat_ui.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:flyer_chat_file_message/flyer_chat_file_message.dart"; -import "package:flyer_chat_system_message/flyer_chat_system_message.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; -import "package:nexus/controllers/room_chat_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/helpers/extensions/show_context_menu.dart"; -import "package:nexus/models/relation_type.dart"; -import "package:nexus/models/requests/report_request.dart"; -import "package:nexus/widgets/chat_page/composer/chat_box.dart"; -import "package:nexus/widgets/chat_page/image_message.dart"; -import "package:nexus/widgets/chat_page/member_list.dart"; -import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart"; -import "package:nexus/widgets/chat_page/room_appbar.dart"; -import "package:nexus/widgets/chat_page/wrappers/text_message_wrapper.dart"; -import "package:nexus/widgets/chat_page/reply_widget.dart"; -import "package:nexus/widgets/form_text_input.dart"; -import "package:nexus/widgets/loading.dart"; -// import "package:dynamic_polls/dynamic_polls.dart"; - -class RoomChat extends HookConsumerWidget { - final bool isDesktop; - final bool showMembersByDefault; - const RoomChat({ - required this.isDesktop, - required this.showMembersByDefault, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final client = ref.watch(ClientController.provider.notifier); - final replyToMessage = useState(null); - final memberListOpened = useState(showMembersByDefault); - final relationType = useState(RelationType.reply); - final room = ref.watch(SelectedRoomController.provider); - final userId = ref.watch(ClientStateController.provider)?.userId; - - final theme = Theme.of(context); - final danger = theme.colorScheme.error; - - if (room == null || userId == null || room.metadata?.id == null) { - return Center( - child: Text( - "Nothing to see here...", - style: theme.textTheme.headlineMedium, - ), - ); - } - - final controllerProvider = RoomChatController.provider(room.metadata!.id); - final notifier = ref.watch(controllerProvider.notifier); - - List getMessageOptions(Message message) { - final isSentByMe = message.authorId == userId; - return [ - PopupMenuItem( - onTap: () { - replyToMessage.value = message; - relationType.value = RelationType.reply; - }, - child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")), - ), - if (message is TextMessage && isSentByMe) - PopupMenuItem( - onTap: () { - replyToMessage.value = message; - relationType.value = RelationType.edit; - }, - child: ListTile(leading: Icon(Icons.edit), title: Text("Edit")), - ), - if (isSentByMe) // TODO: Or if user has permission to redact others' messages - PopupMenuItem( - onTap: () => showDialog( - context: context, - builder: (context) => HookBuilder( - builder: (_) { - final deleteReasonController = useTextEditingController(); - return AlertDialog( - title: Text("Delete Message"), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Are you sure you want to delete this message? This can not be reversed.", - ), - SizedBox(height: 12), - FormTextInput( - required: false, - capitalize: true, - controller: deleteReasonController, - title: "Reason for deletion (optional)", - ), - ], - ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text("Cancel"), - ), - TextButton( - onPressed: () async { - notifier.deleteMessage( - message, - reason: deleteReasonController.text, - ); - Navigator.of(context).pop(); - }, - child: Text("Delete"), - ), - ], - ); - }, - ), - ), - child: ListTile(leading: Icon(Icons.delete), title: Text("Delete")), - ), - 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 event to your server administrators, who can take action like banning this server or 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: () { - if (room.metadata == null) return; - client.reportEvent( - ReportRequest( - roomId: room.metadata!.id, - eventId: message.id, - 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: TextStyle(color: danger)), - ), - ), - ]; - } - - final chatTheme = ChatTheme.fromThemeData(theme).copyWith( - colors: ChatColors.fromThemeData(theme).copyWith( - primary: theme.colorScheme.primaryContainer, - onPrimary: theme.colorScheme.onPrimaryContainer, - ), - ); - - return Scaffold( - appBar: RoomAppbar( - room, - isDesktop: isDesktop, - onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), - onOpenMemberList: (thisContext) { - memberListOpened.value = !memberListOpened.value; - Scaffold.of(thisContext).openEndDrawer(); - }, - ), - body: Row( - children: [ - Expanded( - child: Column( - children: [ - Expanded( - child: ref - .watch(controllerProvider) - .betterWhen( - data: (controller) => Chat( - currentUserId: userId, - theme: chatTheme, - onMessageSecondaryTap: - ( - context, - message, { - required index, - TapUpDetails? details, - }) => details?.globalPosition == null - ? null - : context.showContextMenu( - globalPosition: details!.globalPosition, - children: getMessageOptions(message), - ), - onMessageLongPress: - ( - context, - message, { - required details, - required index, - }) => context.showContextMenu( - globalPosition: details.globalPosition, - children: getMessageOptions(message), - ), - builders: Builders( - loadMoreBuilder: (_) => Loading(), - - chatAnimatedListBuilder: (_, itemBuilder) => - ChatAnimatedList( - itemBuilder: itemBuilder, - onEndReached: room.hasMore - ? notifier.loadOlder - : null, - onStartReached: () => client.markRead(room), - bottomPadding: 72, - ), - - composerBuilder: (_) => ChatBox( - relationType: relationType.value, - relatedMessage: replyToMessage.value, - onDismiss: () => replyToMessage.value = null, - room: room, - ), - - textMessageBuilder: - ( - context, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => TextMessageWrapper( - room: room, - message, - content: message.text, - groupStatus: groupStatus, - onTapReply: notifier.scrollToMessage, - updateMessage: controller.updateMessage, - isSentByMe: isSentByMe, - ), - - imageMessageBuilder: - ( - context, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => TextMessageWrapper( - message, - room: room, - content: message.text, - groupStatus: groupStatus, - onTapReply: notifier.scrollToMessage, - updateMessage: controller.updateMessage, - isSentByMe: isSentByMe, - extra: ExpandableImageMessage( - message, - index: index, - ), - ), - - fileMessageBuilder: - ( - _, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => MessageWrapper( - message, - InkWell( - onTap: () => showDialog( - context: context, - builder: (_) => Dialog( - child: Text( - "TODO: Download Attachments", - ), - ), - ), - child: FlyerChatFileMessage( - topWidget: ReplyWidget( - room: room, - message, - onTapReply: notifier.scrollToMessage, - groupStatus: groupStatus, - ), - message: message, - index: index, - ), - ), - groupStatus, - room, - ), - - systemMessageBuilder: - ( - _, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => FlyerChatSystemMessage( - message: message, - index: index, - ), - - unsupportedMessageBuilder: - ( - _, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => Text( - "${message.authorId} sent ${message.metadata?["eventType"]}", - style: theme.textTheme.labelSmall?.copyWith( - color: Colors.grey, - ), - ), - ), - resolveUser: notifier.resolveUser, - chatController: controller, - ), - ), - ), - ], - ), - ), - - if (memberListOpened.value == true && showMembersByDefault) - MemberList(room), - ], - ), - - endDrawer: showMembersByDefault ? null : MemberList(room), - ); - } -} diff --git a/lib/widgets/chat_page/sidebar.dart b/lib/widgets/chat_page/sidebar.dart deleted file mode 100644 index 4642a58..0000000 --- a/lib/widgets/chat_page/sidebar.dart +++ /dev/null @@ -1,233 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/key_controller.dart"; -import "package:nexus/controllers/selected_space_controller.dart"; -import "package:nexus/controllers/spaces_controller.dart"; -import "package:nexus/helpers/extensions/join_room_with_snackbars.dart"; -import "package:nexus/pages/settings_page.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/chat_page/room_menu.dart"; -import "package:nexus/widgets/form_text_input.dart"; - -class Sidebar extends HookConsumerWidget { - const Sidebar({super.key}); - - @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 = ref.watch(SelectedSpaceController.provider); - - final indexOfSelectedRoom = selectedSpace.children.indexWhere( - (room) => room.metadata?.id == selectedRoomId, - ); - final selectedRoomIndex = indexOfSelectedRoom == -1 - ? selectedSpace.children.isEmpty - ? null - : 0 - : indexOfSelectedRoom; - - return Drawer( - shape: Border(), - child: Row( - children: [ - NavigationRail( - scrollable: true, - onDestinationSelected: (value) { - selectedSpaceIdNotifier.set(spaces[value].id); - selectedRoomIdNotifier.set( - spaces[value].children.firstOrNull?.metadata?.id, - ); - }, - destinations: spaces - .map( - (space) => NavigationRailDestination( - icon: AvatarOrHash( - space.room?.metadata?.avatar, - fallback: space.icon == null ? null : Icon(space.icon), - space.title, - hasBadge: space.children.any( - (room) => room.metadata?.unreadMessages != 0, - ), - badgeNumber: space.children.fold( - 0, - (previousValue, room) => - previousValue + - (room.metadata?.unreadNotifications ?? 0), - ), - ), - label: Text(space.title), - padding: EdgeInsets.only(top: 4), - ), - ) - .toList(), - selectedIndex: selectedIndex, - trailingAtBottom: true, - trailing: Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Column( - spacing: 8, - children: [ - PopupMenuButton( - itemBuilder: (_) => [ - PopupMenuItem( - onTap: () => showDialog( - context: context, - builder: (alertContext) => HookBuilder( - builder: (_) { - final roomAlias = useTextEditingController(); - return AlertDialog( - title: Text("Join a Room"), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Enter the room alias, ID, or a Matrix.to link.", - ), - SizedBox(height: 12), - FormTextInput( - required: false, - capitalize: true, - controller: roomAlias, - title: "#room:server", - ), - ], - ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text("Cancel"), - ), - TextButton( - onPressed: () async { - Navigator.of(alertContext).pop(); - - final client = ref.watch( - ClientController.provider.notifier, - ); - if (context.mounted) { - client.joinRoomWithSnackBars( - context, - roomAlias.text, - ref, - ); - } - }, - child: Text("Join"), - ), - ], - ); - }, - ), - ), - child: ListTile( - title: Text("Join an existing room (or space)"), - leading: Icon(Icons.numbers), - ), - ), - PopupMenuItem( - onTap: () {}, - child: ListTile( - title: Text("Create a new room"), - leading: Icon(Icons.add), - ), - ), - ], - icon: Icon(Icons.add), - ), - IconButton( - tooltip: "Explore other rooms", - onPressed: () => showDialog( - context: context, - builder: (context) => AlertDialog(title: Text("To-do")), - ), - icon: Icon(Icons.explore), - ), - IconButton( - tooltip: "Open settings", - onPressed: () => Navigator.of( - context, - ).push(MaterialPageRoute(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: TextOverflow.ellipsis, - ), - backgroundColor: Colors.transparent, - actions: [ - if (selectedSpace.room != null) - RoomMenu( - selectedSpace.room!, - children: selectedSpace.children, - ), - ], - ), - body: NavigationRail( - scrollable: true, - backgroundColor: Colors.transparent, - extended: true, - selectedIndex: selectedRoomIndex, - destinations: selectedSpace.children - .map( - (room) => NavigationRailDestination( - label: Text(room.metadata?.name ?? "Unnamed Room"), - icon: AvatarOrHash( - room.metadata?.avatar, - hasBadge: room.metadata?.unreadMessages != 0, - badgeNumber: room.metadata?.unreadNotifications ?? 0, - room.metadata?.name ?? "Unnamed Room", - fallback: selectedSpaceId == "dms" - ? null - : Icon(Icons.numbers), - // space.client.headers, - ), - ), - ) - .toList(), - onDestinationSelected: (value) => selectedRoomIdNotifier.set( - selectedSpace.children[value].metadata?.id, - ), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/widgets/chat_page/wrappers/message_wrapper.dart b/lib/widgets/chat_page/wrappers/message_wrapper.dart deleted file mode 100644 index 1be6c2b..0000000 --- a/lib/widgets/chat_page/wrappers/message_wrapper.dart +++ /dev/null @@ -1,59 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:nexus/models/room.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; - -class MessageWrapper extends StatelessWidget { - final Message message; - final Widget child; - final Room room; - final MessageGroupStatus? groupStatus; - const MessageWrapper( - this.message, - this.child, - this.groupStatus, - this.room, { - super.key, - }); - - @override - Widget build(BuildContext context) => ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(12)), - child: AnimatedContainer( - padding: message.metadata?["flashing"] == true - ? EdgeInsets.all(8) - : EdgeInsets.all(0), - color: message.metadata?["flashing"] == true - ? Theme.of(context).colorScheme.onSurface.withAlpha(50) - : Colors.transparent, - duration: Duration(milliseconds: 250), - child: Row( - spacing: 8, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - groupStatus?.isFirst != false - ? MessageAvatar(message, room, height: 40) - : SizedBox(width: 40), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 4, - children: [ - if (groupStatus?.isFirst != false) - MessageDisplayname( - message, - room, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - child, - ], - ), - ), - ], - ), - ), - ); -} diff --git a/lib/widgets/chat_page/wrappers/text_message_wrapper.dart b/lib/widgets/chat_page/wrappers/text_message_wrapper.dart deleted file mode 100644 index 41bc01e..0000000 --- a/lib/widgets/chat_page/wrappers/text_message_wrapper.dart +++ /dev/null @@ -1,115 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_link_previewer/flutter_link_previewer.dart"; -import "package:nexus/models/room.dart"; -import "package:nexus/widgets/chat_page/html/html.dart"; -import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart"; -import "package:nexus/widgets/chat_page/reply_widget.dart"; - -class TextMessageWrapper extends StatelessWidget { - final Message message; - final String? content; - final Room room; - final MessageGroupStatus? groupStatus; - final Future Function(Message oldMessage, Message newMessage) - updateMessage; - final bool isSentByMe; - final Widget? extra; - final OnTapReply onTapReply; - - const TextMessageWrapper( - this.message, { - this.content, - this.onTapReply, - required this.room, - required this.updateMessage, - required this.groupStatus, - required this.isSentByMe, - this.extra, - super.key, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final textMessage = message is TextMessage ? message as TextMessage : null; - - return MessageWrapper( - message, - ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(8)), - child: Container( - padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), - decoration: BoxDecoration( - color: isSentByMe - ? colorScheme.primaryContainer - : colorScheme.surfaceContainer, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ReplyWidget( - message, - room: room, - groupStatus: groupStatus, - onTapReply: onTapReply, - ), - if (content != null) - Html( - textStyle: message.metadata?["big"] == true - ? TextStyle(fontSize: 32) - : null, - content! - .replaceAllMapped( - RegExp( - "(]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)", - caseSensitive: false, - ), - (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"; - }, - ) - .replaceAll("\n", "
"), - ), - if (textMessage?.editedAt != null) - Text("(edited)", style: theme.textTheme.labelSmall), - if (textMessage != null) - LinkPreview( - text: textMessage.text, - backgroundColor: isSentByMe - ? colorScheme.inversePrimary - : colorScheme.surfaceContainerLow, - outsidePadding: EdgeInsets.only(top: 4), - insidePadding: EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - linkPreviewData: message.metadata?["linkPreviewData"], - onLinkPreviewDataFetched: (linkPreviewData) => updateMessage( - message, - message.copyWith( - metadata: { - ...(message.metadata ?? {}), - "linkPreviewData": linkPreviewData, - }, - ), - ), - ), - if (extra != null) extra!, - ], - ), - ), - ), - groupStatus, - room, - ); - } -} diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart new file mode 100644 index 0000000..3d04a78 --- /dev/null +++ b/lib/widgets/composer/composer.dart @@ -0,0 +1,285 @@ +import "dart:io"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:file_selector/file_selector.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"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +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/overlays/tagger_overlay.dart"; +import "package:nexus/widgets/composer/relation_preview.dart"; +import "package:nexus/main.dart"; + +class const Composer( + final String roomId, { + required final Event? relatedEvent, + required final RelationType relationType, + required final VoidCallback onDismiss, + required final Future Function( + String text, { + required bool shouldMention, + required IList tags, + }) + 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 triggerCharacter = useState(""); + final shouldMention = useState(true); + final query = useState(""); + + if (relationType == .edit && controller.value.text.isEmpty) { + controller.value.text = + relatedEvent?.localContent?.editSource ?? + switch (relatedEvent?.content) { + TextMessageContent(:final body) => body, + _ => "", + }; + } + + final attachment = ref.watch(AttachmentController.provider(roomId)); + + void send() { + if (controller.value.text.isEmpty && attachment == null || + attachment != null && attachment.$2 == null) { + return; + } + onSend( + controller.value.formattedText, + shouldMention: shouldMention.value, + tags: .new(controller.value.tags), + ); + + onDismiss(); + controller.value.text = ""; + } + + final style = TextStyle( + color: theme.colorScheme.primary, + fontWeight: .bold, + ); + + return Padding( + padding: .all(12), + child: Column( + children: [ + if (attachment != null) + Card( + margin: .only(bottom: 8), + child: ListTile( + leading: attachment.$2 == null + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(), + ) + : Icon(Icons.file_copy), + title: Text(attachment.$1), + trailing: IconButton( + onPressed: () => + ref.invalidate(AttachmentController.provider(roomId)), + icon: Icon(Icons.close), + ), + ), + ), + ClipRRect( + borderRadius: .all(.circular(12)), + child: Column( + children: [ + RelationPreview( + relatedEvent, + shouldMention: shouldMention.value, + toggleShouldMention: () => + shouldMention.value = !shouldMention.value, + relationType: relationType, + onDismiss: onDismiss, + ), + Container( + color: theme.colorScheme.surfaceContainerHighest, + padding: .symmetric(horizontal: 8), + child: Row( + spacing: 8, + mainAxisAlignment: .center, + children: + ref.watch( + PowerLevelController.provider( + .new(eventType: .message, roomId: roomId), + ), + ) + ? [ + IconButton( + onPressed: () => showModalBottomSheet( + isScrollControlled: true, + context: context, + builder: (context) => EmojiPicker( + onSelection: (value) { + Navigator.of(context).pop(); + controller.value.text += value; + node?.requestFocus(); + }, + ), + ), + icon: Icon(Icons.emoji_emotions), + ), + PopupMenuButton( + tooltip: "Add media", + enabled: attachment == null, + itemBuilder: (context) => [ + if (Platform.isAndroid || Platform.isIOS) + PopupMenuItem( + child: ListTile( + title: Text("Camera"), + leading: Icon(Icons.add_a_photo), + ), + onTap: () async => ref + .watch( + AttachmentController.provider(roomId) + .notifier, + ) + .add( + (await ref + .watch( + ImagePickerController.provider, + ) + .pickImage(source: .camera))!, + ) + .onError(showError), + ), + PopupMenuItem( + child: ListTile( + title: Text("Gallery"), + leading: Icon(Icons.add_photo_alternate), + ), + onTap: () async => ref + .watch( + AttachmentController.provider(roomId) + .notifier, + ) + .add( + (await ref + .watch( + ImagePickerController.provider, + ) + .pickImage(source: .gallery))!, + ) + .onError(showError), + ), + PopupMenuItem( + onTap: () async => ref + .watch( + AttachmentController.provider(roomId) + .notifier, + ) + .add((await openFile())!) + .onError(showError), + child: ListTile( + title: Text("Files"), + leading: Icon(Icons.attachment), + ), + ), + ], + icon: Icon(Icons.add), + ), + Expanded( + child: FlutterTagger( + triggerStrategy: .eager, + overlay: TaggerOverlay( + query.value, + roomId: roomId, + triggerCharacter: triggerCharacter.value, + addTag: ({required id, required name}) { + controller.value.addTag(id: id, name: name); + node?.requestFocus(); + }, + ), + controller: controller.value, + onSearch: (newQuery, newTriggerCharacter) { + triggerCharacter.value = newTriggerCharacter; + query.value = newQuery; + }, + triggerCharacterAndStyles: { + "@": style, + "#": style, + ":": style, + }, + builder: (context, key) => Focus( + onKeyEvent: (_, event) { + if (event is KeyDownEvent && + event.logicalKey == + LogicalKeyboardKey.enter) { + final shiftPressed = HardwareKeyboard + .instance + .isShiftPressed; + + if (!shiftPressed) { + send(); + return KeyEventResult.handled; + } + } + + return KeyEventResult.ignored; + }, + child: TextField( + maxLines: 12, + minLines: 1, + autofocus: + (Platform.isLinux || + Platform.isMacOS || + Platform.isWindows) + ? true + : false, + decoration: .new( + hintText: "Your message here...", + border: .none, + ), + controller: controller.value, + key: key, + focusNode: node, + ), + ), + ), + ), + IconButton( + onPressed: + attachment != null && attachment.$2 == null + ? null + : send, + icon: Icon(Icons.send), + tooltip: "Send message", + ), + ] + : [ + Expanded( + child: Padding( + padding: .symmetric( + horizontal: 8, + vertical: 12, + ), + child: Text( + "You don't have permission to send messages in this room...", + ), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} 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 new file mode 100644 index 0000000..fca9de6 --- /dev/null +++ b/lib/widgets/composer/relation_preview.dart @@ -0,0 +1,58 @@ +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 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(); + final theme = Theme.of(context); + + return Container( + color: theme.colorScheme.surfaceContainerHigh, + padding: .symmetric(horizontal: 12), + child: Row( + spacing: 8, + children: [ + if (relationType == .edit) + Text("Editing message:", style: .new(fontWeight: .bold)), + + Expanded( + child: Padding( + padding: .symmetric(vertical: 8), + child: EventPreview(relatedEvent!), + ), + ), + + if (relationType == .reply) + TextButton( + onPressed: toggleShouldMention, + child: Text( + shouldMention ? "@On" : "@Off", + style: TextStyle( + fontWeight: .w900, + color: shouldMention ? null : Theme.of(context).disabledColor, + ), + ), + ), + + IconButton( + tooltip: "Cancel ${relationType == .edit ? "edit" : "reply"}", + onPressed: onDismiss, + icon: const Icon(Icons.close), + iconSize: 20, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/divider_text.dart b/lib/widgets/divider_text.dart index ca78844..c44ae11 100644 --- a/lib/widgets/divider_text.dart +++ b/lib/widgets/divider_text.dart @@ -1,29 +1,9 @@ -import "package:flutter/material.dart"; - -class DividerText extends StatelessWidget { - final String text; - - const DividerText(this.text, {super.key}); +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/divider_widget.dart"; +final class const DividerText(final String text, {super.key}) + extends StatelessWidget { @override - Widget build(BuildContext context) => LayoutBuilder( - builder: (context, constraints) => Row( - children: [ - SizedBox( - width: 16, - child: Divider(color: Theme.of(context).colorScheme.onSurface), - ), - ConstrainedBox( - constraints: BoxConstraints(maxWidth: constraints.maxWidth - 32), - child: Padding( - padding: const EdgeInsets.all(8), - child: Text(text, style: Theme.of(context).textTheme.labelLarge), - ), - ), - Expanded( - child: Divider(color: Theme.of(context).colorScheme.onSurface), - ), - ], - ), - ); + 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 new file mode 100644 index 0000000..5e4972c --- /dev/null +++ b/lib/widgets/divider_widget.dart @@ -0,0 +1,23 @@ +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( + children: [ + SizedBox( + width: 16, + child: Divider(color: Theme.of(context).colorScheme.onSurface), + ), + ConstrainedBox( + constraints: .new(maxWidth: constraints.maxWidth - 32), + child: Padding(padding: const .all(8), child: widget), + ), + Expanded( + child: Divider(color: Theme.of(context).colorScheme.onSurface), + ), + ], + ), + ); +} diff --git a/lib/widgets/emoji_picker.dart b/lib/widgets/emoji_picker.dart new file mode 100644 index 0000000..a0c691a --- /dev/null +++ b/lib/widgets/emoji_picker.dart @@ -0,0 +1,53 @@ +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) async { + await onSelection(emoji); + 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/error_dialog.dart b/lib/widgets/error_dialog.dart index b016a8b..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( @@ -21,11 +21,12 @@ class ErrorDialog extends ConsumerWidget { onPressed: () => ref.invalidate(provider!), child: const Text("Try Again"), ), - TextButton( - onPressed: () => - Navigator.of(context).popUntil((route) => route.isFirst), - child: const Text("Go Back"), - ), + if (Navigator.of(context).canPop()) + TextButton( + onPressed: () => + Navigator.of(context).popUntil((route) => route.isFirst), + child: const Text("Go Back"), + ), ], ); } diff --git a/lib/widgets/event_preview.dart b/lib/widgets/event_preview.dart new file mode 100644 index 0000000..30dfbfe --- /dev/null +++ b/lib/widgets/event_preview.dart @@ -0,0 +1,35 @@ +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 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, + children: [ + if (event.content is MessageContent) MessageAvatar(event), + + Flexible( + child: Wrap( + crossAxisAlignment: .center, + spacing: 8, + runSpacing: 2, + children: [ + if (event.content is MessageContent) MessageDisplayname(event), + EventRenderer(event, textOnly: true, maxLines: 1), + ], + ), + ), + ], + ), + ), + ); +} diff --git a/lib/widgets/expandable_image.dart b/lib/widgets/expandable_image.dart new file mode 100644 index 0000000..0639417 --- /dev/null +++ b/lib/widgets/expandable_image.dart @@ -0,0 +1,53 @@ +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"; + +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 + ? null + : () => showDialog( + context: context, + builder: (_) => SafeArea( + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: Navigator.of(context).pop, + child: InteractiveViewer( + maxScale: 10, + child: Image( + errorBuilder: (_, error, stackTrace) => ErrorDialog( + "Loading failed for ${request?.mxc}\nError: $error", + stackTrace, + ), + image: MxcImage(ref, request!), + ), + ), + ), + ), + Align( + alignment: .topRight, + child: Padding( + padding: .all(32), + child: M3EButton( + onPressed: Navigator.of(context).pop, + child: Icon(Icons.close), + ), + ), + ), + ], + ), + ), + ), + child: child, + ); +} diff --git a/lib/widgets/file_card.dart b/lib/widgets/file_card.dart new file mode 100644 index 0000000..4c0fdde --- /dev/null +++ b/lib/widgets/file_card.dart @@ -0,0 +1,25 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/size_to_string.dart"; +import "package:nexus/models/info/file.dart"; + +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, + child: Card( + color: Theme.of(context).colorScheme.surfaceContainer, + child: ListTile( + leading: Icon(Icons.file_copy), + title: Text(filename ?? "file", maxLines: 1, overflow: .ellipsis), + subtitle: info?.size == null ? null : Text(info!.size!.sizeAsString), + // TODO: Downloading files + trailing: IconButton(onPressed: null, icon: Icon(Icons.download)), + ), + ), + ); +} diff --git a/lib/widgets/form_text_input.dart b/lib/widgets/form_text_input.dart deleted file mode 100644 index 21b2e5c..0000000 --- a/lib/widgets/form_text_input.dart +++ /dev/null @@ -1,83 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter/services.dart"; - -class FormTextInput extends StatelessWidget { - final List extraValidators; - final TextEditingController? controller; - final TextInputType keyboardType; - final String? initialValue; - final bool readOnly; - final bool obscure; - final String? title; - final int? minLines; - final int? maxLength; - final bool outlined; - final int? maxLines; - final bool capitalize; - final bool required; - final bool autocorrect; - final void Function()? onTap; - final Widget? trailing; - final InputBorder? border; - final List? formatters; - final bool autofocus; - - const FormTextInput({ - super.key, - this.border, - this.controller, - this.autofocus = false, - this.title, - this.obscure = false, - this.readOnly = false, - this.extraValidators = const [], - this.keyboardType = TextInputType.text, - this.initialValue, - this.minLines, - this.capitalize = false, - this.maxLength, - this.formatters, - this.maxLines = 1, - this.outlined = true, - this.trailing, - this.onTap, - this.autocorrect = true, - this.required = true, - }); - - @override - Widget build(BuildContext context) => TextFormField( - autofocus: autofocus, - controller: controller, - keyboardType: keyboardType, - readOnly: readOnly, - minLines: minLines, - maxLines: maxLines, - maxLength: maxLength, - inputFormatters: formatters, - textCapitalization: capitalize - ? TextCapitalization.sentences - : TextCapitalization.none, - initialValue: initialValue, - autocorrect: autocorrect, - obscureText: obscure, - onTap: onTap, - decoration: InputDecoration( - labelText: title, - border: border ?? (outlined ? null : const UnderlineInputBorder()), - suffixIcon: trailing, - ), - validator: (value) { - if ((value?.isEmpty ?? true) && required) { - return "This field is required"; - } - - for (final validator in extraValidators) { - final reason = validator(value!); - if (reason != null) return reason; - } - - return null; - }, - ); -} diff --git a/lib/widgets/highlight_wrapper.dart b/lib/widgets/highlight_wrapper.dart new file mode 100644 index 0000000..b10e3ff --- /dev/null +++ b/lib/widgets/highlight_wrapper.dart @@ -0,0 +1,20 @@ +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)), + child: AnimatedContainer( + padding: isHighlighted ? .all(8) : .all(0), + color: isHighlighted + ? Theme.of(context).colorScheme.onSurface.withAlpha(50) + : Colors.transparent, + duration: .new(milliseconds: 250), + child: Material(color: Colors.transparent, child: child), + ), + ); +} diff --git a/lib/widgets/chat_page/html/code_block.dart b/lib/widgets/html/code_block.dart similarity index 61% rename from lib/widgets/chat_page/html/code_block.dart rename to lib/widgets/html/code_block.dart index 80950ce..abd9be2 100644 --- a/lib/widgets/chat_page/html/code_block.dart +++ b/lib/widgets/html/code_block.dart @@ -1,34 +1,35 @@ 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); return ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(16)), + borderRadius: .all(.circular(16)), child: ColoredBox( color: theme.colorScheme.surfaceContainerHighest, child: IntrinsicWidth( child: Column( children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: .spaceBetween, children: [ Padding( - padding: EdgeInsets.symmetric(horizontal: 8), + padding: .symmetric(horizontal: 8), child: Text( lang.substring(0, min(lang.length, 15)), - style: TextStyle(fontFamily: "monospace"), + style: .new(fontFamily: "monospace"), ), ), TextButton.icon( - onPressed: () {}, + onPressed: () => Clipboard.setData(.new(text: code)), icon: Icon(Icons.copy), label: Text("Copy"), ), @@ -37,13 +38,13 @@ class CodeBlock extends StatelessWidget { ColoredBox( color: theme.colorScheme.surfaceContainerHigh, child: Container( - constraints: BoxConstraints(minWidth: 250), - padding: EdgeInsets.all(8), + constraints: .new(minWidth: 250), + padding: .all(8), child: SelectableText( code, minLines: 1, maxLines: 99, - style: TextStyle(fontFamily: "monospace"), + style: .new(fontFamily: "monospace"), ), ), ), diff --git a/lib/widgets/html/html.dart b/lib/widgets/html/html.dart new file mode 100644 index 0000000..85f8054 --- /dev/null +++ b/lib/widgets/html/html.dart @@ -0,0 +1,150 @@ +import "package:fast_immutable_collections/fast_immutable_collections.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/launch_helper.dart"; +import "package:nexus/helpers/mxc_image.dart"; +import "package:nexus/widgets/expandable_image.dart"; +import "package:nexus/widgets/html/mention_chip.dart"; +import "package:nexus/widgets/html/spoiler_text.dart"; +import "package:nexus/widgets/html/code_block.dart"; +import "package:nexus/widgets/html/quoted.dart"; + +class const Html( + final String html, { + final String? roomId, + final TextStyle? textStyle, + super.key, +}) extends ConsumerWidget { + @override + 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(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"] ?? ""); + + 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, + ), + ), + 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 new file mode 100644 index 0000000..2387a49 --- /dev/null +++ b/lib/widgets/html/mention_chip.dart @@ -0,0 +1,62 @@ +import "package:material_ui/material_ui.dart"; +import "package:flutter_riverpod/flutter_riverpod.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/show_user_popover.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/room_summary.dart"; + +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 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), + + _ => null, + }; + + return mention == null + ? SizedBox.shrink() + : InkWell( + onTap: () { + if (data case MembershipContent membership) { + context.showUserPopover(membership, mention, roomId: roomId); + } else if (data case RoomSummary summary) { + // TODO: Handle summary + } + }, + child: Text( + switch (data) { + RoomSummary summary => + (summary.name == null ? null : "#${summary.name}") ?? + summary.canonicalAlias ?? + summary.roomId, + 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 new file mode 100644 index 0000000..3157263 --- /dev/null +++ b/lib/widgets/html/quoted.dart @@ -0,0 +1,13 @@ +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( + border: Border( + left: .new(width: 4, color: Theme.of(context).dividerColor), + ), + ), + child: Padding(padding: .only(left: 8), child: child), + ); +} diff --git a/lib/widgets/chat_page/html/spoiler_text.dart b/lib/widgets/html/spoiler_text.dart similarity index 51% rename from lib/widgets/chat_page/html/spoiler_text.dart rename to lib/widgets/html/spoiler_text.dart index 9a42bff..92a5538 100644 --- a/lib/widgets/chat_page/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); @@ -13,15 +9,15 @@ class SpoilerText extends HookWidget { return InkWell( onTap: () => revealed.value = !revealed.value, child: AnimatedContainer( - duration: const Duration(milliseconds: 100), - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + duration: const .new(milliseconds: 100), + padding: const .symmetric(horizontal: 4, vertical: 2), decoration: BoxDecoration( color: revealed.value ? Colors.transparent : Colors.blueGrey, - borderRadius: BorderRadius.circular(4), + borderRadius: .circular(4), ), child: Text( text, - style: TextStyle(color: revealed.value ? null : Colors.transparent), + style: .new(color: revealed.value ? null : Colors.transparent), ), ), ); diff --git a/lib/widgets/join_dialog.dart b/lib/widgets/join_dialog.dart new file mode 100644 index 0000000..e818c7d --- /dev/null +++ b/lib/widgets/join_dialog.dart @@ -0,0 +1,132 @@ +import "package:collection/collection.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 const JoinDialog(final WidgetRef ref, {super.key}) extends HookWidget { + @override + Widget build(BuildContext context) { + final roomAlias = useTextEditingController(); + return AlertDialog( + title: Text("Join a Room"), + content: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Text("Enter the room alias, Matrix URI, or Matrix.to link."), + SizedBox(height: 12), + TextField( + controller: roomAlias, + decoration: .new(hintText: "#room:server"), + ), + ], + ), + 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"), + ), + ], + ); + } +} diff --git a/lib/widgets/lazy_loading/message_avatar.dart b/lib/widgets/lazy_loading/message_avatar.dart new file mode 100644 index 0000000..709d558 --- /dev/null +++ b/lib/widgets/lazy_loading/message_avatar.dart @@ -0,0 +1,29 @@ +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/models/event.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; + +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), + )) { + AsyncData(:final value) || AsyncLoading(:final value?) => InkWell( + onTap: () => + context.showUserPopover(value, event.sender, roomId: event.roomId), + child: AvatarOrHash( + value.avatarUrl, + value.displayName ?? event.sender.localpart, + height: height, + ), + ), + _ => AvatarOrHash(null, event.sender.localpart, height: height), + }; +} diff --git a/lib/widgets/lazy_loading/message_displayname.dart b/lib/widgets/lazy_loading/message_displayname.dart new file mode 100644 index 0000000..9203f69 --- /dev/null +++ b/lib/widgets/lazy_loading/message_displayname.dart @@ -0,0 +1,54 @@ +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"; + +class const MessageDisplayname( + final Event event, { + final TextStyle? style, + final bool clickable = 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 + ? () => context.showUserPopover( + value, + event.sender, + roomId: event.roomId, + ) + : null, + child: Wrap( + spacing: 4, + crossAxisAlignment: .center, + children: [ + Text( + value.displayName ?? event.sender.localpart, + style: + style ?? .new(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), + ), + }; +} diff --git a/lib/widgets/linkified_text.dart b/lib/widgets/linkified_text.dart new file mode 100644 index 0000000..77ba7a4 --- /dev/null +++ b/lib/widgets/linkified_text.dart @@ -0,0 +1,23 @@ +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"; + +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, + maxLines: maxLines, + style: style, + options: .new(humanize: false), + onOpen: (link) => + ref.watch(LaunchHelper.provider).launchUrl(.parse(link.url)), + linkStyle: .new(color: Theme.of(context).colorScheme.primary), + overflow: maxLines == null ? null : .ellipsis, + ); +} diff --git a/lib/widgets/loading.dart b/lib/widgets/loading.dart index 9bb2858..180cd1b 100644 --- a/lib/widgets/loading.dart +++ b/lib/widgets/loading.dart @@ -1,13 +1,10 @@ -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? height}) extends StatelessWidget { @override Widget build(BuildContext context) => Center( child: Padding( - padding: EdgeInsets.all(16), + padding: .all(16), child: SizedBox(height: height, child: CircularProgressIndicator()), ), ); diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart new file mode 100644 index 0000000..9ddafa3 --- /dev/null +++ b/lib/widgets/member_list.dart @@ -0,0 +1,187 @@ +import "package:fast_immutable_collections/fast_immutable_collections.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"; +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/string_to_color.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/membership_status.dart"; +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 const MemberList(final String roomId, {super.key}) + extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final statusIndex = useState(0); + + final options = { + "Joined": .join, + "Invited": .invite, + "Banned": .ban, + }; + final status = options.values.toIList()[statusIndex.value]; + + return Drawer( + shape: Border(), + child: Scaffold( + backgroundColor: Colors.transparent, + body: Column( + children: [ + Padding( + padding: .symmetric(vertical: 8), + child: M3EToggleButtonGroup( + selectedIndex: statusIndex.value, + onSelectedIndexChanged: (index) => + statusIndex.value = index ?? statusIndex.value, + actions: options + .mapTo( + (name, value) => M3EToggleButtonGroupAction( + checkedLabel: Text( + "$name${switch (ref.watch(MembersByStatusController.provider(.new(roomId: roomId, status: value)))) { + AsyncData(:final value) || AsyncLoading(:final value?) => " (${value.length})", + _ => "", + }}", + ), + label: Text(name), + ), + ) + .toList(), + ), + ), + + switch (ref.watch( + MembersGroupedController.provider( + .new(roomId: roomId, status: status), + ), + )) { + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + AsyncData(:final value) || AsyncLoading(:final value?) => + value.isEmpty + ? Center( + child: Padding( + padding: .symmetric(vertical: 18), + child: Text( + "No ${options.keys.toIList()[statusIndex.value]} Members", + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ) + : Expanded( + child: CustomScrollView( + slivers: [ + for (final MapEntry(key: powerLevel, value: members) + in value) ...[ + SliverToBoxAdapter( + child: Padding( + padding: .symmetric(horizontal: 16), + child: DividerText( + powerLevel == null + ? "Creators" + : "Power Level $powerLevel", + ), + ), + ), + SliverM3ECardList( + padding: .all(4), + color: Theme.of(context) + .colorScheme + .surfaceContainerHigh, + margin: .symmetric(horizontal: 12, vertical: 4), + itemCount: members.length, + itemBuilder: (context, index) => + switch (members[index].content) { + MembershipContent( + :final avatarUrl, + :final displayName, + ) => + ListTile( + title: Text( + displayName ?? + members[index] + .stateKey! + .localpart, + overflow: .ellipsis, + style: .new( + color: members[index] + .stateKey! + .colorHash, + fontWeight: .bold, + ), + ), + subtitle: Text( + members[index].stateKey!, + overflow: .ellipsis, + ), + leading: AvatarOrHash( + avatarUrl, + height: 36, + displayName ?? + members[index] + .stateKey! + .localpart, + ), + ), + _ => throw Exception( + "Member content was not MembershipContent", + ), + }, + onTap: (index) { + 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, + ), + ); + } + }, + ), + ], + ], + ), + ), + AsyncLoading _ => Loading(), + }, + ], + ), + appBar: Scaffold.of(context).hasEndDrawer + ? AppBar( + scrolledUnderElevation: 0, + leading: Icon(Icons.people), + title: Text("Members"), + actionsPadding: .only(right: 4), + actions: [ + IconButton( + onPressed: Scaffold.of(context).closeEndDrawer, + icon: Icon(Icons.close), + tooltip: "Close member list", + ), + ], + ) + : null, + ), + ); + } +} diff --git a/lib/widgets/message_image.dart b/lib/widgets/message_image.dart new file mode 100644 index 0000000..b04c209 --- /dev/null +++ b/lib/widgets/message_image.dart @@ -0,0 +1,57 @@ +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"; +import "package:nexus/models/info/image.dart" as i; +import "package:nexus/models/requests/download_media.dart"; +import "package:nexus/widgets/expandable_image.dart"; +import "package:nexus/widgets/loading.dart"; + +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); + return ExpandableImage( + request, + child: ClipRRect( + borderRadius: .all(.circular(8)), + child: AspectRatio( + aspectRatio: (info?.width ?? 1) / (info?.height ?? 1), + child: Image( + image: MxcImage(ref, request), + width: info?.width, + fit: BoxFit.fitWidth, + loadingBuilder: (_, child, loadingProgress) => + loadingProgress == null + ? child + : switch (info?.blurHash) { + final blurHash? => + info?.width == null || info?.height == null + ? SizedBox( + width: 200, + height: 200, + child: BlurHash(hash: blurHash), + ) + : SizedBox( + width: info!.width, + child: BlurHash(hash: blurHash), + ), + _ => Loading(), + }, + errorBuilder: (context, error, stackTrace) => Center( + child: Text( + "Image Failed to Load", + style: .new(color: Theme.of(context).colorScheme.error), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/pages/chat.dart b/lib/widgets/pages/chat.dart new file mode 100644 index 0000000..186dfa2 --- /dev/null +++ b/lib/widgets/pages/chat.dart @@ -0,0 +1,60 @@ +import "package:material_ui/material_ui.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/init_complete.dart"; +import "package:nexus/controllers/jump_to_event.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/room_chat.dart"; +import "package:nexus/widgets/loading.dart"; + +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)) + .requireValue; + + return SafeArea( + child: Scaffold( + appBar: initComplete ? null : Appbar(), + body: initComplete + ? Row( + children: [ + if (isDesktop) Sidebar(isDesktop: isDesktop), + Expanded( + child: Consumer( + builder: (context, ref, _) { + final initialHighlight = ref.watch( + JumpToEventController.provider(roomId), + ); + return RoomChat( + key: ValueKey((roomId, initialHighlight)), + roomId: roomId, + isDesktop: isDesktop, + initialHighlightedEvent: initialHighlight, + showMembersByDefault: showMembersByDefault, + ); + }, + ), + ), + ], + ) + : Center( + child: Column( + mainAxisSize: .min, + children: [Loading(), Text("Syncing...")], + ), + ), + drawer: isDesktop || !initComplete + ? null + : Sidebar(isDesktop: isDesktop), + ), + ); + }, + ); +} diff --git a/lib/widgets/pages/notifications.dart b/lib/widgets/pages/notifications.dart new file mode 100644 index 0000000..2ad8930 --- /dev/null +++ b/lib/widgets/pages/notifications.dart @@ -0,0 +1,215 @@ +import "dart:async"; + +import "package:collection/collection.dart"; +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/jump_to_event.dart"; +import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/notifications.dart"; +import "package:nexus/controllers/spaces.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/appbar.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 NotificationsPage({ + final String? highlightedEventId, + final bool defaultToAllNotifications = false, + super.key, +}) extends HookConsumerWidget { + @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(highlightedEventId); + 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( + (element) => element.eventId == 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 Scaffold( + appBar: Appbar(title: Text("Notifications")), + body: 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 event = value[index]; + final isHighlighted = + event.eventId == highlightedId.value; + + return Padding( + padding: .only(top: 8), + child: HighlightWrapper( + InkWell( + onTap: () async { + final spaces = ref.read( + SpacesController.provider, + ); + final space = spaces.firstWhereOrNull( + (space) => + space.children.any( + (room) => + room.metadata?.id == + event.roomId, + ) || + space.subSpaces.any( + (subSpace) => + subSpace.children.any( + (room) => + room.metadata?.id == + event.roomId, + ), + ), + ); + if (space == null) return; + + await ref + .read( + KeyController.provider( + KeyController.spaceKey, + ).notifier, + ) + .set(space.id); + await ref + .read( + KeyController.provider( + KeyController.roomKey, + ).notifier, + ) + .set(event.roomId); + ref + .watch( + JumpToEventController.provider( + event.roomId, + ).notifier, + ) + .set(event.eventId); + + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + child: IgnorePointer( + child: EventRenderer(event), + ), + ), + 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/widgets/pages/select_server.dart b/lib/widgets/pages/select_server.dart new file mode 100644 index 0000000..34f1a50 --- /dev/null +++ b/lib/widgets/pages/select_server.dart @@ -0,0 +1,236 @@ +import "package:app_links/app_links.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"; +import "package:nexus/controllers/auth_url.dart"; +import "package:nexus/controllers/client.dart"; +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/widgets/pages/settings.dart"; +import "package:nexus/widgets/appbar.dart"; +import "package:nexus/widgets/divider_text.dart"; + +class const SelectServerPage({super.key}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + final launch = ref.watch(LaunchHelper.provider).launchUrl; + + final isLoading = useState(false); + 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 { + if (newHomeserver?.hasScheme == false) { + newHomeserver = Uri.https(newHomeserver!.path); + } + + final newUrl = await ref + .read(ClientController.provider.notifier) + .discoverHomeserver(newHomeserver!); + + if (context.mounted) { + Future tryLogin(Uri url) async { + final codeResponse = await ref.watch( + AuthUrlController.provider(url).future, + ); + + await ref.watch(LaunchHelper.provider).launchUrl(codeResponse.url); + + AppLinks().uriLinkStream.listen((encodedUri) async { + final state = encodedUri.queryParameters["state"]; + if (state != codeResponse.state) return; + isLoading.value = true; + + try { + final code = encodedUri.queryParameters["code"]; + + 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, + ), + ), + ); + } + } 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; + } + } + + return Scaffold( + appBar: Appbar( + actions: .new([ + IconButton( + onPressed: () => + showDialog(context: context, builder: (_) => SettingsPage()), + icon: Icon(Icons.settings), + ), + ]), + ), + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: .new(maxWidth: 600), + child: ListView( + padding: .symmetric(vertical: 8, horizontal: 12), + children: [ + Row( + children: [ + SvgPicture.asset("assets/bundled/icon.svg", width: 128), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: .start, + children: [ + Text("Nexus", style: theme.textTheme.displayMedium), + Text( + "A Simple Matrix Client", + style: theme.textTheme.headlineMedium, + overflow: .ellipsis, + ), + ], + ), + ), + ], + ), + Padding(padding: .symmetric(vertical: 12), child: Divider()), + DividerText("Enter a homeserver domain:"), + Row( + spacing: 8, + children: [ + Expanded( + child: TextField( + textInputAction: .done, + autofocus: true, + onSubmitted: (text) => setHomeserver(.tryParse(text)), + controller: homeserverUrl, + decoration: .new( + labelText: "Homeserver URL", + hintText: "matrix.org", + ), + ), + ), + IconButton.filled( + tooltip: "Confirm homeserver choice", + onPressed: isLoading.value + ? null + : () => setHomeserver(.tryParse(homeserverUrl.text)), + icon: Icon(Icons.check), + ), + ], + ), + DividerText("Or, choose from some popular homeservers:"), + ...([ + .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.", + 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.", + 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", + ), + ].map( + (homeserver) => Card( + child: ListTile( + enabled: !isLoading.value, + title: Text(homeserver.name), + leading: Image.network( + homeserver.iconUrl, + errorBuilder: (_, _, _) => SizedBox.shrink(), + height: 32, + ), + subtitle: Text(homeserver.description), + onTap: isLoading.value + ? null + : () => setHomeserver(homeserver.url), + trailing: IconButton( + tooltip: "Launch homeserver info page", + onPressed: () => launch(homeserver.url), + icon: Icon(Icons.info_outline), + ), + ), + ), + )), + + TextButton( + onPressed: () => launch(.https("servers.joinmatrix.org")), + child: Text("See more homeservers..."), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/pages/settings.dart b/lib/widgets/pages/settings.dart new file mode 100644 index 0000000..50eb7f0 --- /dev/null +++ b/lib/widgets/pages/settings.dart @@ -0,0 +1,247 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.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"; +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/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 const SettingsPage({super.key}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder( + builder: (_, constraints) => HookBuilder( + builder: (context) { + final categoriesArePages = constraints.maxWidth < 550; + + final selected = useState(0); + + final highlightedMatch = useState<(int, int)?>(null); + final listController = useRef(ListController()); + final scrollController = useScrollController(); + + final searchBar = SearchAnchor.bar( + barHintText: "Search...", + isFullScreen: categoriesArePages, + suggestionsBuilder: (suggestionsContext, controller) async { + final categories = await ref.watch( + SettingsSectionsController.provider.future, + ); + final query = controller.text.toLowerCase(); + + final matches = categories.values.flattenedToList + .asMap() + .entries + .expand( + (categoryEntry) => categoryEntry.value.settings + .asMap() + .entries + .where( + (settingEntry) => + settingEntry.value.title.toLowerCase().contains( + query, + ) || + settingEntry.value.description + .toLowerCase() + .contains(query), + ) + .map( + (settingEntry) => ( + (categoryEntry.key, settingEntry.key), + settingEntry.value, + ), + ), + ) + .toIList(); + + return matches.map( + (match) => ListTile( + onTap: () async { + if (context.mounted) Navigator.of(suggestionsContext).pop(); + controller.text = ""; + + if (categoriesArePages) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => SettingsCategoryPage( + match.$1.$1, + initialHighlight: match.$1.$2, + ), + ), + ); + } else { + selected.value = match.$1.$1; + listController.value.animateToItem( + index: match.$1.$2, + scrollController: scrollController, + alignment: 0.5, + duration: (_) => .new(milliseconds: 700), + curve: (_) => Curves.easeInOut, + ); + highlightedMatch.value = match.$1; + await Future.delayed(.new(seconds: 1), () { + if (highlightedMatch.value == match.$1) { + highlightedMatch.value = null; + } + }); + } + }, + leading: Icon(match.$2.icon), + title: Text(match.$2.title), + subtitle: Text(match.$2.description), + ), + ); + }, + ); + + final settingsContent = Scaffold( + appBar: AppBar( + title: Text("Settings"), + actionsPadding: .symmetric(horizontal: 12), + actions: [ + IconButton( + onPressed: () => context.showAboutDialog(ref), + icon: Icon(Icons.info_outline), + ), + ], + ), + body: ref + .watch(SettingsSectionsController.provider) + .betterWhen( + data: (sections) => categoriesArePages + ? CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(12).copyWith(bottom: 8), + child: searchBar, + ), + ), + ...sections + .mapTo( + (section, categories) => [ + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 16, + ).copyWith(bottom: 4), + child: DividerText(section), + ), + ), + SliverM3ECardList( + padding: .symmetric( + horizontal: 4, + vertical: 8, + ), + margin: .symmetric(horizontal: 12), + color: Theme.of(context) + .colorScheme + .primaryContainer, + itemCount: categories.length, + onTap: (index) => Navigator.of(context) + .push( + MaterialPageRoute( + builder: (context) => + SettingsCategoryPage( + sections + .values + .flattenedToList + .indexOf( + categories[index], + ), + ), + ), + ), + itemBuilder: (context, index) => ListTile( + leading: Icon(categories[index].icon), + title: Text(categories[index].title), + ), + ), + ], + ) + .flattened, + ], + ) + : Row( + children: [ + 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( + child: SuperListView( + listController: listController.value, + controller: scrollController, + padding: .symmetric(vertical: 12), + children: sections + .values + .flattenedToList[selected.value] + .settings + .mapIndexed( + (index, setting) => Padding( + padding: .only(bottom: 4), + child: HighlightWrapper( + setting.builder( + setting.title, + setting.description, + setting.icon, + ), + isHighlighted: + highlightedMatch.value == + (selected.value, index), + ), + ), + ) + .toList(), + ), + ), + ], + ), + ), + ); + + return constraints.maxWidth < 650 + ? settingsContent + : Dialog( + constraints: .loose(Size(900, 600)), + child: ClipRRect( + borderRadius: BorderRadiusGeometry.circular(12), + child: settingsContent, + ), + ); + }, + ), + ); +} diff --git a/lib/widgets/pages/settings_category.dart b/lib/widgets/pages/settings_category.dart new file mode 100644 index 0000000..8e94c56 --- /dev/null +++ b/lib/widgets/pages/settings_category.dart @@ -0,0 +1,79 @@ +import "dart:async"; + +import "package:collection/collection.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"; +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 const SettingsCategoryPage( + final int index, { + final int? initialHighlight, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final highlight = useState(initialHighlight); + final listController = useRef(ListController()); + final scrollController = useScrollController(); + + useEffect(() { + if (initialHighlight == null) return null; + Timer? timer; + + void listener() => WidgetsBinding.instance.addPostFrameCallback((_) { + if (!listController.value.isAttached) return; + + listController.value.animateToItem( + index: initialHighlight!, + scrollController: scrollController, + alignment: 0.5, + duration: (_) => .new(milliseconds: 700), + curve: (_) => Curves.easeInOut, + ); + timer = Timer(.new(seconds: 1), () { + highlight.value = null; + }); + listController.value.removeListener(listener); + }); + + listController.value.addListener(listener); + return timer?.cancel; + }, []); + + return ref + .watch(SettingsSectionsController.provider) + .betterWhen( + data: (sections) => Scaffold( + appBar: AppBar( + title: Text(sections.values.flattenedToList[index].title), + ), + body: SafeArea( + child: SuperListView( + controller: scrollController, + listController: listController.value, + padding: .symmetric(vertical: 12, horizontal: 8), + children: sections.values.flattenedToList[index].settings + .mapIndexed( + (index, setting) => Padding( + padding: .only(bottom: 4), + child: HighlightWrapper( + setting.builder( + setting.title, + setting.description, + setting.icon, + ), + isHighlighted: highlight.value == index, + ), + ), + ) + .toList(), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/pages/verify.dart b/lib/widgets/pages/verify.dart new file mode 100644 index 0000000..a4f95ec --- /dev/null +++ b/lib/widgets/pages/verify.dart @@ -0,0 +1,82 @@ +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/widgets/pages/settings.dart"; +import "package:nexus/widgets/appbar.dart"; +import "package:nexus/helpers/required_validator_helper.dart"; + +class const VerifyPage({super.key}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final passphraseController = useTextEditingController(); + final isLoading = useState(false); + final inputError = useState(null); + final formKey = useRef(GlobalKey()); + + Future verify() async { + isLoading.value = true; + + try { + if (formKey.value.currentState?.validate() != true) { + return; + } + + inputError.value = await ref + .watch(ClientController.provider.notifier) + .verify(passphraseController.text); + } finally { + isLoading.value = false; + } + } + + return Scaffold( + appBar: Appbar( + actions: .new([ + IconButton( + onPressed: () => + showDialog(context: context, builder: (_) => SettingsPage()), + icon: Icon(Icons.settings), + ), + ]), + ), + body: AlertDialog( + title: Text("Verify"), + content: Form( + key: formKey.value, + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Text( + "Enter your recovery key or passphrase below to unlock encrypted events.\nYour passphrase is usually not the same as your password.", + ), + SizedBox(height: 12), + TextFormField( + autofocus: true, + controller: passphraseController, + textInputAction: .done, + autovalidateMode: .onUserInteraction, + validator: requiredValidator, + obscureText: true, + decoration: .new( + label: Text("Recovery Key or Passphrase"), + errorText: inputError.value, + ), + onFieldSubmitted: (_) => verify(), + // Don't defocus on submit + onEditingComplete: () {}, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: isLoading.value ? null : verify, + child: Text("Verify"), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/pinned_events_drawer.dart b/lib/widgets/pinned_events_drawer.dart new file mode 100644 index 0000000..73d9a79 --- /dev/null +++ b/lib/widgets/pinned_events_drawer.dart @@ -0,0 +1,90 @@ +import "package:fast_immutable_collections/fast_immutable_collections.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/models/event.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/renderers/event.dart"; + +final class const PinnedEventsDrawer( + final String roomId, { + required final IList Function(Event event) getEventOptions, + required final Future Function(String eventId) jumpToId, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final pinsProvider = ref.watch(PinnedEventsController.provider(roomId)); + final theme = Theme.of(context); + + return Drawer( + width: 400, + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + scrolledUnderElevation: 0, + leading: Icon(Icons.push_pin), + title: Text("Pinned Events"), + actionsPadding: .only(right: 4), + actions: [ + IconButton( + onPressed: Scaffold.of(context).closeEndDrawer, + icon: Icon(Icons.close), + tooltip: "Close pinned events", + ), + ], + ), + body: switch (pinsProvider) { + AsyncData(:final value) when value.isEmpty => Center( + child: Column( + mainAxisSize: .min, + children: [ + Icon( + Icons.push_pin_outlined, + size: 48, + color: theme.colorScheme.onSurface, + ), + SizedBox(height: 12), + Text("No pinned events", style: theme.textTheme.headlineSmall), + ], + ), + ), + AsyncData(:final value) || + AsyncLoading(:final value?) => ListView.builder( + padding: .all(8), + reverse: true, + itemCount: value.length, + itemBuilder: (context, index) { + final event = value.reversed[index]; + + return InkWell( + borderRadius: .circular(12), + onTap: () { + Navigator.of(context).pop(); + jumpToId(event.eventId); + }, + child: Padding( + padding: .symmetric(vertical: 4), + child: IgnorePointer( + child: EventRenderer( + event, + maxLines: 2, + isGrouped: false, + getEventOptions: getEventOptions, + ), + ), + ), + ); + }, + ), + AsyncLoading() => Loading(), + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + }, + ), + ); + } +} diff --git a/lib/widgets/players/audio.dart b/lib/widgets/players/audio.dart new file mode 100644 index 0000000..bd50a8b --- /dev/null +++ b/lib/widgets/players/audio.dart @@ -0,0 +1,109 @@ +import "dart:async"; + +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/controllers/client.dart"; +import "package:nexus/models/info/audio.dart"; + +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( + () => Player(configuration: .new(bufferSize: 128 * 1024 * 1024)), + ); + + final playing = useState(false); + final position = useState(Duration.zero); + final duration = useState(Duration.zero); + + useEffect(() { + 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); + + player.stream.playing.listen((value) { + playing.value = value; + }); + + player.stream.position.listen((value) { + position.value = value; + }); + + player.stream.duration.listen((value) { + duration.value = value; + }); + }); + + return player.dispose; + }, []); + + String format(Duration duration) { + final minutes = duration.inMinutes + .remainder(60) + .toString() + .padLeft(2, "0"); + final seconds = duration.inSeconds + .remainder(60) + .toString() + .padLeft(2, "0"); + + return "$minutes:$seconds"; + } + + return SizedBox( + height: 60, + child: Card( + color: Theme.of(context).colorScheme.surfaceContainer, + child: Padding( + padding: .only(left: 8, right: 16), + child: Row( + children: [ + 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), + style: Theme.of(context).textTheme.bodySmall, + ), + Expanded( + child: Slider( + min: 0, + max: duration.value.inMilliseconds <= 0 + ? 1 + : duration.value.inMilliseconds.toDouble(), + value: position.value.inMilliseconds.toDouble(), + onChanged: (value) => + player.seek(.new(milliseconds: value.toInt())), + ), + ), + Text( + format(duration.value), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/players/video.dart b/lib/widgets/players/video.dart new file mode 100644 index 0000000..024c30e --- /dev/null +++ b/lib/widgets/players/video.dart @@ -0,0 +1,38 @@ +import "dart:async"; + +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"; + +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( + () => Player(configuration: .new(bufferSize: 128 * 1024 * 1024)), + ); + final controller = useMemoized(() => VideoController(player)); + + useEffect(() { + 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; + }, []); + + return SizedBox(height: 300, child: Video(controller: controller)); + } +} diff --git a/lib/widgets/reaction_row.dart b/lib/widgets/reaction_row.dart new file mode 100644 index 0000000..195aa4f --- /dev/null +++ b/lib/widgets/reaction_row.dart @@ -0,0 +1,106 @@ +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"; +import "package:nexus/controllers/reactions.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/helpers/mxc_image.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/main.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; + +class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final clientState = ref.watch(ClientStateController.provider); + + return Padding( + padding: .only(top: 4), + child: switch (ref.watch( + ReactionsController.provider( + .new(roomId: event.roomId, eventRowId: event.rowId), + ), + )) { + AsyncData(value: final IMap>? reactors) || + AsyncLoading(value: final reactors) => Wrap( + spacing: 4, + runSpacing: 4, + children: event.reactions + .where((_, value) => value != 0) + .mapTo( + (reaction, count) => HookBuilder( + builder: (context) { + final enabled = useState(true); + + final selected = + reactors?[reaction]?.contains(clientState!.userId) ?? + false; + return Tooltip( + message: reactors?[reaction]?.join(", ") ?? "", + child: ChoiceChip( + showCheckmark: false, + selected: selected, + label: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + Flexible( + child: reaction.startsWith("mxc://") + ? Image( + height: 20, + image: MxcImage( + ref, + .new(mxc: Uri.parse(reaction)), + ), + ) + : Text(reaction, overflow: .ellipsis), + ), + Text(count.toString(), overflow: .ellipsis), + ], + ), + onSelected: enabled.value + ? (value) async { + enabled.value = false; + try { + final controller = ref.watch( + RoomChatController.provider(( + event.roomId, + null, + )).notifier, + ); + + if (selected) { + await controller + .removeReaction( + reaction, + event, + clientState!.userId!, + ) + .onError(showError); + } else { + await controller + .sendReaction(reaction, event) + .onError(showError); + } + } finally { + enabled.value = true; + } + } + : null, + ), + ); + }, + ), + ) + .toList(), + ), + + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + }, + ); + } +} diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart new file mode 100644 index 0000000..697161f --- /dev/null +++ b/lib/widgets/renderers/event.dart @@ -0,0 +1,237 @@ +import "package:fast_immutable_collections/fast_immutable_collections.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/helpers/extensions/show_context_menu.dart"; +import "package:nexus/models/content/avatar.dart"; +import "package:nexus/models/content/canonical_alias.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/create.dart"; +import "package:nexus/models/content/encrypted.dart"; +import "package:nexus/models/content/history_visibility.dart"; +import "package:nexus/models/content/join_rules.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/content/pinned_events.dart"; +import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/content/server_acl.dart"; +import "package:nexus/models/content/sticker.dart"; +import "package:nexus/models/content/topic.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/lazy_loading/message_displayname.dart"; +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 const EventRenderer( + final Event event, { + final bool textOnly = false, + final bool isGrouped = false, + final int? maxLines, + final VoidCallback? onTapReply, + final IList Function(Event event)? getEventOptions, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final errorStyle = TextStyle(color: colorScheme.error); + final focusNode = useFocusNode(); + useListenable(focusNode); + + final child = event.redactedBy != null || event.relationType == "m.replace" + ? null + : switch (event.content) { + Content(:final parseError?) => Row( + children: [ + ErrorDialog( + "An error occurred while parsing event ${event.eventId}:\n$parseError", + parseError.stackTrace, + ), + ], + ), + + MessageContent() || + EncryptedContent() || + StickerContent() => MessageRenderer( + event, + onTapReply: onTapReply, + isGrouped: isGrouped, + maxLines: maxLines, + textOnly: textOnly, + ), + + MembershipContent content => switch (event.previousContent) { + MembershipContent(:final status) => + status == content.status ? null : MembershipRenderer(event), + _ => MembershipRenderer(event), + }, + + AvatarContent() => GenericEventRenderer(Icons.interests, [ + MessageDisplayname(event), + Text("changed the room avatar"), + ]), + + CreateContent() => GenericEventRenderer(Icons.add, [ + MessageDisplayname(event), + Text("created the room"), + ]), + + PowerLevelsContent() => GenericEventRenderer(Icons.power, [ + MessageDisplayname(event), + Text("changed the room's power levels"), + ]), + + JoinRulesContent() => GenericEventRenderer(Icons.rule, [ + MessageDisplayname(event), + Text("changed the room's join rules"), + ]), + + TopicContent() => GenericEventRenderer(Icons.description, [ + MessageDisplayname(event), + 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)", + }}", + ), + ]), + + PinnedEventsContent() => GenericEventRenderer(Icons.push_pin, [ + MessageDisplayname(event), + Text("pinned/unpinned some events"), + ]), + + ServerACLContent() => GenericEventRenderer(Icons.list, [ + MessageDisplayname(event), + 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", + + 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 + : (PositionedGestureDetails details) => context.showContextMenu( + globalPosition: details.globalPosition, + children: getEventOptions!(event).toList(), + ); + + return Column( + crossAxisAlignment: .start, + children: [ + if (child != null) ...[ + if (textOnly) + child + else ...[ + Builder( + builder: (context) => FocusableActionDetector( + focusNode: focusNode, + actions: contextMenuCallback == null + ? null + : { + ActivateIntent: CallbackAction( + onInvoke: (_) { + final renderBox = + context.findRenderObject() as RenderBox; + final topLeft = renderBox.localToGlobal( + Offset.zero, + ); + context.showContextMenu( + globalPosition: topLeft, + children: getEventOptions!(event).toList(), + ); + return null; + }, + ), + }, + child: Container( + decoration: BoxDecoration( + color: focusNode.hasPrimaryFocus + ? theme.colorScheme.surfaceContainerHighest + : null, + ), + child: GestureDetector( + onSecondaryTapUp: contextMenuCallback, + onLongPressStart: contextMenuCallback, + child: Padding( + 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), + ], + ), + ), + ), + ), + ), + ), + + ...[ + if (child is! MessageRenderer) ReactionRow(event), + + if (event.sendError != null && event.sendError != "not sent") + Padding( + padding: .only(bottom: 4), + child: Text( + event.sendError!, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ), + ].map((child) => Padding(padding: .only(left: 4), child: child)), + ], + ] 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 new file mode 100644 index 0000000..eb5b29b --- /dev/null +++ b/lib/widgets/renderers/generic_event.dart @@ -0,0 +1,20 @@ +import "package:material_ui/material_ui.dart"; + +class const GenericEventRenderer( + final IconData icon, + final List children, { + 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)), + Flexible(child: Wrap(spacing: 4, children: children)), + ], + ), + ); +} diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart new file mode 100644 index 0000000..5ce5d68 --- /dev/null +++ b/lib/widgets/renderers/membership.dart @@ -0,0 +1,51 @@ +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"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/lazy_loading/message_displayname.dart"; +import "package:nexus/widgets/renderers/generic_event.dart"; + +class const MembershipRenderer(final Event event, {super.key}) + extends StatelessWidget { + @override + Widget build(BuildContext context) { + assert( + event.content is MembershipContent, + "Make sure to only pass membership events to MembershipRenderer", + ); + + return switch (event.content) { + MembershipContent content => GenericEventRenderer(Icons.people, [ + 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), + ), + ), + 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}\""), + ]), + _ => SizedBox.shrink(), + }; + } +} diff --git a/lib/widgets/renderers/message.dart b/lib/widgets/renderers/message.dart new file mode 100644 index 0000000..a0bbf99 --- /dev/null +++ b/lib/widgets/renderers/message.dart @@ -0,0 +1,240 @@ +import "package:collection/collection.dart"; +import "package:flutter/rendering.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/models/content/encrypted.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/content/sticker.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/file_card.dart"; +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/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:nexus/widgets/event_preview.dart"; +import "package:nexus/widgets/players/video.dart"; +import "package:nexus/widgets/players/audio.dart"; + +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 textStyle = TextStyle( + fontSize: event.localContent?.bigEmoji == true ? 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) => + 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, :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), + ], + ], + ), + MessageContent(: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, + children: [ + if (!textOnly) + if (isGrouped) + SizedBox(width: 40) + else + MessageAvatar(event, height: 40), + Flexible( + child: Column( + spacing: 4, + crossAxisAlignment: .start, + children: [ + if (!isGrouped && !textOnly) + Row( + spacing: 4, + children: [ + Flexible(child: MessageDisplayname(event)), + Flexible(flex: 0, child: Timestamp(event.timestamp)), + ], + ), + Card( + margin: textOnly ? .zero : .only(bottom: 4), + color: textOnly + ? Colors.transparent + : ref.watch( + ClientStateController.provider.select( + (value) => value?.userId, + ), + ) == + event.sender + ? (event.eventId.startsWith("~") + ? colorScheme.onPrimary + : colorScheme.primaryContainer) + : colorScheme.surfaceContainer, + elevation: textOnly ? 0 : null, + + child: Padding( + padding: textOnly ? .zero : .all(12), + child: Column( + crossAxisAlignment: .start, + children: [ + if (!textOnly && event.replyTo != null) + Card( + margin: .only(bottom: 8), + color: theme.colorScheme.surfaceContainerHigh, + child: InkWell( + onTap: onTapReply, + child: Padding( + padding: .symmetric(vertical: 8, horizontal: 12), + 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..."), + }, + ), + ), + ), + + RendererBinding.instance.mouseTracker.mouseIsConnected + ? SelectableRegion( + selectionControls: materialTextSelectionControls, + child: rendered, + ) + : rendered, + if (!textOnly) ReactionRow(event), + ], + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/widgets/room_appbar.dart b/lib/widgets/room_appbar.dart new file mode 100644 index 0000000..305c490 --- /dev/null +++ b/lib/widgets/room_appbar.dart @@ -0,0 +1,135 @@ +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"; + +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; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final room = roomId == null + ? null + : ref.watch(RoomsController.provider.select((value) => value[roomId!])); + + return Appbar( + onTap: room == null + ? null + : () => showDialog( + context: context, + builder: (context) => Dialog( + constraints: .loose(.fromWidth(400)), + child: Padding( + padding: .all(24), + child: SingleChildScrollView( + 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, + ), + ), + ], + ), + ), + ), + ), + ), + leading: isDesktop + ? room == null + ? null + : AvatarOrHash( + room.metadata?.avatar, + room.metadata?.name ?? "Unnamed Room", + height: 24, + fallback: Icon(Icons.numbers), + ) + : DrawerButton(onPressed: onOpenDrawer), + scrolledUnderElevation: 0, + title: room == null + ? null + : Column( + crossAxisAlignment: .start, + children: [ + Text( + room.metadata?.name ?? "Unnamed Room", + overflow: .ellipsis, + maxLines: 1, + ), + if (room.metadata?.topic?.isNotEmpty == true) + Text( + room.metadata!.topic!, + maxLines: 1, + overflow: .ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + 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), + ]), + ); + } +} diff --git a/lib/widgets/room_chat/chat_timeline.dart b/lib/widgets/room_chat/chat_timeline.dart new file mode 100644 index 0000000..2cab4ca --- /dev/null +++ b/lib/widgets/room_chat/chat_timeline.dart @@ -0,0 +1,80 @@ +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/content/message.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 ChatScroll scroll, + required final Future Function(String) jumpToId, + required final IList Function(Event) getEventOptions, + required final String? highlightedEvent, + required final double composerHeight, + super.key, +}) extends StatelessWidget { + bool isGrouped(Event event, Event? previousEvent) => + previousEvent?.content is MessageContent && + previousEvent?.redactedBy == null && + previousEvent?.relationType != "m.replace" && + event.sender == previousEvent?.sender && + event.pmp?.id == previousEvent?.pmp?.id; + + Widget eventRow( + Event event, + Event? previousEvent, { + required Future Function(String) jumpToId, + required IList Function(Event) getEventOptions, + required String? highlightedEvent, + required Key key, + }) => HighlightWrapper( + EventRenderer( + event, + onTapReply: () => jumpToId(event.replyTo!), + getEventOptions: getEventOptions, + isGrouped: isGrouped(event, previousEvent), + ), + key: key, + isHighlighted: highlightedEvent == event.eventId, + ); + + @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.liveItems.length, + itemBuilder: (_, index) => eventRow( + scroll.liveItems[index], + index > 0 + ? scroll.liveItems.getOrNull(index - 1) + : scroll.historyItems.firstOrNull, + jumpToId: jumpToId, + getEventOptions: getEventOptions, + highlightedEvent: highlightedEvent, + key: scroll.keyFor(scroll.liveItems[index].eventId), + ), + ), + + SuperSliverList.builder( + key: scroll.centerKey, + itemCount: scroll.historyItems.length, + itemBuilder: (_, index) => eventRow( + scroll.historyItems[index], + scroll.historyItems.getOrNull(index + 1), + jumpToId: jumpToId, + getEventOptions: getEventOptions, + highlightedEvent: highlightedEvent, + key: scroll.keyFor(scroll.historyItems[index].eventId), + ), + ), + ], + ); +} diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart new file mode 100644 index 0000000..709d22d --- /dev/null +++ b/lib/widgets/room_chat/room_chat.dart @@ -0,0 +1,231 @@ +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/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"; + +final class const RoomChat({ + required final String? roomId, + required final bool isDesktop, + required final bool showMembersByDefault, + final String? initialHighlightedEvent, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final relatedEvent = useState(null); + final relationType = useState(RelationType.reply); + final contextualEvent = useState(initialHighlightedEvent); + 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 controllerProvider = RoomChatController.provider(( + roomId, + 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 composerNode = useFocusNode( + onKeyEvent: (_, event) { + if (event is KeyDownEvent && event.logicalKey == .escape) { + relatedEvent.value = null; + return .handled; + } + + return .ignored; + }, + ); + + Future jumpToId(String eventId) async { + highlightedEvent.value = eventId; + + await scroll.jumpToId(eventId); + await Future.delayed(.new(milliseconds: 700), () { + if (highlightedEvent.value == eventId) highlightedEvent.value = null; + }); + } + + useEffect(() { + if (initialHighlightedEvent == null) return null; + + void check() { + if (!context.mounted) return; + + if (scroll.scrollController.hasClients) { + jumpToId(initialHighlightedEvent!); + } else { + WidgetsBinding.instance.addPostFrameCallback((_) => check()); + } + } + + check(); + + return null; + }, [initialHighlightedEvent]); + + IList getEventOptions(Event event) => + event.buildEventOptions( + context: context, + ref: ref, + roomId: roomId, + userId: userId, + onRelation: (event, type) { + relatedEvent.value = event; + relationType.value = type; + composerNode.requestFocus(); + }, + ); + + 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) { + 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: ChatTimeline( + scroll: scroll, + jumpToId: jumpToId, + getEventOptions: getEventOptions, + highlightedEvent: highlightedEvent.value, + composerHeight: composerSize.value, + ), + ), + ), + 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, + relation: relatedEvent.value, + ) + .onError(showError), + relationType: relationType.value, + relatedEvent: relatedEvent.value, + onDismiss: () => relatedEvent.value = null, + ), + ), + ), + ], + ), + ), + + if (memberListOpened == true && showMembersByDefault) + MemberList(roomId), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/chat_page/room_menu.dart b/lib/widgets/room_menu.dart similarity index 54% rename from lib/widgets/chat_page/room_menu.dart rename to lib/widgets/room_menu.dart index 2687bc8..7488ada 100644 --- a/lib/widgets/chat_page/room_menu.dart +++ b/lib/widgets/room_menu.dart @@ -1,31 +1,26 @@ 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_controller.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/via.dart"; import "package:nexus/models/room.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 RoomMenu( + 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: (_) => [ - // PopupMenuItem( - // onTap: () async { - // final link = await room.matrixToInviteLink(); - // await Clipboard.setData(ClipboardData(text: link.toString())); - // }, - // child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")), - // ), PopupMenuItem( onTap: () async { - await client.markRead(room); + if (room != null) await client.markRead(room!); await Future.wait(children.map((child) => client.markRead(child))); }, child: ListTile( @@ -33,41 +28,60 @@ class RoomMenu extends ConsumerWidget { title: Text("Mark as Read"), ), ), - PopupMenuItem( - onTap: () => showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text("Leave Room"), - content: Text( - "Are you sure you want to leave \"${room.metadata?.name ?? "Unnamed Room"}\"?", - ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text("Cancel"), + if (room != null) ...[ + PopupMenuItem( + onTap: () async { + final vias = ref.watch(ViaController.provider(room!)); + + await Clipboard.setData( + .new( + text: "matrix:roomid/${room!.metadata?.id.substring(1)}$vias", ), - TextButton( - onPressed: () async { - Navigator.of(context).pop(); - final snackbar = ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text("Leaving room..."), - duration: Duration(days: 1), - ), - ); - await client.leaveRoom(room); - snackbar.close(); - }, - child: Text("Leave"), - ), - ], + ); + }, + child: ListTile( + leading: Icon(Icons.link), + title: Text("Copy Link"), ), ), - child: ListTile( - leading: Icon(Icons.logout, color: danger), - title: Text("Leave", style: TextStyle(color: danger)), + PopupMenuItem( + onTap: () => showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text("Leave Room"), + content: Text( + "Are you sure you want to leave \"${room!.metadata?.name ?? "Unnamed Room"}\"?", + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + final snackbar = ScaffoldMessenger.of(context) + .showSnackBar( + .new( + content: Text("Leaving room..."), + duration: Duration(days: 1), + ), + ); + await client.leaveRoom(room!); + snackbar.close(); + }, + child: Text("Leave"), + ), + ], + ), + ), + child: ListTile( + leading: Icon(Icons.logout, color: danger), + title: Text("Leave", style: TextStyle(color: danger)), + ), ), - ), + ], + // PopupMenuItem( // onTap: () => showDialog( // context: context, diff --git a/lib/widgets/settings/dialog_list_tile.dart b/lib/widgets/settings/dialog_list_tile.dart new file mode 100644 index 0000000..e66e335 --- /dev/null +++ b/lib/widgets/settings/dialog_list_tile.dart @@ -0,0 +1,57 @@ +import "package:material_ui/material_ui.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/widgets/settings/radio_dialog.dart"; + +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) => + value == null && required == true ? "This field is required." : null, + initialValue: initialValue, + builder: (field) => InputDecorator( + decoration: InputDecoration( + errorText: field.errorText, + contentPadding: EdgeInsets.zero, + enabledBorder: InputBorder.none, + ), + child: ListTile( + enabled: onChanged != null, + onTap: () => showDialog( + context: context, + builder: (context) => RadioDialog( + title: title, + getName: getName, + onChanged: onChanged == null + ? null + : (value) { + field.didChange(value); + onChanged!.call(value); + }, + value: field.value, + options: options, + ), + ), + title: Text(title), + subtitle: subtitle, + leading: icon, + trailing: Chip( + label: Text( + field.value == null ? "None" : getName(field.value as T), + overflow: TextOverflow.ellipsis, + style: onChanged == null ? .new(color: Colors.grey) : null, + ), + ), + ), + ), + ); +} diff --git a/lib/widgets/settings/radio_dialog.dart b/lib/widgets/settings/radio_dialog.dart new file mode 100644 index 0000000..6696139 --- /dev/null +++ b/lib/widgets/settings/radio_dialog.dart @@ -0,0 +1,48 @@ +import "package:material_ui/material_ui.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; + +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); + return AlertDialog( + title: Text(title), + content: RadioGroup( + groupValue: mutValue.value ?? value, + onChanged: (value) => mutValue.value = value ?? mutValue.value, + + child: Column( + mainAxisSize: MainAxisSize.min, + children: options + .map( + (option) => RadioListTile( + enabled: onChanged != null, + value: option, + title: Text(getName(option)), + dense: true, + ), + ) + .toList(), + ), + ), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + if (onChanged != null) + TextButton( + onPressed: () { + if (mutValue.value != null) onChanged!(mutValue.value as T); + Navigator.of(context).pop(); + }, + child: Text("OK"), + ), + ], + ); + } +} diff --git a/lib/widgets/sidebar.dart b/lib/widgets/sidebar.dart new file mode 100644 index 0000000..cc6d441 --- /dev/null +++ b/lib/widgets/sidebar.dart @@ -0,0 +1,324 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.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/widgets/pages/notifications.dart"; +import "package:nexus/widgets/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"; + +// Needed for navigation_rail_m3e (#65). +import "package:flutter/material.dart" as old_mat; + +class const Sidebar({required final bool isDesktop, super.key}) + extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedSpaceProvider = KeyController.provider( + KeyController.spaceKey, + ); + final selectedSpaceId = ref.watch(selectedSpaceProvider).requireValue; + final selectedSpaceIdNotifier = ref.watch(selectedSpaceProvider.notifier); + + final selectedRoomController = KeyController.provider( + KeyController.roomKey, + ); + final selectedRoomId = ref.watch(selectedRoomController).requireValue; + 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(); + + return Drawer( + width: 330, + shape: Border(), + child: Row( + children: [ + 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: 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 notifications", + onPressed: () { + if (!isDesktop) Navigator.of(context).pop(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => NotificationsPage(), + ), + ); + }, + icon: Icon(Icons.notifications), + ), + 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: 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: 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(); + }, + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} 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 new file mode 100644 index 0000000..5eda427 --- /dev/null +++ b/lib/widgets/url_preview.dart @@ -0,0 +1,65 @@ +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 const UrlPreview(final Uri link, {super.key}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => ConstrainedBox( + constraints: .loose(.fromWidth(400)), + child: ref + .watch(UrlPreviewController.provider(link)) + .betterWhen( + data: (preview) => preview == null + ? SizedBox.shrink() + : InkWell( + onTap: () => ref.watch(LaunchHelper.provider).launchUrl(link), + child: Card( + margin: .symmetric(vertical: 4), + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + child: Padding( + padding: .all(16), + child: Column( + crossAxisAlignment: .start, + spacing: 4, + children: [ + if (preview.title != null) + Text( + preview.title!, + style: Theme.of(context).textTheme.titleLarge, + maxLines: 3, + overflow: .ellipsis, + ), + if (preview.description != null) ...[ + Text( + preview.description!, + maxLines: 20, + overflow: .ellipsis, + ), + SizedBox(height: 4), + ], + if (preview.imageUrl != null) + ClipRRect( + borderRadius: .all(.circular(8)), + child: Image( + errorBuilder: (_, _, _) => SizedBox.shrink(), + width: preview.width, + image: MxcImage( + ref, + .new(mxc: preview.imageUrl!), + ), + fit: .fitWidth, + ), + ), + ], + ), + ), + ), + ), + ), + ); +} diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart new file mode 100644 index 0000000..9fbdf35 --- /dev/null +++ b/lib/widgets/user_bottom_sheet.dart @@ -0,0 +1,255 @@ +import "package:collection/collection.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"; +import "package:m3e_buttons/m3e_buttons.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/power_level.dart"; +import "package:nexus/controllers/profile.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/models/membership_action.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/main.dart"; +import "package:nexus/widgets/expandable_image.dart"; + +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.read(ClientController.provider.notifier); + + void showMembershipDialog(MembershipAction action) => showDialog( + context: context, + builder: (context) => HookBuilder( + builder: (context) { + final actionReasonController = useTextEditingController(); + return AlertDialog( + title: Text("${toBeginningOfSentenceCase(action.name)} $userId"), + content: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Text("Are you sure you want to ${action.name} $userId?"), + SizedBox(height: 12), + TextField( + textCapitalization: .sentences, + controller: actionReasonController, + decoration: .new( + labelText: "Reason for ${action.name} (optional)", + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + client + .setMembership( + .new( + userId: userId, + roomId: roomId!, + action: action, + reason: actionReasonController.text, + ), + ) + .onError(showError); + }, + child: Text(toBeginningOfSentenceCase(action.name)), + ), + ], + ); + }, + ), + ); + + 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: (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 f70fb6e..70d9ba2 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,30 +6,42 @@ #include "generated_plugin_registrant.h" -#include +#include #include +#include +#include +#include #include #include +#include #include -#include void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) dynamic_system_colors_registrar = + g_autoptr(FlPluginRegistrar) dynamic_color_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin"); - dynamic_color_plugin_register_with_registrar(dynamic_system_colors_registrar); + dynamic_color_plugin_register_with_registrar(dynamic_color_registrar); g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) gtk_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); + gtk_plugin_register_with_registrar(gtk_registrar); + g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); + media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); + g_autoptr(FlPluginRegistrar) media_kit_video_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin"); + media_kit_video_plugin_register_with_registrar(media_kit_video_registrar); g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + 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); - g_autoptr(FlPluginRegistrar) window_size_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "WindowSizePlugin"); - window_size_plugin_register_with_registrar(window_size_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 78dcf40..1d53ad7 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,12 +3,15 @@ # list(APPEND FLUTTER_PLUGIN_LIST - dynamic_system_colors + dynamic_color file_selector_linux + gtk + media_kit_libs_linux + media_kit_video screen_retriever_linux url_launcher_linux + webcrypto window_manager - window_size ) list(APPEND FLUTTER_FFI_PLUGIN_LIST 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 new file mode 100644 index 0000000..64b4264 --- /dev/null +++ b/linux/nexus.federated.nexus.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Name=Nexus +GenericName=Matrix Client +Comment=A simple and user-friendly Matrix client +Exec=nexus +Icon=nexus +Terminal=false +Type=Application +Categories=Chat;Network;InstantMessaging; +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..756eb0b --- /dev/null +++ b/linux/nix/default.nix @@ -0,0 +1,59 @@ +{ 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; + }; + 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" + ]; + + 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 new file mode 100644 index 0000000..8bc8641 --- /dev/null +++ b/linux/nix/devshell.nix @@ -0,0 +1,50 @@ +{ pkgs, lib }: +let + android = pkgs.androidenv.composeAndroidPackages { + toolsVersion = "26.1.1"; + platformToolsVersion = "37.0.1"; + buildToolsVersions = [ + "35.0.0" + "36.0.0" + ]; + cmakeVersions = [ "3.22.1" ]; + platformVersions = [ "36" ]; + abiVersions = [ + "armeabi-v7a" + "arm64-v8a" + ]; + includeNDK = true; + ndkVersions = [ "28.2.13676358" ]; + }; +in +pkgs.mkShell { + 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 ]; + + ANDROID_HOME = "${android.androidsdk}/libexec/android-sdk"; + ANDROID_SDK_ROOT = ANDROID_HOME; + JAVA_HOME = pkgs.jdk21; + + 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 new file mode 100644 index 0000000..3e55dfe --- /dev/null +++ b/linux/nix/pkg/default.nix @@ -0,0 +1,54 @@ +{ + lib, + callPackage, + mpv-unwrapped, + libass, + libclang, + flutter, + src, +}: + +flutter.buildFlutterApplication { + pname = "nexus"; + version = "0.1.0"; + inherit src; + + preBuild = '' + cp ${callPackage ./gomuks.nix { inherit src; }}/lib/* . + packageRunCustom nexus generate source/scripts test + packageRun build_runner build + ''; + + buildInputs = [ + mpv-unwrapped + libass + ]; + + env.LIBCLANG_PATH = lib.makeLibraryPath [ libclang ]; + + autoPubspecLock = src + "/pubspec.lock"; + + gitHashes = { + 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-As1X1/IfCbSD5ful2kAxgBita95KxmInRN4/6QmVaTI="; + }; + + postInstall = '' + 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 + ''; + + meta = { + description = "A simple and user-friendly Matrix client"; + mainProgram = "nexus"; + platforms = lib.platforms.unix; + maintainers = with lib.maintainers; [ quadradical ]; + }; +} diff --git a/linux/nix/pkg/gomuks.nix b/linux/nix/pkg/gomuks.nix new file mode 100644 index 0000000..64e0faa --- /dev/null +++ b/linux/nix/pkg/gomuks.nix @@ -0,0 +1,38 @@ +{ + src, + stdenv, + buildGoModule, +}: + +buildGoModule ( + finalAttrs: + let + filename = "libgomuks${stdenv.hostPlatform.extensions.sharedLibrary}"; + in + { + pname = "gomuks-ffi"; + version = "submodule"; + + doCheck = false; + + src = "${src}/gomuks"; + + vendorHash = "sha256-rLLDbNrYf5HqG3Y6zihRi1v+GqGeNAtZUL+8QHdCz7w="; + + buildPhase = '' + runHook preBuild + + go build -buildmode=c-shared -o ${filename} -tags goolm,noheic,sqlite_fts5 ./pkg/ffi + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + install -Dm0644 ${filename} -t $out/lib + + runHook postInstall + ''; + } +) diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 58cd859..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,136 +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); + + 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); + 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 TRUE; + 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_NON_UNIQUE, - 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/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +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 new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +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 new file mode 100644 index 0000000..8b6bbb6 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,36 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +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 +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")) + ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) + 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 new file mode 100644 index 0000000..fa18d44 --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,825 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 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 */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* 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; }; + 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 = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 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 */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 34AEA7295333B7F48CD3BE4C /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + BAF79690465DBAC1A1E19D28 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + FC780AF47288088B9004ACE2 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* nexus.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + 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 */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + C413A8A75594D2165C9D56BD /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + 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 = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* nexus.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + 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 */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 18EDDEF1FB58EC56ABDA1BEB /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + 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)/nexus.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/nexus"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 8F5ECEC7BF9DD35CB3E7196E /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + 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)/nexus.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/nexus"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5099E6191AE87E21D356D9C8 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + 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)/nexus.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/nexus"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* 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 = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..746b383 --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..96d3fee --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "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 new file mode 100644 index 0000000..1682af0 Binary files /dev/null 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 new file mode 100644 index 0000000..c2e488b Binary files /dev/null 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 new file mode 100644 index 0000000..e2bf256 Binary files /dev/null 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 new file mode 100644 index 0000000..983d7fa Binary files /dev/null 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 new file mode 100644 index 0000000..c8f0676 Binary files /dev/null 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 new file mode 100644 index 0000000..468be1f Binary files /dev/null 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 new file mode 100644 index 0000000..a77207d Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..b4c5133 --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = nexus + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 nexus.federated. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..4e593b0 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.files.user-selected.read-only + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..36999ec --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + CFBundleURLTypes + + + CFBundleURLName + + auth_callback + CFBundleURLSchemes + + + nexus.federated.nexus + + + + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..741903e --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-only + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/nix/android.nix b/nix/android.nix deleted file mode 100644 index f373968..0000000 --- a/nix/android.nix +++ /dev/null @@ -1,20 +0,0 @@ -{ - androidenv, -}: -androidenv.composeAndroidPackages { - toolsVersion = "26.1.1"; - platformToolsVersion = "36.0.1"; - buildToolsVersions = [ - "35.0.0" - "36.0.0" - ]; - cmakeVersions = [ "3.22.1" ]; - platformVersions = [ "36" ]; - abiVersions = [ - "armeabi-v7a" - "arm64-v8a" - ]; - includeNDK = true; - ndkVersions = [ "27.0.12077973" ]; - -} diff --git a/pubspec.lock b/pubspec.lock index da5de89..51e0bd6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,50 +5,82 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + sha256: fdcd9f70f9eb80df3bc5ed0fa67280df2595fd481948df8a9ab082e6a40ad04b url: "https://pub.dev" source: hosted - version: "91.0.0" + version: "108.0.0" analysis_server_plugin: dependency: transitive description: name: analysis_server_plugin - sha256: "44adba4d74a2541173bad4c11531d2a4d22810c29c5ddb458a38e9f4d0e5eac7" + sha256: "3460f040e1f77647fcd82db9bd3bb117de04d04a3d67c39ad361794331147a4e" url: "https://pub.dev" source: hosted - version: "0.3.4" + version: "0.3.23" analyzer: - dependency: "direct overridden" + dependency: transitive description: name: analyzer - sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + sha256: a51c769bff3b6dfbe9d60199b8606d808290702a296bef0c26a4ca391d414e46 url: "https://pub.dev" source: hosted - version: "8.4.1" + version: "14.4.0" analyzer_buffer: dependency: transitive description: name: analyzer_buffer - sha256: aba2f75e63b3135fd1efaa8b6abefe1aa6e41b6bd9806221620fa48f98156033 + sha256: f890a2e708e38fbf4238ad2f9fd3d506538acdddb0cd8eb2c3e8c7bd17784b57 url: "https://pub.dev" source: hosted - version: "0.1.11" + version: "0.3.4" analyzer_plugin: dependency: transitive description: name: analyzer_plugin - sha256: "6645a029da947ffd823d98118f385d4bd26b54eb069c006b22e0b94e451814b5" + sha256: "9a3518eb84e8e26665874d9867534961d855493e9c5eb0eb55cbd46edd995529" url: "https://pub.dev" source: hosted - version: "0.13.11" + version: "0.14.17" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: f8db46d2ea9ff6f3a37191a7fd5b7813da1253ace8c32c1b9eadace41d1188ea + url: "https://pub.dev" + source: hosted + version: "7.2.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" archive: dependency: transitive description: name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4" url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "4.3.0" args: dependency: transitive description: @@ -69,18 +101,10 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" - blurhash_dart: - dependency: transitive - description: - name: blurhash_dart - sha256: "43955b6c2e30a7d440028d1af0fa185852f3534b795cc6eb81fbf397b464409f" - url: "https://pub.dev" - source: hosted - version: "1.2.1" + version: "2.13.1" boolean_selector: dependency: transitive description: @@ -93,34 +117,34 @@ packages: dependency: transitive description: name: build - sha256: c1668065e9ba04752570ad7e038288559d1e2ca5c6d0131c0f5f55e39e777413 + sha256: "8a5c5761af8e31748bba3c82f68925ace40f3225c3eea25be9beb57eca7cd7a8" url: "https://pub.dev" source: hosted - version: "4.0.3" + version: "4.0.11" build_config: dependency: transitive description: name: build_config - sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + sha256: d466ed2dc9c6cd1d169948879b84ee061eb5e22c64a7c6089879c6296d272a8d url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.3" build_daemon: dependency: transitive description: name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + sha256: e1d40ef3f7934986d5da2271b1ba07794921ce263e44d622fb6c406d76589e33 url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.1.6" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "110c56ef29b5eb367b4d17fc79375fa8c18a6cd7acd92c05bb3986c17a079057" + sha256: "894c243f6bc32015fec466ce30a6925bd537a77a426ee5bf481120477eb3de67" url: "https://pub.dev" source: hosted - version: "2.10.4" + version: "2.16.1" built_collection: dependency: transitive description: @@ -133,26 +157,26 @@ packages: dependency: transitive description: name: built_value - sha256: "426cf75afdb23aa74bd4e471704de3f9393f3c7b04c1e2d9c6f1073ae0b8b139" + sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7 url: "https://pub.dev" source: hosted - version: "8.12.1" + version: "8.13.0" + button_m3e: + dependency: transitive + description: + name: button_m3e + sha256: "6754ddeb9068ad2005bd26d5ceabc41268029465095686d7d228296c2e706909" + url: "https://pub.dev" + source: hosted + version: "0.1.2" characters: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" - charcode: - dependency: transitive - description: - name: charcode - sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a - url: "https://pub.dev" - source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -161,22 +185,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" - ci: - dependency: transitive - description: - name: ci - sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" - url: "https://pub.dev" - source: hosted - version: "0.1.0" - 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: @@ -189,26 +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: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57" url: "https://pub.dev" source: hosted - version: "1.0.0" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" - url: "https://pub.dev" - source: hosted - version: "4.11.1" + version: "2.1.0" collection: dependency: "direct main" description: @@ -233,30 +233,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" - coverage: - dependency: transitive - description: - name: coverage - sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" - url: "https://pub.dev" - source: hosted - version: "1.15.0" - cross_cache: - dependency: "direct main" - description: - name: cross_cache - sha256: "4983a16603cc99b0a14de6a772fa8ee4533411f46f3c423f1386fea7566049c5" - url: "https://pub.dev" - source: hosted - version: "1.1.0" cross_file: dependency: transitive description: name: cross_file - sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 url: "https://pub.dev" source: hosted - version: "0.3.5+1" + version: "0.3.5+5" crypto: dependency: transitive description: @@ -273,86 +257,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" - custom_lint: - dependency: "direct dev" - description: - name: custom_lint - sha256: "751ee9440920f808266c3ec2553420dea56d3c7837dd2d62af76b11be3fcece5" - url: "https://pub.dev" - source: hosted - version: "0.8.1" - custom_lint_core: + cupertino_ui: dependency: transitive description: - name: custom_lint_core - sha256: "85b339346154d5646952d44d682965dfe9e12cae5febd706f0db3aa5010d6423" + name: cupertino_ui + sha256: c23816577a1280cff409e989541767887011f8f1d8c1863a794ed308af46f2a9 url: "https://pub.dev" source: hosted - version: "0.8.1" - custom_lint_visitor: - dependency: transitive - description: - name: custom_lint_visitor - sha256: e466d17856197cf9bce7ca03804d784fddab809db7bda787f3d2799ac89faadd - url: "https://pub.dev" - source: hosted - version: "1.0.0+9.0.0" + version: "1.1.1" dart_style: dependency: transitive description: name: dart_style - sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + sha256: "82ade9fc4273f29ed673e33166944465225b4f7fc5d4aaef48605cc751c18fc1" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.13" dbus: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 url: "https://pub.dev" source: hosted - version: "0.7.11" - diffutil_dart: - dependency: transitive + version: "0.7.15" + dynamic_color: + dependency: "direct main" description: - name: diffutil_dart - sha256: "5e74883aedf87f3b703cb85e815bdc1ed9208b33501556e4a8a5572af9845c81" + name: dynamic_color + sha256: "869b3bce0100eb519768ecd71c0111f0b5ea679f5f62e1f59c49d80067e730c5" url: "https://pub.dev" source: hosted - version: "4.0.1" - dio: - dependency: transitive - description: - name: dio - sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 - url: "https://pub.dev" - source: hosted - version: "5.9.0" - dio_web_adapter: - dependency: transitive - description: - name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" - url: "https://pub.dev" - source: hosted - version: "2.1.1" + version: "2.1.0" dynamic_polls: dependency: "direct main" description: name: dynamic_polls - sha256: fba71ee6fb0ae8f3bebf7d07b3f2a79347d496956de88fb24d3daa32d47e0774 + sha256: "58cd1fafbbaba6b485a61692aa3bd33ded6f0f61734543b6c754511c362d8ae1" url: "https://pub.dev" source: hosted - version: "0.0.6" - dynamic_system_colors: - dependency: "direct main" - description: - name: dynamic_system_colors - sha256: "43794e658fa88cbdec9f397dd1afd2eb69b6c9717e99b93b16ba37c3aa3b3a8c" - url: "https://pub.dev" - source: hosted - version: "1.8.0" + version: "0.0.8" encrypt: dependency: transitive description: @@ -361,6 +305,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.3" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + fab_m3e: + dependency: transitive + description: + name: fab_m3e + sha256: e4f5abfa3c8c092005449d56dcac45b85e2dbe9c32789d672c5ed71428e43b59 + url: "https://pub.dev" + source: hosted + version: "0.1.1" fake_async: dependency: transitive description: @@ -373,26 +333,34 @@ packages: dependency: "direct main" description: name: fast_immutable_collections - sha256: "19f70498af299cbce5ff919dbbecd5abfd9d0c28139004f68d3810ce23dedfb3" + sha256: "58cec99fc068427c71901e82d4b31b232240ebe6e61200993c2cb91bcada0ff6" url: "https://pub.dev" source: hosted - version: "11.1.0" + version: "11.2.0" ffi: dependency: "direct main" description: name: ffi - sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" ffigen: 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: @@ -401,30 +369,46 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" - file_picker: + file_selector: dependency: "direct main" description: - name: file_picker - sha256: d974b6ba2606371ac71dd94254beefb6fa81185bde0b59bdc1df09885da85fde + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a url: "https://pub.dev" source: hosted - version: "10.3.8" + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c + url: "https://pub.dev" + source: hosted + version: "0.5.2+11" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c" + url: "https://pub.dev" + source: hosted + 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: @@ -433,14 +417,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" file_selector_windows: 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: @@ -454,23 +446,14 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_chat_core: + flutter_blurhash: dependency: "direct main" description: - name: flutter_chat_core - sha256: "8c46790f64f106bf6e610e2a7324b3844320e9e295867c06d45d9deb134d848d" + name: flutter_blurhash + sha256: e97b9aff13b9930bbaa74d0d899fec76e3f320aba3190322dcc5d32104e3d25d url: "https://pub.dev" source: hosted - version: "2.9.0" - flutter_chat_ui: - dependency: "direct main" - description: - path: "packages/flutter_chat_ui" - ref: HEAD - resolved-ref: "03be67c8c81c8f637672ee03dd8f082d2c223627" - url: "https://github.com/Henry-Hiles/flutter_chat_ui" - source: git - version: "2.11.1" + version: "0.9.1" flutter_hooks: dependency: "direct main" description: @@ -487,15 +470,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.14.4" - flutter_link_previewer: + flutter_linkify: dependency: "direct main" description: - path: "packages/flutter_link_previewer" - ref: HEAD - resolved-ref: "03be67c8c81c8f637672ee03dd8f082d2c223627" - url: "https://github.com/Henry-Hiles/flutter_chat_ui" - source: git - version: "4.2.0" + name: flutter_linkify + sha256: "74669e06a8f358fee4512b4320c0b80e51cffc496607931de68d28f099254073" + url: "https://pub.dev" + source: hosted + version: "6.0.0" flutter_lints: dependency: "direct dev" description: @@ -504,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 @@ -513,26 +535,26 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" url: "https://pub.dev" source: hosted - version: "2.0.33" + version: "2.0.35" flutter_riverpod: dependency: "direct main" description: name: flutter_riverpod - sha256: "38ec6c303e2c83ee84512f5fc2a82ae311531021938e63d7137eccc107bf3c02" + sha256: "2b7f9d2a3c730ac1a98221b420cef966b990fca7ab546ede324642ca57a533e3" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.4.3" flutter_svg: dependency: "direct main" description: name: flutter_svg - sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.3.0" flutter_test: dependency: transitive description: flutter @@ -547,50 +569,26 @@ packages: dependency: "direct main" description: name: flutter_widget_from_html_core - sha256: "1120ee6ed3509ceff2d55aa6c6cbc7b6b1291434422de2411b5a59364dd6ff03" + sha256: "530f2e3cc57be1a00f8bfae1b0000064e8a6df0cf1fb4d48a80f85afde9b39b7" url: "https://pub.dev" source: hosted - version: "0.17.0" + version: "0.17.4" fluttertagger: dependency: "direct main" description: name: fluttertagger - sha256: "3df0132bdd431a7279da78ea70500ea1e767fa093f43f32785b757c10c6a0fcc" + sha256: "04514674b41a063b97901aedf6970d0675b828bd723a0fb9f9dba89b91953382" url: "https://pub.dev" source: hosted - version: "2.3.1" - flyer_chat_file_message: - dependency: "direct main" - description: - name: flyer_chat_file_message - sha256: "96c5c25908cd671dda1963ade03e188e6a14bba6b116e73fac329f1abefc9ad1" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - flyer_chat_image_message: - dependency: "direct main" - description: - name: flyer_chat_image_message - sha256: "04730c9373c9c7315ba0e1a360c67ac5f6c7ec8a700ffe2d2dc00e29b7f8ff90" - url: "https://pub.dev" - source: hosted - version: "2.3.0" - flyer_chat_system_message: - dependency: "direct main" - description: - name: flyer_chat_system_message - sha256: d254f85be55949f8eb1a4a9a9b1c5b54ffed0c9a39dfa7e4fa6a6358bdb5d45a - url: "https://pub.dev" - source: hosted - version: "2.2.0" + version: "2.3.2" freezed: dependency: "direct dev" description: name: freezed - sha256: "03dd9b7423ff0e31b7e01b2204593e5e1ac5ee553b6ea9d8184dff4a26b9fb07" + sha256: "3af98c92d7d4dd182ea1393193e69a840fcbaee1f66f1158bd20504e5f3f6f4f" url: "https://pub.dev" source: hosted - version: "3.2.4" + version: "4.0.2" freezed_annotation: dependency: "direct main" description: @@ -599,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: c9c65de2baa228783f46a55137538dc599a3c9b1834130cfd3b417ec3b643813 + sha256: "597cd8f6ef8b01c747a6d95ff7b0310066f6c82ddf5565ca371f047f316fe43e" url: "https://pub.dev" source: hosted - version: "0.0.8" + 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: @@ -631,32 +621,40 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" hooks: dependency: "direct main" description: name: hooks - sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7" + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "2.2.0" hooks_riverpod: dependency: "direct main" description: name: hooks_riverpod - sha256: b880efcd17757af0aa242e5dceac2fb781a014c22a32435a5daa8f17e9d5d8a9 + sha256: d58e1e14aa112ca9c56d54effb7d0d717b2d3f96dad3cdd1fce16f8740450700 url: "https://pub.dev" source: hosted - version: "3.1.0" + 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: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -679,38 +677,38 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" - idb_shim: + icon_button_m3e: dependency: transitive description: - name: idb_shim - sha256: b26b2ad126be411d0072d1dfc4d97ebe02121a863e4eadc635b511b9bc138489 + name: icon_button_m3e + sha256: c4524d6141a468679821bbb635b833ac6831925d8a6ae4a4511430b0e4ab9c67 url: "https://pub.dev" source: hosted - version: "2.7.1+2" + version: "0.2.1" image: dependency: transitive description: name: image - sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" + sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89 url: "https://pub.dev" source: hosted - version: "4.7.2" + version: "4.10.1" image_picker: dependency: "direct main" description: name: image_picker - sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.3" image_picker_android: dependency: transitive description: name: image_picker_android - sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677" + sha256: "0a55d645a670d6ae11efa948f955ae904afa472f1344fc15a7ddacc20c1e7219" url: "https://pub.dev" source: hosted - version: "0.8.13+10" + version: "0.8.13+23" image_picker_for_web: dependency: transitive description: @@ -723,10 +721,10 @@ packages: dependency: transitive description: name: image_picker_ios - sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad" + sha256: "852a7762962f3bb3ad54ef1d342669e41557f466033a676baa8876b367cf4050" url: "https://pub.dev" source: hosted - version: "0.8.13+3" + version: "0.8.13+8" image_picker_linux: dependency: transitive description: @@ -763,18 +761,18 @@ 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: @@ -787,18 +785,18 @@ packages: dependency: "direct main" description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.12.0" json_serializable: dependency: "direct dev" description: name: json_serializable - sha256: "6b253f7851cf1626a05c8b49c792e04a14897349798c03798137f2b5f7e0b5b1" + sha256: e45aefa0324f08c683caafbb94b72837aa6193c61822799c916e45f4a263113d url: "https://pub.dev" source: hosted - version: "6.11.3" + version: "6.14.1" leak_tracker: dependency: transitive description: @@ -823,14 +821,31 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + linkify: + dependency: "direct main" + description: + path: "." + ref: "fix/consecutive-periods-loose-url" + resolved-ref: f2f42b06f84dbbd03fb7c3741f7fbacbe5459a4b + url: "https://github.com/appelladev/linkify" + source: git + version: "5.0.0" lints: dependency: transitive description: name: lints - sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "6.0.0" + 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: @@ -839,62 +854,207 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + m3e_buttons: + dependency: "direct main" + description: + name: m3e_buttons + sha256: dca780e813563754576194ed18e3f6a33422ed136c8d4ccfeca20909edddd728 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + m3e_card_list: + dependency: "direct main" + description: + name: m3e_card_list + sha256: ed1cc150f051b8812ffd735915ae59fed23517ffeeed04c4b2b8ef6257cc18b7 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + m3e_design: + dependency: transitive + description: + name: m3e_design + sha256: "15ff0ef4c43553d855c5e866a9aee8231d44919fe2bb354b1259337bdfd659b4" + url: "https://pub.dev" + source: hosted + version: "0.2.1" matcher: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.20" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" + material_emoji_picker: + dependency: "direct main" + description: + name: material_emoji_picker + sha256: fff8b05f9f8c4a0da552397b03823b4fe351e98ec8aa4ed46e3ab9a7f6655c83 + url: "https://pub.dev" + source: hosted + version: "1.1.1+2" + material_ui: + dependency: "direct main" + description: + name: material_ui + sha256: "134ab2f0843545c4e661e4ba2753fe1bcf0753cb690d5a9ee4870638a40411e9" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + measure_size: + dependency: "direct main" + description: + name: measure_size + sha256: "4b2de7b29567501434902a2f4080cf12a8bc7038b2eb97dfae91b71791620b68" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + media_kit: + dependency: "direct main" + description: + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.dev" + source: hosted + version: "1.2.6" + media_kit_libs_android_video: + dependency: transitive + description: + name: media_kit_libs_android_video + sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + media_kit_libs_ios_video: + dependency: transitive + description: + name: media_kit_libs_ios_video + sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_linux: + dependency: transitive + description: + name: media_kit_libs_linux + sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + media_kit_libs_macos_video: + dependency: transitive + description: + name: media_kit_libs_macos_video + sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_video: + dependency: "direct main" + description: + name: media_kit_libs_video + sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + media_kit_libs_windows_video: + dependency: transitive + description: + name: media_kit_libs_windows_video + sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab + url: "https://pub.dev" + source: hosted + version: "1.0.11" + media_kit_video: + dependency: "direct main" + description: + name: media_kit_video + sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d + url: "https://pub.dev" + source: hosted + version: "2.0.1" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.17.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" - nested: + version: "2.1.0" + motor: dependency: transitive description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + name: motor + sha256: cbd49f21b00e568c2b1a55f134ed803614a107782f4fea7769693bca32940c58 url: "https://pub.dev" source: hosted - version: "1.0.0" - node_preamble: + version: "1.1.0" + native_toolchain_c: dependency: transitive description: - name: node_preamble - sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + name: native_toolchain_c + sha256: "90eaad8059ebc2004a63810a4696ea9acdb02473e47ed3b6b3bafb100ac77e31" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "0.19.5" + navigation_rail_m3e: + dependency: "direct main" + description: + path: "packages/navigation_rail_m3e" + ref: HEAD + resolved-ref: da860a413d5cf78b9c9c967215250bcdbfe5191d + url: "https://github.com/Henry-Hiles/material_3_expressive" + source: git + version: "0.3.5" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: ad56fd53a78ff6b1472fa59ff2a4e8b8ccabafc586fc263a1dfad0b99b5553e3 + url: "https://pub.dev" + source: hosted + 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: + name: package_info_plus + sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" + url: "https://pub.dev" + source: hosted + version: "10.2.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" path: dependency: "direct main" description: @@ -915,42 +1075,42 @@ packages: dependency: "direct main" description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: - dependency: transitive + dependency: "direct overridden" description: name: path_provider_android - sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" url: "https://pub.dev" source: hosted - version: "2.2.22" + version: "2.2.23" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_windows: dependency: transitive description: @@ -963,18 +1123,18 @@ packages: dependency: transitive description: name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" 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: @@ -995,50 +1155,42 @@ packages: 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: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e url: "https://pub.dev" source: hosted - version: "6.0.3" - provider: + version: "6.5.2" + process: dependency: transitive description: - name: provider - sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" url: "https://pub.dev" source: hosted - version: "6.1.5+1" + 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" - punycode: - dependency: transitive - description: - name: punycode - sha256: "39b874cc1f78b94e57db17e74b3f2ba2a96e25c0bebdcc8a571614dccda0ff0c" - url: "https://pub.dev" - source: hosted - version: "1.0.0" + version: "1.6.0" quiver: dependency: transitive description: @@ -1047,30 +1199,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.2" + record_use: + dependency: transitive + description: + name: record_use + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" + url: "https://pub.dev" + source: hosted + version: "1.1.1" riverpod: dependency: transitive description: name: riverpod - sha256: "16ff608d21e8ea64364f2b7c049c94a02ab81668f78845862b6e88b71dd4935a" + sha256: "484dfc873ea4c4f4240e5635444fa066f87b07862b7279ebd004a2b71fba4b7a" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.4.3" riverpod_analyzer_utils: dependency: transitive description: name: riverpod_analyzer_utils - sha256: "947b05d04c52a546a2ac6b19ef2a54b08520ff6bdf9f23d67957a4c8df1c3bc0" + sha256: bb13cb3985815840cc103eda1027158358c5fdc00f308fc6f76574d0594107af url: "https://pub.dev" source: hosted - version: "1.0.0-dev.8" + version: "1.0.0-dev.12" riverpod_lint: dependency: "direct dev" description: name: riverpod_lint - sha256: "4d2eb0d19bbe7e3323bd0ce4553b2e6170d161a13914bfdd85a3612329edcb43" + sha256: "215cb3cd54a97e09bc3512535fa4e8bedd3bf2a06dc2ae015ac8e0050fc71bd1" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.9" rxdart: dependency: transitive description: @@ -1079,86 +1239,78 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: "494b982d5edb71030650ea463d939670e91b232b588323dc75229d2c5f23e7b7" + url: "https://pub.dev" + source: hosted + version: "2.0.6" screen_retriever: dependency: transitive description: name: screen_retriever - sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" + sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6 url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.2.2" screen_retriever_linux: dependency: transitive description: name: screen_retriever_linux - sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 + sha256: "7b52006a5ceae1f3d5af7f77188c3290d6e7d8ded16d99809bea84967c65c257" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.2.2" screen_retriever_macos: dependency: transitive description: name: screen_retriever_macos - sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" + sha256: a1489b99cce597c45a54b9aae1cd94c8d4705353b7e0bb2457a6e4de44e0ad8a url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.2.2" screen_retriever_platform_interface: dependency: transitive description: name: screen_retriever_platform_interface - sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 + sha256: "94a5535277510a63184ca178ce12a1449bc0b38618879aa1c18bf57369c5064a" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.2.2" screen_retriever_windows: dependency: transitive description: name: screen_retriever_windows - sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" + sha256: dafc6922b0bfbf1d48cf3ccbf519b4fff47bdcb820da1728ea6db675fecc9324 url: "https://pub.dev" source: hosted - version: "0.2.0" - scrollview_observer: - dependency: transitive - description: - name: scrollview_observer - sha256: "6e40ced415145c449a691d892157a3b854b751f024aed20d9aebda04c21444a3" - url: "https://pub.dev" - source: hosted - version: "1.26.3" - sembast: - dependency: transitive - description: - name: sembast - sha256: "139cf71496105de32e7a08a4e3a1ead0f81c4a616ec9703ed07e8f0d10cdd505" - url: "https://pub.dev" - source: hosted - version: "3.8.6" + version: "0.2.2" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.5" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" url: "https://pub.dev" source: hosted - version: "2.4.18" + 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: @@ -1171,10 +1323,10 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" shared_preferences_web: dependency: transitive description: @@ -1199,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: @@ -1229,53 +1365,37 @@ packages: source: sdk version: "0.0.0" source_gen: - dependency: "direct overridden" + dependency: transitive description: name: source_gen - sha256: "07b277b67e0096c45196cbddddf2d8c6ffc49342e88bf31d460ce04605ddac75" + sha256: "11b6047da8e4eb6c643ccac3d0fda3f9c9e11651695f7da24a85ade8aff15a44" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.3.0" source_helper: dependency: transitive description: name: source_helper - sha256: e82b1996c63da42aa3e6a34cc1ec17427728a1baf72ed017717a5669a7123f0d + sha256: "5e6f216fdf6376c9f3852381ae037499797a3385377d388b011dac98d303c67c" url: "https://pub.dev" source: hosted - version: "1.3.9" - 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: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" stack_trace: 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: @@ -1296,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: @@ -1308,14 +1428,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + super_sliver_list: + dependency: "direct main" + description: + name: super_sliver_list + sha256: b1e1e64d08ce40e459b9bb5d9f8e361617c26b8c9f3bb967760b0f436b6e3f56 + url: "https://pub.dev" + source: hosted + version: "0.4.1" synchronized: dependency: transitive description: name: synchronized - sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + sha256: "397b146f97613b83d84bdccb439bc8d0f3aebb6c1d14a9641e6f24201c490b2d" url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "3.4.2" term_glyph: dependency: transitive description: @@ -1324,38 +1452,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" - test: - dependency: transitive - description: - name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" - url: "https://pub.dev" - source: hosted - version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.7" - test_core: + version: "0.7.12" + timeago: + dependency: "direct main" + description: + name: timeago + sha256: b05159406a97e1cbb2b9ee4faa9fb096fe0e2dfcd8b08fcd2a00553450d3422e + url: "https://pub.dev" + source: hosted + version: "3.7.1" + timezone: dependency: transitive description: - name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + name: timezone + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" url: "https://pub.dev" source: hosted - version: "0.6.12" - thumbhash: - dependency: transitive - description: - name: thumbhash - sha256: "5f6d31c5279ca0b5caa81ec10aae8dcaab098d82cb699ea66ada4ed09c794a37" - url: "https://pub.dev" - source: hosted - version: "0.1.0+1" + version: "0.11.1" typed_data: dependency: transitive description: @@ -1364,22 +1484,72 @@ 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: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce" + url: "https://pub.dev" + source: hosted + version: "3.0.2" url_launcher: dependency: "direct main" description: @@ -1392,34 +1562,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" url: "https://pub.dev" source: hosted - version: "6.3.28" + version: "6.3.33" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" url: "https://pub.dev" source: hosted - version: "6.3.6" + 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: @@ -1432,34 +1602,34 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.3" url_launcher_windows: 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: name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "4.6.0" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.2.3" vector_graphics_codec: dependency: transitive description: @@ -1472,26 +1642,42 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" url: "https://pub.dev" source: hosted - version: "1.1.19" + 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: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" url: "https://pub.dev" source: hosted - version: "15.0.2" + version: "15.3.0" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: "22b3e7e937721de70e63c85e7139f4ac781dc22863b9262431a53ac030eb074b" + url: "https://pub.dev" + source: hosted + version: "1.8.0" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "764c25504562abc8ac3406f5d175f126b276e6ba916c7c2675690b73368ca384" + url: "https://pub.dev" + source: hosted + version: "1.7.0" watcher: dependency: transitive description: @@ -1524,41 +1710,49 @@ 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: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d url: "https://pub.dev" source: hosted - version: "5.15.0" + version: "6.4.0" window_manager: dependency: "direct main" description: name: window_manager - sha256: "7eb6d6c4164ec08e1bf978d6e733f3cebe792e2a23fb07cbca25c2872bfdbdcd" + sha256: "05c231fd7b23d2380f14c5cc10b7b93d60d4fa4a2fb4e0f032de27e44b5560e9" url: "https://pub.dev" source: hosted - version: "0.5.1" - window_size: + version: "0.5.2" + xdg_desktop_portal: dependency: "direct main" description: - path: "plugins/window_size" + path: "." ref: HEAD - resolved-ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601 - url: "https://github.com/google/flutter-desktop-embedding" + resolved-ref: "9ead341e908da2972293b2edc345f2b0a6423d78" + url: "https://github.com/Henry-Hiles/xdg_desktop_portal.dart" source: git - version: "0.1.0" + version: "0.1.14" xdg_directories: - dependency: transitive + dependency: "direct main" description: name: xdg_directories sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" @@ -1569,26 +1763,26 @@ 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: name: yaml_edit - sha256: ec709065bb2c911b336853b67f3732dd13e0336bd065cc2f1061d7610ddf45e3 + sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.2.4" sdks: - dart: ">=3.10.4 <4.0.0" - flutter: ">=3.35.0" + dart: "3.13.3" + flutter: ">=3.47.0" diff --git a/pubspec.yaml b/pubspec.yaml index 3c0198d..2535e0b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,84 +1,119 @@ name: nexus -description: "Yet another Matrix client" -version: 1.0.0 +description: "A simple and user-friendly Matrix client" +version: 0.1.0 publish_to: none flutter: - assets: - - assets/ - uses-material-design: true + fonts: + - family: fallback-emoji + fonts: + - asset: assets/fonts/NotoColorEmoji.ttf + - family: fallback-sans + fonts: + - asset: assets/fonts/Roboto.ttf + assets: + - assets/bundled/ + uses-material-design: true environment: - sdk: "^3.9.2" + sdk: 3.13.3 dependency_overrides: - analyzer: ^8.4.0 - source_gen: ^4.0.2 - flutter_hooks: ^0.21.2 + 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.0.3 - hooks_riverpod: ^3.0.3 - intl: ^0.20.1 - fast_immutable_collections: ^11.0.0 - path_provider: ^2.1.3 - url_launcher: ^6.2.6 - freezed_annotation: ^3.1.0 - image_picker: ^1.1.2 - file_picker: ^10.3.3 - path: ^1.9.0 - dynamic_system_colors: ^1.8.0 - collection: ^1.19.1 - window_manager: ^0.5.1 - window_size: - git: - url: https://github.com/google/flutter-desktop-embedding - path: plugins/window_size - flutter_chat_core: ^2.0.0 - flyer_chat_image_message: ^2.2.2 - flyer_chat_system_message: ^2.1.13 - flyer_chat_file_message: ^2.3.1 - flutter_chat_ui: - git: - url: https://github.com/Henry-Hiles/flutter_chat_ui - path: packages/flutter_chat_ui - flutter_link_previewer: - git: - url: https://github.com/Henry-Hiles/flutter_chat_ui - path: packages/flutter_link_previewer - color_hash: ^1.0.1 - flutter_widget_from_html_core: ^0.17.0 - flutter_svg: ^2.2.2 - json_annotation: ^4.9.0 - shared_preferences: ^2.5.3 - fluttertagger: ^2.3.1 - dynamic_polls: ^0.0.6 - flutter_hooks: ^0.21.3+1 - cross_cache: ^1.1.0 - ffi: ^2.1.5 - hooks: ^1.0.0 - code_assets: ^1.0.0 - ffigen: ^20.1.1 - + 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.4.0 + unifiedpush: 6.2.0 + unifiedpush_storage_shared_preferences: 1.0.0 + flutter_local_notifications: 22.3.1 + material_emoji_picker: 1.1.1+2 + xdg_desktop_portal: + git: + url: https://github.com/Henry-Hiles/xdg_desktop_portal.dart + dev_dependencies: - build_runner: ^2.4.11 - custom_lint: ^0.8.0 - flutter_lints: ^6.0.0 - freezed: ^3.2.3 - riverpod_lint: ^3.0.3 - flutter_launcher_icons: ^0.14.1 - json_serializable: ^6.11.1 + 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: "#000000" - adaptive_icon_foreground: assets/foreground.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 b240d98..d8a8cb1 100644 --- a/scripts/generate.dart +++ b/scripts/generate.dart @@ -1,45 +1,44 @@ import "dart:io"; + import "package:ffigen/ffigen.dart"; import "package:path/path.dart"; +import "package:nexus/helpers/extensions/get_xcode_sdk.dart"; void main(List args) async { - final repoDir = Directory.fromUri( - Platform.script.resolve("../src/gomuks/source"), - ); - if (await repoDir.exists()) await repoDir.delete(recursive: true); - await repoDir.create(recursive: true); + final repoDir = Directory.fromUri(Platform.script.resolve("../gomuks")); - print("Cloning Gomuks repository..."); - final cloneResult = await Process.run("git", [ - "clone", - "--depth", - "1", - "https://mau.dev/gomuks/gomuks", - repoDir.path, - ]); - - if (cloneResult.exitCode != 0) { - throw Exception( - "Failed to clone Gomuks repository: \n${cloneResult.stderr}", - ); - } - - 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"], + compilerOptions: [ + "--no-warnings", + 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/scripts/generate.sh b/scripts/generate.sh deleted file mode 100755 index 6076ab8..0000000 --- a/scripts/generate.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -pushd "$(dirname "$(readlink -f "$0")")"/.. > /dev/null || exit - -mkdir -p build -touch build/lock -dart scripts/generate.dart -rm build/lock - -popd > /dev/null || exit \ No newline at end of file diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 55fb066..5d2b53e 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,24 +6,33 @@ #include "generated_plugin_registrant.h" -#include +#include +#include #include +#include +#include #include #include +#include #include -#include void RegisterPlugins(flutter::PluginRegistry* registry) { + AppLinksPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AppLinksPluginCApi")); DynamicColorPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); + MediaKitVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WebcryptoPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WebcryptoPlugin")); WindowManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("WindowManagerPlugin")); - WindowSizePluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("WindowSizePlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 9333a2f..c74b2f5 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,15 +3,19 @@ # list(APPEND FLUTTER_PLUGIN_LIST - dynamic_system_colors + app_links + dynamic_color file_selector_windows + media_kit_libs_windows_video + media_kit_video screen_retriever_windows url_launcher_windows + webcrypto window_manager - window_size ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/windows/installer.iss b/windows/installer.iss index c5004c3..22f4dcf 100644 --- a/windows/installer.iss +++ b/windows/installer.iss @@ -14,4 +14,8 @@ Source: "..\build\windows\x64\runner\Release\*"; DestDir: "{app}"; Flags: recurs [Icons] Name: "{group}\Nexus"; Filename: "{app}\nexus.exe" -Name: "{commondesktop}\Nexus"; Filename: "{app}\nexus.exe" \ No newline at end of file +Name: "{commondesktop}\Nexus"; Filename: "{app}\nexus.exe" + +[Registry] +Root: HKCU; Subkey: "Software\Classes\nexus.federated.nexus"; ValueType: string; ValueName: "URL Protocol"; ValueData: "" +Root: HKCU; Subkey: "Software\Classes\nexus.federated.nexus\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\nexus.exe"" ""%1""" \ No newline at end of file diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index 453f9cf..590e663 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -1,12 +1,17 @@ #include #include #include - +#include "app_links/app_links_plugin_c_api.h" #include "flutter_window.h" #include "utils.h" int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, _In_ wchar_t *command_line, _In_ int show_command) { + // Forward the link to an existing instance, if any, then exit. + // You may ignore the result if you need to create another window. + if (SendAppLinkToInstance()) { + return EXIT_SUCCESS; + } // Attach to console when present (e.g., 'flutter run') or create a // new console when running with a debugger. if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico index e3c83c9..f8a91f7 100644 Binary files a/windows/runner/resources/app_icon.ico and b/windows/runner/resources/app_icon.ico differ