diff --git a/Makefile b/Makefile index 733fa9f..4b6362d 100644 --- a/Makefile +++ b/Makefile @@ -236,6 +236,29 @@ desktop-linux: desktop ## Build Linux packages of the desktop client (on Linux) -config build/linux/nfpm.yaml -out ../../$(BIN_DIR) || exit 1; \ done +# The Android app. Needs Flutter, the Android SDK, and Rust with the Android +# targets (flutter_vodozemac compiles the crypto from source); see +# apps/mobile/README.md. The APKs land in bin/. +FLUTTER ?= flutter +MOBILE_DIR := apps/mobile +MOBILE_APKS := $(MOBILE_DIR)/build/app/outputs/flutter-apk + +.PHONY: mobile +mobile: ## Build the Android app (release APKs, one per ABI, into bin/) + cd $(MOBILE_DIR) && $(FLUTTER) build apk --release --split-per-abi + mkdir -p $(BIN_DIR) + for abi in arm64-v8a armeabi-v7a x86_64; do \ + cp $(MOBILE_APKS)/app-$$abi-release.apk $(BIN_DIR)/warp-android-$$abi.apk || exit 1; \ + done + +.PHONY: mobile-run +mobile-run: ## Run the Android app on a connected device or emulator + cd $(MOBILE_DIR) && $(FLUTTER) run $(ARGS) + +.PHONY: mobile-test +mobile-test: ## Analyze and test the Android app + cd $(MOBILE_DIR) && $(FLUTTER) analyze && $(FLUTTER) test + .PHONY: complement-image complement-image: ## Build the Complement homeserver image docker build -t $(COMPLEMENT_IMAGE) -f $(SERVER_DIR)/test/complement/Dockerfile . diff --git a/README.md b/README.md index a31805a..8cf74a2 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,9 @@ same way, a process of its own, `warp-web`: `docker compose --profile web up -d`, or see [apps/web](apps/web/README.md). The desktop client, `warp-desktop`, is the same client in a native window (Wails v3) that signs in to any homeserver: `make desktop`, or see -[apps/desktop](apps/desktop/README.md). +[apps/desktop](apps/desktop/README.md). The Android app is Flutter on the +famedly Matrix SDK, with end-to-end encryption and UnifiedPush +notifications: `make mobile`, or see [apps/mobile](apps/mobile/README.md). Also in place: argon2id passwords, hashed access and refresh tokens, per-endpoint rate limiting, YAML + environment configuration, structured @@ -376,7 +378,7 @@ server/ the homeserver (Go module) filestore/ media content on the local filesystem sqlite/ postgres/ pocketbase/ test/complement/ the Complement homeserver image and runner -apps/ clients: ui (design system), admin (warp-admin panel); web, desktop, mobile to come +apps/ clients: ui (design system), admin (warp-admin panel), web, desktop, mobile (Android) deploy/ compose/ (with Prometheus, Grafana, Jaeger), helm/warp, grafana/ dashboard docs/ ``` diff --git a/apps/README.md b/apps/README.md index 03e998c..9bf56bf 100644 --- a/apps/README.md +++ b/apps/README.md @@ -6,7 +6,7 @@ | [`admin/`](admin/README.md) | admin web panel, served by its own optional `warp-admin` binary | done for the current admin API | | [`web/`](web/README.md) | web client (Vue SPA on matrix-js-sdk), served by its own optional `warp-web` binary | rooms, timeline, sending text and files, invites; E2EE next | | `desktop/` | Wails v3 shell reusing the web frontend | next: web has a timeline | -| `mobile/` | Flutter app, Android first | after web | +| [`mobile/`](mobile/README.md) | Flutter app on the famedly Matrix SDK, Android first | sign-in, rooms, timeline, files, E2EE with verification, push | The frontend packages are a pnpm workspace (`pnpm-workspace.yaml` at the repository root); the Go hosts join `go.work`. diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/apps/mobile/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/apps/mobile/.metadata b/apps/mobile/.metadata new file mode 100644 index 0000000..954d671 --- /dev/null +++ b/apps/mobile/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "ad70ec4617166f1c38e5d2bfd388af71fda14f06" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: android + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: ios + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/apps/mobile/README.md b/apps/mobile/README.md new file mode 100644 index 0000000..0bb7a8b --- /dev/null +++ b/apps/mobile/README.md @@ -0,0 +1,158 @@ +# Warp for Android + +A Matrix client for Android, written in Flutter on the famedly +[Matrix Dart SDK](https://github.com/famedly/matrix-dart-sdk) with +[vodozemac](https://github.com/matrix-org/vodozemac) for end-to-end +encryption. It speaks the plain Client-Server API, so it works against any +Matrix homeserver, not only Warp. The same code is meant to ship to iOS +later (PLAN.md §8.5); only Android is built and tested today. + +It looks like the [web client](../web/README.md): the same tokens (colours, +radii), the Outfit and Figtree fonts, the Warp symbol. + +## What it does + +- Sign in with a password, on a server name (`example.org`, found through + `/.well-known/matrix/client`), a homeserver address, or a full user ID + alone. Sign up when the server allows it, with a registration token when + it asks for one. +- The session survives restarts: sync state, rooms and keys are in SQLite in + the app's private storage, and the access token and cached secrets also + in the Android Keystore (`flutter_secure_storage`). They are excluded from + cloud backups and device transfers, since a restored copy would be a + second session with this one's keys. +- Room list: unread and mention counts, invites first with Join and + Decline, a search, and filters for unread, people and rooms. +- Timeline: grouped messages, day separators, room events as one-line + notices, older history on scroll, links that open in the browser, images + (full screen with zoom), files saved where you choose. Media is fetched + with the access token (authenticated media, Matrix 1.11). +- Sending: text (`/me` and the SDK's other commands), photos and videos from + the gallery or the camera, any file. A message shows at once as + *Sending*, then *Sent*, then *Read* once someone else has read it; a + failed one says *Not sent*, with Retry and Delete on a long press. +- Typing notifications, read receipts, deleting messages, editing shown as + *edited*. +- New rooms (end-to-end encrypted by default, public or not), direct + messages (encrypted), joining by address. Room info: members with their + role and verification shield, invite, rename, topic, notification level, + turning encryption on, leaving. +- End-to-end encryption, on by default: + - *Set up encryption* creates cross-signing keys, secret storage and a key + backup in one step, behind a recovery key shown once. + - A new session verifies itself with the recovery key (which also + restores older messages from the backup) or from another session. + - Verifying people and sessions by comparing emoji, from room info, the + session list, or an incoming request. + - A message this session can't decrypt says why, not just "encrypted". + - *Reset encryption* for a lost recovery key. +- Sessions: every session of the account, which are verified, verify one, + sign one out. +- Notifications while the app runs, one per room, opening the room when + tapped, and push notifications through + [UnifiedPush](https://unifiedpush.org) (below). +- Settings: display name, avatar, theme (system, light, dark). + +**Not yet:** replies, reactions, threads, spaces, voice and video calls, +QR-code verification, Simplified Sliding Sync (the SDK has no client for +it, so the app uses `/sync`), a per-message trust shield like the web +client's. + +## Push notifications + +Warp doesn't use Firebase. Push goes through UnifiedPush: the user installs +a *distributor* app, such as [ntfy](https://ntfy.sh), and turns on *Push +notifications* in Settings. The distributor gives the app an endpoint, +which becomes a Matrix pusher on the homeserver. When something notifies, +the homeserver posts to the distributor's Matrix gateway, the distributor +wakes the app, and the app fetches the event, decrypts it if it can, and +shows it. + +- Pushes are `event_id_only`: the gateway and the distributor never see + message content, in encrypted rooms or not. +- The gateway is found with UnifiedPush's discovery + (`GET /_matrix/push/v1/notify`; ntfy has one). A + distributor without one goes through UnifiedPush's public gateway, + `matrix.gateway.unifiedpush.org`. +- The homeserver needs `push.enabled` (the default); see + [docs/push.md](../../docs/push.md). It accepts any gateway URL with the + path `/_matrix/push/v1/notify`. +- Without a distributor the app is still notified, but only while Android + keeps it running. + +## Build and run + +You need: + +- Flutter 3.44 or later (Dart 3.12). +- The Android SDK with an NDK and CMake (Android Studio installs them). +- Rust, with the Android targets: vodozemac is compiled from source during + the build. + + ```bash + rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android + ``` + +Then: + +| Command | What | +|---|---| +| `make mobile` | release APKs, one per ABI, to `bin/warp-android-.apk` | +| `make mobile-run` | run on a connected phone or emulator (`ARGS=--release` for a release build) | +| `make mobile-test` | `flutter analyze` and the unit tests | + +The first build compiles the Rust crate and takes a few minutes; later +builds reuse it. + +Release builds are signed with the key in `android/key.properties` +(`storeFile`, `storePassword`, `keyAlias`, `keyPassword`; not committed). +Without that file they are signed with the debug key, which is fine for +trying a release build and not for distributing one. + +### Against a local homeserver + +Debug builds may use plain HTTP, release builds only HTTPS. From the +emulator, the host machine is `10.0.2.2`: + +```bash +WARP_SERVER_NAME=dev.local WARP_PRIVATE_KEY=./keys/dev.key ./bin/warp generate-keys +WARP_SERVER_NAME=dev.local WARP_PRIVATE_KEY=./keys/dev.key WARP_REGISTRATION_ENABLED=true ./bin/warp serve +``` + +and sign up on the server `http://10.0.2.2:8008`. + +### Windows notes + +- `android/gradle.properties` turns off Kotlin's incremental compilation: + with the project and the pub cache on different drives, plugins fail to + compile with "different roots". +- `flutter test` builds the native code of every dependency for the host, + including `webcrypto` (pulled in by UnifiedPush's Linux support). Its + build needs CMake 3.13 or later; if Visual Studio's developer environment + puts an older CMake first (Visual Studio 2017 ships 3.12), the build fails + with "does not appear to contain CMakeLists.txt". Android builds use the + Android SDK's CMake and are not affected. + +## Layout + +| Path | What | +|---|---| +| `lib/main.dart` | start-up: notifications, the client, push; a push alone starts the app without UI (`--unifiedpush-bg`) | +| `lib/app.dart` | the app shell: signed in or not, prompts the SDK raises (password, verification requests), opening rooms from notifications | +| `lib/matrix/` | everything around the SDK: the client and homeserver discovery (`client.dart`), the timeline projection, media, encryption setup, notifications, push | +| `lib/screens/` | sign-in, sign-up, room list, room, room info, settings, encryption, sessions | +| `lib/widgets/` | message bubbles, avatars, new chat, verification, password prompts | +| `lib/theme.dart` | the design tokens as a Flutter theme | +| `test/` | the timeline projection, formatting | +| `assets/` | the brand fonts (as TTF: Flutter can't load woff2) and the symbol | +| `tool/icons/` | launcher icon sources; `dart run flutter_launcher_icons` regenerates the icons | + +The SDK's objects change in place, as matrix-js-sdk's do in the web client. +Screens watch a counter bumped on every sync (`syncProvider` in +`lib/state.dart`) and read the client again. + +## Licences + +The Matrix Dart SDK is AGPL-3.0. An APK built from this app includes it, +so distributing the APK has to follow the AGPL. The fonts are under the SIL +Open Font License. diff --git a/apps/mobile/analysis_options.yaml b/apps/mobile/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/apps/mobile/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/apps/mobile/android/.gitignore b/apps/mobile/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/apps/mobile/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/apps/mobile/android/app/build.gradle.kts b/apps/mobile/android/app/build.gradle.kts new file mode 100644 index 0000000..1ecfd9b --- /dev/null +++ b/apps/mobile/android/app/build.gradle.kts @@ -0,0 +1,78 @@ +import java.util.Properties + +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +// Release signing comes from android/key.properties (not committed); without +// it a release build is signed with the debug key, so `flutter run --release` +// still works on a development machine. +val keyProperties = Properties().apply { + val file = rootProject.file("key.properties") + if (file.exists()) file.inputStream().use { load(it) } +} + +android { + namespace = "io.github.dariuszjaniak.warp" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + // flutter_local_notifications uses java.time on older Android versions. + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + applicationId = "io.github.dariuszjaniak.warp" + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + signingConfigs { + if (keyProperties.isNotEmpty()) { + create("release") { + storeFile = file(keyProperties.getProperty("storeFile")) + storePassword = keyProperties.getProperty("storePassword") + keyAlias = keyProperties.getProperty("keyAlias") + keyPassword = keyProperties.getProperty("keyPassword") + } + } + } + + buildTypes { + release { + signingConfig = if (keyProperties.isNotEmpty()) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") +} + +// UnifiedPush's connector depends on Tink's JVM artifact and +// flutter_secure_storage on its Android one: the same classes twice. Keep +// the Android build. +configurations.all { + exclude(group = "com.google.crypto.tink", module = "tink") +} + +flutter { + source = "../.." +} diff --git a/apps/mobile/android/app/src/debug/AndroidManifest.xml b/apps/mobile/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..1aa48cc --- /dev/null +++ b/apps/mobile/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..58db8f6 --- /dev/null +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/android/app/src/main/kotlin/io/github/dariuszjaniak/warp/MainActivity.kt b/apps/mobile/android/app/src/main/kotlin/io/github/dariuszjaniak/warp/MainActivity.kt new file mode 100644 index 0000000..e3792c6 --- /dev/null +++ b/apps/mobile/android/app/src/main/kotlin/io/github/dariuszjaniak/warp/MainActivity.kt @@ -0,0 +1,5 @@ +package io.github.dariuszjaniak.warp + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/apps/mobile/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..b11ea87 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..c8c1e31 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-v21/launch_background.xml b/apps/mobile/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..89b9055 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..331496b Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..f0f6e5e Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable/ic_notification.png b/apps/mobile/android/app/src/main/res/drawable/ic_notification.png new file mode 100644 index 0000000..a41a414 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable/ic_notification.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable/launch_background.xml b/apps/mobile/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..073b498 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..674a744 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..8faa5b3 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..385092f Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..afcc469 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..97fa27b Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/values-night/styles.xml b/apps/mobile/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/values/colors.xml b/apps/mobile/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..403f795 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #00A389 + \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/res/values/styles.xml b/apps/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/xml/data_extraction_rules.xml b/apps/mobile/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..f9adc71 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/android/app/src/profile/AndroidManifest.xml b/apps/mobile/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/apps/mobile/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/mobile/android/build.gradle.kts b/apps/mobile/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/apps/mobile/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/apps/mobile/android/gradle.properties b/apps/mobile/android/gradle.properties new file mode 100644 index 0000000..1bd539d --- /dev/null +++ b/apps/mobile/android/gradle.properties @@ -0,0 +1,10 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false + +# The project and the pub cache are on different drives on Windows, which +# breaks the Kotlin incremental caches of plugins ("different roots"). +kotlin.incremental=false diff --git a/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties b/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/apps/mobile/android/settings.gradle.kts b/apps/mobile/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/apps/mobile/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.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/apps/mobile/assets/fonts/Figtree-Variable.ttf b/apps/mobile/assets/fonts/Figtree-Variable.ttf new file mode 100644 index 0000000..4645df9 Binary files /dev/null and b/apps/mobile/assets/fonts/Figtree-Variable.ttf differ diff --git a/apps/mobile/assets/fonts/JetBrainsMono-Variable.ttf b/apps/mobile/assets/fonts/JetBrainsMono-Variable.ttf new file mode 100644 index 0000000..18c56fa Binary files /dev/null and b/apps/mobile/assets/fonts/JetBrainsMono-Variable.ttf differ diff --git a/apps/mobile/assets/fonts/Outfit-Variable.ttf b/apps/mobile/assets/fonts/Outfit-Variable.ttf new file mode 100644 index 0000000..225c5ac Binary files /dev/null and b/apps/mobile/assets/fonts/Outfit-Variable.ttf differ diff --git a/apps/mobile/assets/logo/warp-symbol.png b/apps/mobile/assets/logo/warp-symbol.png new file mode 100644 index 0000000..494e2a8 Binary files /dev/null and b/apps/mobile/assets/logo/warp-symbol.png differ diff --git a/apps/mobile/ios/.gitignore b/apps/mobile/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/apps/mobile/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/apps/mobile/ios/Flutter/AppFrameworkInfo.plist b/apps/mobile/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/apps/mobile/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/apps/mobile/ios/Flutter/Debug.xcconfig b/apps/mobile/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/apps/mobile/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/apps/mobile/ios/Flutter/Release.xcconfig b/apps/mobile/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/apps/mobile/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/apps/mobile/ios/Runner.xcodeproj/project.pbxproj b/apps/mobile/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..f0f7054 --- /dev/null +++ b/apps/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,644 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 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 = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.github.dariuszjaniak.warp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.github.dariuszjaniak.warp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.github.dariuszjaniak.warp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.github.dariuszjaniak.warp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.github.dariuszjaniak.warp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.github.dariuszjaniak.warp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/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/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/apps/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/apps/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/apps/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata b/apps/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/apps/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/apps/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/apps/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/apps/mobile/ios/Runner/AppDelegate.swift b/apps/mobile/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/apps/mobile/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d0d98aa --- /dev/null +++ b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..fdc7e5d Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..fb216df Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..9160b02 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..f1974a6 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..bdceab5 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..3a9f934 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..18fe788 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..9160b02 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..7d1c359 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..fcfe1a3 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..0ca0a7d Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..6f8e3e9 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..dac9f4e Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..678024c Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..fcfe1a3 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..a1afcc7 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..12a2aaa Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..f212e8c Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..72ee241 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..bda2b4b Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..5e33b72 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/apps/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard b/apps/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/apps/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/ios/Runner/Base.lproj/Main.storyboard b/apps/mobile/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/apps/mobile/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/ios/Runner/Info.plist b/apps/mobile/ios/Runner/Info.plist new file mode 100644 index 0000000..143db41 --- /dev/null +++ b/apps/mobile/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Warp + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + warp + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/apps/mobile/ios/Runner/Runner-Bridging-Header.h b/apps/mobile/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/apps/mobile/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/apps/mobile/ios/Runner/SceneDelegate.swift b/apps/mobile/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/apps/mobile/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/apps/mobile/ios/RunnerTests/RunnerTests.swift b/apps/mobile/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/apps/mobile/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/apps/mobile/lib/app.dart b/apps/mobile/lib/app.dart new file mode 100644 index 0000000..c8b649d --- /dev/null +++ b/apps/mobile/lib/app.dart @@ -0,0 +1,116 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/encryption.dart'; +import 'package:matrix/matrix.dart'; + +import 'matrix/notifications.dart'; +import 'screens/home_screen.dart'; +import 'screens/login_screen.dart'; +import 'screens/room_screen.dart'; +import 'state.dart'; +import 'theme.dart'; +import 'widgets/uia.dart'; +import 'widgets/verification_sheet.dart'; + +final navigatorKey = GlobalKey(); + +class WarpApp extends ConsumerWidget { + const WarpApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final loginState = ref.watch(loginStateProvider); + final signedIn = loginState.value == LoginState.loggedIn; + // Signing in from the sign-up screen, or out from settings, leaves those + // screens on top of the new home: close them. + ref.listen(loginStateProvider, (prev, next) { + if (prev?.value != next.value) { + navigatorKey.currentState?.popUntil((route) => route.isFirst); + } + }); + return MaterialApp( + title: 'Warp', + navigatorKey: navigatorKey, + debugShowCheckedModeBanner: false, + theme: warpTheme(Brightness.light), + darkTheme: warpTheme(Brightness.dark), + themeMode: ref.watch(themeModeProvider), + home: loginState.isLoading + ? const Scaffold() + : signedIn + ? const _SignedIn(key: ValueKey('signed-in')) + : const LoginScreen(key: ValueKey('signed-out')), + ); + } +} + +/// Everything that runs only while signed in: the notification listener, +/// prompts the SDK raises (a password for an account change, an incoming +/// verification request) and opening rooms from notifications. +class _SignedIn extends ConsumerStatefulWidget { + const _SignedIn({super.key}); + + @override + ConsumerState<_SignedIn> createState() => _SignedInState(); +} + +class _SignedInState extends ConsumerState<_SignedIn> + with WidgetsBindingObserver { + final _subs = []; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + final client = ref.read(clientProvider); + _subs + ..add(Notifications.watch(client)) + ..add(Notifications.opened.listen(_openRoom)) + ..add(client.onUiaRequest.stream.listen(_onUia)) + ..add(client.onKeyVerificationRequest.stream.listen(_onVerification)); + Notifications.launchRoomId().then((roomId) { + if (roomId != null) _openRoom(roomId); + }); + Notifications.requestPermission(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + for (final sub in _subs) { + sub.cancel(); + } + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // The sync loop keeps running in the background for as long as Android + // lets the process live; pushes cover the time after that. + Notifications.foreground = state == AppLifecycleState.resumed; + } + + void _openRoom(String roomId) { + final nav = navigatorKey.currentState; + if (nav == null) return; + nav.popUntil((route) => route.isFirst); + nav.push(RoomScreen.route(roomId)); + } + + void _onUia(UiaRequest request) { + final context = navigatorKey.currentContext; + if (context == null) return request.cancel(); + handleUia(context, ref.read(clientProvider), request); + } + + void _onVerification(KeyVerification request) { + final context = navigatorKey.currentContext; + if (context == null) return; + showVerificationSheet(context, request); + } + + @override + Widget build(BuildContext context) => const HomeScreen(); +} diff --git a/apps/mobile/lib/main.dart b/apps/mobile/lib/main.dart new file mode 100644 index 0000000..ceb46dc --- /dev/null +++ b/apps/mobile/lib/main.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'app.dart'; +import 'matrix/client.dart'; +import 'matrix/notifications.dart'; +import 'matrix/push.dart'; +import 'state.dart'; + +Future main(List args) async { + WidgetsFlutterBinding.ensureInitialized(); + + // A push starts the app with this argument when it isn't running: restore + // the session, show the notification, and don't start the UI. + final background = args.contains('--unifiedpush-bg'); + + await Notifications.init(); + final client = await createClient(startSync: !background); + await Push.init(client); + if (background) return; + + runApp( + ProviderScope( + overrides: [clientProvider.overrideWithValue(client)], + child: const WarpApp(), + ), + ); +} diff --git a/apps/mobile/lib/matrix/client.dart b/apps/mobile/lib/matrix/client.dart new file mode 100644 index 0000000..c5b8134 --- /dev/null +++ b/apps/mobile/lib/matrix/client.dart @@ -0,0 +1,183 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod; +import 'package:matrix/encryption.dart'; +import 'package:matrix/matrix.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart' as sqflite; + +/// The name the session's device gets on the homeserver. +const deviceDisplayName = 'Warp Android'; + +const _secureStorage = FlutterSecureStorage(); + +/// Creates the one [Client] the app uses and restores its session, if any. +/// +/// Sync state, rooms and the Olm/Megolm keys live in a SQLite database in the +/// app's private storage. The SDK also writes its credentials (the access +/// token and the cached secrets) to [FlutterSecureStorage], the Android +/// Keystore, and reads them from there first. +/// +/// [startSync] is false when the app was started in the background to show a +/// push notification: that only needs the session, not a sync loop. +Future createClient({bool startSync = true}) async { + await vod.init(); + + final dir = await getApplicationSupportDirectory(); + // The SDK's file store (attachments being sent, downloaded media) writes + // into this directory but doesn't create it. + final media = await Directory(p.join(dir.path, 'media')).create(); + final database = await MatrixSdkDatabase.init( + 'warp', + database: await sqflite.openDatabase(p.join(dir.path, 'warp.sqlite')), + fileStorageLocation: media.uri, + deleteFilesAfterDuration: const Duration(days: 30), + secureStorage: ( + read: (key) => _secureStorage.read(key: key), + write: (key, value) => _secureStorage.write(key: key, value: value), + delete: (key) => _secureStorage.delete(key: key), + ), + ); + + final client = Client( + 'Warp', + database: database, + verificationMethods: { + KeyVerificationMethod.emoji, + KeyVerificationMethod.numbers, + }, + supportedLoginTypes: {AuthenticationTypes.password}, + nativeImplementations: NativeImplementationsIsolate( + compute, + vodozemacInit: () => vod.init(), + ), + logLevel: kReleaseMode ? Level.warning : Level.info, + importantStateEvents: {'im.ponies.room_emotes'}, + // Refresh tokens: a soft logout (an expired access token) is answered by + // refreshing it rather than by signing the user out. + onSoftLogout: (client) => client.refreshAccessToken(), + ); + client.backgroundSync = startSync; + await client.init( + waitForFirstSync: false, + waitUntilLoadCompletedLoaded: startSync, + ); + return client; +} + +/// What the user typed as their server, resolved to a homeserver. +/// +/// Accepts a server name (`example.org`, looked up through +/// `/.well-known/matrix/client`), a homeserver address +/// (`https://matrix.example.org`) or, for development, a plain HTTP one +/// (`http://10.0.2.2:8008`, which the emulator sees as the host machine). +/// A bare `host:port` on a private address is tried over HTTPS first, then +/// over HTTP. +Future resolveHomeserver(Client client, String input) async { + final text = input.trim(); + if (text.isEmpty) throw const FormatException('Enter a server.'); + + final candidates = []; + if (text.startsWith('http://') || text.startsWith('https://')) { + final uri = Uri.tryParse(text); + if (uri == null || uri.host.isEmpty) { + throw const FormatException("That isn't a server address."); + } + candidates.add(uri); + } else { + final https = Uri.tryParse('https://$text'); + if (https == null || https.host.isEmpty) { + throw const FormatException("That isn't a server name."); + } + candidates.add(https); + if (_isLocal(https.host)) candidates.add(https.replace(scheme: 'http')); + } + + Object? lastError; + for (final uri in candidates) { + try { + await client.checkHomeserver( + uri, + checkWellKnown: uri.scheme == 'https' && !uri.hasPort, + fetchAuthMetadata: false, + ); + return; + } catch (e) { + lastError = e; + } + } + throw HomeserverException(text, lastError); +} + +bool _isLocal(String host) => + host == 'localhost' || + host.endsWith('.localhost') || + host.endsWith('.local') || + host.startsWith('10.') || + host.startsWith('192.168.') || + host.startsWith('127.') || + RegExp(r'^172\.(1[6-9]|2\d|3[01])\.').hasMatch(host); + +class HomeserverException implements Exception { + HomeserverException(this.input, this.cause); + final String input; + final Object? cause; + + @override + String toString() { + final cause = this.cause; + if (cause is BadServerLoginTypesException) { + return "$input doesn't offer sign-in with a password."; + } + return "Couldn't reach a Matrix server at $input."; + } +} + +/// The server part of a user ID typed in full (`@alice:example.org`), so +/// signing in with one doesn't need the server typed separately. +String? serverOfUserId(String input) { + final text = input.trim(); + if (!text.startsWith('@')) return null; + final i = text.indexOf(':'); + if (i < 0 || i == text.length - 1) return null; + return text.substring(i + 1); +} + +/// A human sentence for an error from the SDK or the homeserver. +String describeError(Object error) { + if (error is MatrixException) { + switch (error.error) { + case MatrixError.M_FORBIDDEN: + return error.errorMessage.isNotEmpty + ? error.errorMessage + : "You aren't allowed to do that."; + case MatrixError.M_USER_IN_USE: + return 'That username is taken.'; + case MatrixError.M_INVALID_USERNAME: + return "That username isn't allowed."; + case MatrixError.M_LIMIT_EXCEEDED: + return 'Too many attempts. Wait a moment and try again.'; + case MatrixError.M_NOT_FOUND: + return error.errorMessage.isNotEmpty + ? error.errorMessage + : "That doesn't exist."; + default: + return error.errorMessage.isNotEmpty + ? error.errorMessage + : error.errcode; + } + } + if (error is HomeserverException || error is FormatException) { + return error is FormatException ? error.message : error.toString(); + } + final text = error.toString(); + if (text.contains('SocketException') || + text.contains('ClientException') || + text.contains('TimeoutException')) { + return "Couldn't reach the server. Check your connection."; + } + return text.replaceFirst('Exception: ', ''); +} diff --git a/apps/mobile/lib/matrix/encryption.dart b/apps/mobile/lib/matrix/encryption.dart new file mode 100644 index 0000000..816a43a --- /dev/null +++ b/apps/mobile/lib/matrix/encryption.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:matrix/encryption.dart'; +import 'package:matrix/matrix.dart'; + +/// Sets up encryption for the account in one step, like the web client: +/// a new secret storage key, new cross-signing keys, and a new key backup, +/// all behind one recovery key, which is returned to be shown once. +/// +/// It always starts over: whatever secret storage, cross-signing keys or +/// backup the account had are replaced. That is what "set up" means for an +/// account without cross-signing, and what "reset" means for one that lost +/// its recovery key. +/// +/// Uploading cross-signing keys needs the account password; the SDK asks +/// for it through [Client.onUiaRequest], which the app answers. +Future setUpEncryption(Encryption encryption) { + final done = Completer(); + BootstrapState? handled; + + Future step(Bootstrap b) async { + // onUpdate fires once per state, but guard anyway: answering a state + // twice throws. + if (b.state == handled) return; + handled = b.state; + switch (b.state) { + case BootstrapState.loading: + break; + case BootstrapState.askWipeSsss: + b.wipeSsss(true); + case BootstrapState.askUseExistingSsss: + b.useExistingSsss(false); + case BootstrapState.askBadSsss: + b.ignoreBadSecrets(true); + case BootstrapState.askUnlockSsss: + b.unlockedSsss(); + case BootstrapState.askNewSsss: + await b.newSsss(); + case BootstrapState.openExistingSsss: + await b.openExistingSsss(); + case BootstrapState.askWipeCrossSigning: + await b.wipeCrossSigning(true); + case BootstrapState.askSetupCrossSigning: + await b.askSetupCrossSigning( + setupMasterKey: true, + setupSelfSigningKey: true, + setupUserSigningKey: true, + ); + case BootstrapState.askWipeOnlineKeyBackup: + b.wipeOnlineKeyBackup(true); + case BootstrapState.askSetupOnlineKeyBackup: + await b.askSetupOnlineKeyBackup(true); + case BootstrapState.done: + final key = b.newSsssKey?.recoveryKey; + if (key == null) { + done.completeError(Exception('No recovery key was made.')); + } else { + done.complete(key); + } + case BootstrapState.error: + done.completeError( + b.errorResult?.error ?? Exception('Setting up encryption failed.'), + ); + } + } + + encryption.bootstrap( + onUpdate: (b) => step(b).catchError((Object e, StackTrace s) { + Logs().e('Encryption setup failed', e, s); + if (!done.isCompleted) done.completeError(e, s); + }), + ); + return done.future; +} + +/// Verifies this session with the recovery key (or passphrase): unlocks +/// secret storage, which caches the cross-signing and backup keys and signs +/// this session, then restores the room keys from the backup so older +/// messages can be read. +Future verifyWithRecoveryKey(Encryption encryption, String input) async { + final key = encryption.ssss.open(); + await key.unlock(keyOrPassphrase: input.trim()); + await key.maybeCacheAll(); + if (!encryption.crossSigning.isSelfSigned) { + await encryption.crossSigning.selfSign(openSsss: key); + } + if (encryption.keyManager.enabled) { + // Best effort: the session is verified whether or not this finishes. + unawaited( + encryption.keyManager.loadAllKeys().catchError( + (Object e) => Logs().w('Restoring keys from the backup failed', e), + ), + ); + } +} diff --git a/apps/mobile/lib/matrix/media.dart b/apps/mobile/lib/matrix/media.dart new file mode 100644 index 0000000..5e6c860 --- /dev/null +++ b/apps/mobile/lib/matrix/media.dart @@ -0,0 +1,87 @@ +import 'dart:typed_data'; + +import 'package:matrix/matrix.dart'; + +/// Loads media for display: avatars by their `mxc://` URI, attachments by +/// their event. +/// +/// Media is authenticated (Matrix 1.11), so it can't be handed to an image +/// widget as a URL: it is fetched with the access token here. Attachments go +/// through the SDK, which decrypts files from encrypted rooms and keeps them +/// in its file store; avatars are cached in that store too. A small +/// in-memory cache in front keeps scrolling from refetching. +class Media { + Media._(); + + static const _maxEntries = 300; + static final _cache = >{}; + + static Future _remember( + String key, + Future Function() load, + ) { + final hit = _cache.remove(key); + if (hit != null) { + _cache[key] = hit; + return hit; + } + final future = load().catchError((Object e) { + Logs().v('Could not load $key', e); + _cache.remove(key); + return null; + }); + _cache[key] = future; + while (_cache.length > _maxEntries) { + _cache.remove(_cache.keys.first); + } + return future; + } + + /// A square thumbnail of [mxc], [size] logical pixels (fetched at 2x). + static Future thumbnail(Client client, Uri mxc, int size) { + final px = size * 2 < 96 ? 96 : size * 2; + return _remember('$mxc@$px', () async { + final stored = await client.database.getFile(_storeKey(mxc, px)); + if (stored != null) return stored; + final uri = await mxc.getThumbnailUri( + client, + width: px, + height: px, + method: ThumbnailMethod.crop, + ); + final response = await client.httpClient.get( + uri, + headers: {'authorization': 'Bearer ${client.accessToken}'}, + ); + if (response.statusCode != 200) return null; + final bytes = response.bodyBytes; + await client.database.storeFile( + _storeKey(mxc, px), + bytes, + DateTime.now().millisecondsSinceEpoch, + ); + return bytes; + }); + } + + static Uri _storeKey(Uri mxc, int px) => + Uri(scheme: 'cache', host: 'avatar', path: '${mxc.host}${mxc.path}@$px'); + + /// The attachment of [event], or its thumbnail when it has one and + /// [thumbnail] is set. Decrypted when the room is encrypted. + static Future attachment(Event event, {bool thumbnail = false}) { + final useThumb = thumbnail && event.hasThumbnail; + return _remember( + '${event.eventId}${useThumb ? '#thumb' : ''}', + () async => (await event.downloadAndDecryptAttachment( + getThumbnail: useThumb, + )).bytes, + ); + } + + /// Forgets an event's cached attachment, for a retry after a failure. + static void evict(Event event) { + _cache.remove(event.eventId); + _cache.remove('${event.eventId}#thumb'); + } +} diff --git a/apps/mobile/lib/matrix/notifications.dart b/apps/mobile/lib/matrix/notifications.dart new file mode 100644 index 0000000..1c6def3 --- /dev/null +++ b/apps/mobile/lib/matrix/notifications.dart @@ -0,0 +1,157 @@ +import 'dart:async'; +import 'dart:ui' show Color; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:matrix/matrix.dart'; + +/// System notifications for new messages: one per room, replaced as more +/// messages arrive, opening the room when tapped. +/// +/// They come from two places. While the app runs, from the SDK's +/// [Client.onNotification] (events the user's push rules say notify). While +/// it doesn't, from a push ([Push]), which starts the app in the background +/// just long enough to fetch the event and show it. +class Notifications { + Notifications._(); + + static final _plugin = FlutterLocalNotificationsPlugin(); + static final _opened = StreamController.broadcast(); + static bool _ready = false; + + /// Room IDs of notifications the user tapped while the app was running. + static Stream get opened => _opened.stream; + + /// The room the user is looking at, whose messages don't notify. + static String? activeRoomId; + + /// Whether the app is in the foreground. Messages in rooms other than + /// [activeRoomId] still notify then. + static bool foreground = true; + + static const _channel = AndroidNotificationChannel( + 'messages', + 'Messages', + description: 'New messages and invites', + importance: Importance.high, + ); + + static Future init() async { + if (_ready) return; + await _plugin.initialize( + settings: const InitializationSettings( + android: AndroidInitializationSettings('ic_notification'), + ), + onDidReceiveNotificationResponse: (response) { + final roomId = response.payload; + if (roomId != null && roomId.isNotEmpty) _opened.add(roomId); + }, + ); + await _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.createNotificationChannel(_channel); + _ready = true; + } + + /// The room of the notification that launched the app, if one did. + static Future launchRoomId() async { + final details = await _plugin.getNotificationAppLaunchDetails(); + if (details?.didNotificationLaunchApp != true) return null; + return details?.notificationResponse?.payload; + } + + /// Asks for the Android 13+ notification permission. Returns whether + /// notifications may be shown. + static Future requestPermission() async => + await _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.requestNotificationsPermission() ?? + true; + + /// Shows [event] unless the user is looking at its room. + static Future showEvent(Event event) async { + final room = event.room; + if (foreground && room.id == activeRoomId) return; + final i18n = const MatrixDefaultLocalizations(); + final body = room.membership == Membership.invite + ? '${event.senderFromMemoryOrFallback.calcDisplayname()} invited you' + : event.calcLocalizedBodyFallback( + i18n, + withSenderNamePrefix: !room.isDirectChat, + hideReply: true, + plaintextBody: false, + removeMarkdown: true, + ); + await show( + roomId: room.id, + title: room.getLocalizedDisplayname(i18n), + body: body, + timestamp: event.originServerTs, + ); + } + + static Future show({ + required String roomId, + required String title, + required String body, + DateTime? timestamp, + }) async { + if (!_ready) await init(); + try { + await _plugin.show( + id: _idFor(roomId), + title: title, + body: body, + payload: roomId, + notificationDetails: NotificationDetails( + android: AndroidNotificationDetails( + _channel.id, + _channel.name, + channelDescription: _channel.description, + importance: Importance.high, + priority: Priority.high, + category: AndroidNotificationCategory.message, + when: timestamp?.millisecondsSinceEpoch, + showWhen: timestamp != null, + color: const Color(0xFF00A389), + groupKey: 'messages', + ), + ), + ); + } catch (e, s) { + Logs().w('Could not show a notification', e, s); + } + } + + /// Removes the room's notification, when it has been read or opened. + static Future clearRoom(String roomId) async { + if (!_ready) return; + await _plugin.cancel(id: _idFor(roomId)); + } + + static Future clearAll() async { + if (!_ready) return; + await _plugin.cancelAll(); + } + + // Notification IDs are 32-bit ints; a room's is a stable hash of its ID so + // a new message replaces the room's previous notification. + static int _idFor(String roomId) { + var h = 0x811c9dc5; + for (final unit in roomId.codeUnits) { + h = ((h ^ unit) * 0x01000193) & 0x7fffffff; + } + return h; + } + + /// Listens to [client] for events that notify while the app is running. + static StreamSubscription watch(Client client) => + client.onNotification.stream.listen((event) { + if (kDebugMode) debugPrint('notify ${event.eventId}'); + showEvent(event); + }); +} diff --git a/apps/mobile/lib/matrix/push.dart b/apps/mobile/lib/matrix/push.dart new file mode 100644 index 0000000..2a3354e --- /dev/null +++ b/apps/mobile/lib/matrix/push.dart @@ -0,0 +1,206 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; +import 'package:matrix/matrix.dart'; +import 'package:unifiedpush/unifiedpush.dart'; + +import 'notifications.dart'; + +/// Push notifications through UnifiedPush. +/// +/// The user picks a distributor app (ntfy, or another UnifiedPush +/// distributor) which gives this app an endpoint URL. The endpoint becomes a +/// Matrix pusher: the homeserver posts each notification to the +/// distributor's Matrix gateway, the distributor wakes the app, and the app +/// fetches the event (decrypting it if it can) and shows it. +/// +/// Pushes are `event_id_only`, so neither the gateway nor the distributor +/// sees message content, not even in unencrypted rooms. +/// +/// No Google services are involved, so this works without a Firebase +/// project; the cost is that the user needs a distributor installed. +class Push { + Push._(); + + static const appId = 'io.github.dariuszjaniak.warp'; + static const _endpointKey = 'warp.push.endpoint'; + static const _storage = FlutterSecureStorage(); + + /// Where a push goes when the distributor's server has no Matrix gateway + /// of its own: the UnifiedPush project's public one, which forwards to any + /// endpoint. + static const fallbackGateway = + 'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify'; + + static Client? _client; + + /// Sets up the push receiver. Called on every start, in the foreground and + /// when a push starts the app in the background. + static Future init(Client client) async { + _client = client; + final registered = await UnifiedPush.initialize( + onNewEndpoint: (endpoint, _) => _onNewEndpoint(endpoint.url), + onUnregistered: (_) => _onUnregistered(), + onRegistrationFailed: (reason, _) => + Logs().w('UnifiedPush registration failed: $reason'), + onMessage: (message, _) => _onMessage(message), + ); + // The connector wants a register() on every start to keep its + // registration with the distributor alive. + if (registered && client.isLogged()) { + await UnifiedPush.register(features: const []); + } + } + + /// The installed distributors, by package name. + static Future> distributors() => UnifiedPush.getDistributors(); + + static Future currentDistributor() => UnifiedPush.getDistributor(); + + /// Turns push on with [distributor], or the default one when null. Returns + /// false when there is no distributor to use and the user has to pick one. + static Future enable({String? distributor}) async { + if (distributor != null) { + await UnifiedPush.saveDistributor(distributor); + } else if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) { + return false; + } + await UnifiedPush.register(features: const []); + return true; + } + + /// Turns push off: unregisters from the distributor and removes the + /// pusher from the homeserver. + static Future disable() async { + await UnifiedPush.unregister(); + await _onUnregistered(); + } + + /// Removes this device's pusher, when signing out. The homeserver also + /// removes it when the session ends, so this is best effort. + static Future forget() async { + try { + await UnifiedPush.unregister(); + } catch (_) {} + await _storage.delete(key: _endpointKey); + } + + static Future _onNewEndpoint(String endpoint) async { + final client = _client; + if (client == null || !client.isLogged()) return; + final previous = await _storage.read(key: _endpointKey); + if (previous != null && previous != endpoint) { + await _deletePusher(client, previous); + } + final gateway = await _gatewayFor(endpoint); + await client.postPusher( + Pusher( + appId: appId, + pushkey: endpoint, + appDisplayName: 'Warp', + deviceDisplayName: client.deviceName ?? 'Warp Android', + kind: 'http', + lang: 'en', + data: PusherData(url: Uri.parse(gateway), format: 'event_id_only'), + ), + append: false, + ); + await _storage.write(key: _endpointKey, value: endpoint); + Logs().i('Pusher registered with gateway $gateway'); + } + + static Future _onUnregistered() async { + final client = _client; + final endpoint = await _storage.read(key: _endpointKey); + if (client != null && endpoint != null && client.isLogged()) { + await _deletePusher(client, endpoint); + } + await _storage.delete(key: _endpointKey); + } + + static Future _deletePusher(Client client, String endpoint) async { + try { + await client.deletePusher(PusherId(appId: appId, pushkey: endpoint)); + } catch (e) { + Logs().w('Could not remove the old pusher', e); + } + } + + /// The Matrix gateway for [endpoint]. UnifiedPush's discovery: a server + /// that has one answers `GET /_matrix/push/v1/notify` with + /// `{"unifiedpush": {"gateway": "matrix"}}`. + static Future _gatewayFor(String endpoint) async { + final uri = Uri.parse(endpoint); + final candidate = uri.replace( + path: '/_matrix/push/v1/notify', + query: null, + fragment: null, + ); + try { + final response = await http + .get(candidate) + .timeout(const Duration(seconds: 10)); + if (response.statusCode == 200) { + final json = jsonDecode(response.body); + if (json is Map && + json['unifiedpush'] is Map && + json['unifiedpush']['gateway'] == 'matrix') { + return candidate.toString(); + } + } + } catch (e) { + Logs().v('No Matrix gateway at ${candidate.origin}', e); + } + return fallbackGateway; + } + + static Future _onMessage(PushMessage message) async { + final client = _client; + Map notification; + try { + final json = jsonDecode(utf8.decode(message.content)); + notification = Map.from( + (json is Map && json['notification'] is Map) + ? json['notification'] as Map + : json as Map, + ); + } catch (e) { + Logs().w('Push that is not a Matrix notification', e); + return; + } + + final push = PushNotification.fromJson(notification); + final roomId = push.roomId; + // A push with no event is the homeserver saying the counts changed: + // everything was read on another device. + if (push.eventId == null || roomId == null) { + if ((push.counts?.unread ?? 0) == 0) await Notifications.clearAll(); + return; + } + if (client == null) return; + + try { + // Null when the event has already been seen (read on another device, + // or shown from the sync while the app was running). + final event = await client.getEventByPushNotification(push); + if (event != null) await Notifications.showEvent(event); + return; + } catch (e, s) { + Logs().w('Could not fetch the pushed event', e, s); + } + // Fetching failed (no network, or the session is gone): still say + // something arrived, without content. + final room = client.getRoomById(roomId); + await Notifications.show( + roomId: roomId, + title: + room?.getLocalizedDisplayname() ?? + push.roomName ?? + push.senderDisplayName ?? + 'Warp', + body: 'New message', + ); + } +} diff --git a/apps/mobile/lib/matrix/timeline.dart b/apps/mobile/lib/matrix/timeline.dart new file mode 100644 index 0000000..210018e --- /dev/null +++ b/apps/mobile/lib/matrix/timeline.dart @@ -0,0 +1,133 @@ +import 'package:matrix/matrix.dart'; + +/// How an event shows in the timeline. +enum EntryKind { + /// A bubble: a message, sticker, or an encrypted event. + message, + + /// A one-line notice in the middle: a join, a rename, encryption turned + /// on. + notice, + + /// Not shown: edits, reactions, redactions, call signalling, state + /// nobody needs to read. + hidden, +} + +/// The state events shown as notices. Everything else in a room's state +/// (power levels, join rules, ACLs, widgets) is hidden: it is what the room +/// info screen is for. +const noticeStateTypes = { + EventTypes.RoomMember, + EventTypes.RoomName, + EventTypes.RoomTopic, + EventTypes.RoomAvatar, + EventTypes.RoomCreate, + EventTypes.Encryption, + EventTypes.RoomTombstone, +}; + +EntryKind kindOf(Event event) { + if (event.relationshipType == RelationshipTypes.edit) return EntryKind.hidden; + switch (event.type) { + case EventTypes.Message: + case EventTypes.Sticker: + case EventTypes.Encrypted: + return EntryKind.message; + } + if (noticeStateTypes.contains(event.type)) { + if (event.type == EventTypes.RoomMember && _isProfileOnlyChange(event)) { + // A display name or avatar change of somebody already in the room is + // noise in a busy room; the bubble shows the new name anyway. + return EntryKind.hidden; + } + return EntryKind.notice; + } + return EntryKind.hidden; +} + +bool _isProfileOnlyChange(Event event) { + final prev = event.prevContent; + return prev != null && + prev['membership'] == 'join' && + event.content['membership'] == 'join'; +} + +/// One row of the timeline, as the screen draws it. +sealed class TimelineItem { + const TimelineItem(); +} + +class DayItem extends TimelineItem { + const DayItem(this.day); + final DateTime day; +} + +class EventItem extends TimelineItem { + const EventItem( + this.index, { + required this.kind, + required this.firstInGroup, + required this.lastInGroup, + }); + + /// The position of the event in the list given to [projectTimeline]. + final int index; + final EntryKind kind; + + /// Starts a run of messages from one sender: shows the name. + final bool firstInGroup; + + /// Ends a run: shows the avatar and the time. + final bool lastInGroup; +} + +/// What [projectTimeline] needs to know about an event. +typedef Entry = ({String sender, DateTime time, EntryKind kind}); + +/// Messages from one sender this close together are drawn as one group. +const groupGap = Duration(minutes: 5); + +/// Turns events, newest first as the SDK keeps them, into rows, newest first +/// for a reversed list: hidden events dropped, a [DayItem] above the first +/// event of each day, and runs of messages from one sender grouped. +List projectTimeline(List entries) { + final shown = [ + for (var i = 0; i < entries.length; i++) + if (entries[i].kind != EntryKind.hidden) i, + ]; + + bool sameGroup(int a, int b) { + final x = entries[a], y = entries[b]; + return x.kind == EntryKind.message && + y.kind == EntryKind.message && + x.sender == y.sender && + _sameDay(x.time, y.time) && + x.time.difference(y.time).abs() <= groupGap; + } + + final items = []; + for (var n = 0; n < shown.length; n++) { + final i = shown[n]; + final newer = n > 0 ? shown[n - 1] : null; + final older = n + 1 < shown.length ? shown[n + 1] : null; + items.add( + EventItem( + i, + kind: entries[i].kind, + firstInGroup: older == null || !sameGroup(i, older), + lastInGroup: newer == null || !sameGroup(i, newer), + ), + ); + final time = entries[i].time.toLocal(); + if (older == null || !_sameDay(entries[older].time, entries[i].time)) { + items.add(DayItem(DateTime(time.year, time.month, time.day))); + } + } + return items; +} + +bool _sameDay(DateTime a, DateTime b) { + final x = a.toLocal(), y = b.toLocal(); + return x.year == y.year && x.month == y.month && x.day == y.day; +} diff --git a/apps/mobile/lib/screens/encryption_screen.dart b/apps/mobile/lib/screens/encryption_screen.dart new file mode 100644 index 0000000..ef003d1 --- /dev/null +++ b/apps/mobile/lib/screens/encryption_screen.dart @@ -0,0 +1,374 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/encryption.dart'; + +import '../matrix/client.dart'; +import '../matrix/encryption.dart'; +import '../state.dart'; +import '../theme.dart'; +import '../widgets/verification_sheet.dart'; + +/// Encryption for the account and this session: set it up, verify this +/// session (with the recovery key or another session), or reset it. +class EncryptionScreen extends ConsumerStatefulWidget { + const EncryptionScreen({super.key}); + + @override + ConsumerState createState() => _EncryptionScreenState(); +} + +class _EncryptionScreenState extends ConsumerState { + bool _busy = false; + String? _error; + + Future _run(Future Function() action) async { + setState(() { + _busy = true; + _error = null; + }); + try { + await action(); + } catch (e) { + if (mounted) setState(() => _error = describeError(e)); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _setUp({required bool reset}) async { + final encryption = ref.read(clientProvider).encryption!; + if (reset) { + final ok = await showDialog( + context: context, + builder: (dialog) => AlertDialog( + title: const Text('Reset encryption?'), + content: const Text( + 'Only do this if you have lost your recovery key and have no ' + 'other verified session.\n\nYou get a new recovery key. Messages ' + 'only the old keys could read stay unreadable, and the people ' + "you've verified will need to verify you again.", + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialog, false), + child: const Text('Cancel'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: context.warp.danger, + ), + onPressed: () => Navigator.pop(dialog, true), + child: const Text('Reset'), + ), + ], + ), + ); + if (ok != true) return; + } + String? recoveryKey; + await _run(() async => recoveryKey = await setUpEncryption(encryption)); + final key = recoveryKey; + if (key != null && mounted) { + await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => _RecoveryKeyScreen(recoveryKey: key), + ), + ); + if (mounted) setState(() {}); + } + } + + Future _verifyWithKey() async { + final controller = TextEditingController(); + final input = await showDialog( + context: context, + builder: (dialog) => AlertDialog( + title: const Text('Enter your recovery key'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'The key you saved when you set up encryption. It looks ' + 'like EsTc 1234 …', + ), + const SizedBox(height: 16), + TextField( + controller: controller, + autofocus: true, + autocorrect: false, + enableSuggestions: false, + minLines: 2, + maxLines: 3, + style: const TextStyle(fontFamily: fontMono), + decoration: const InputDecoration(labelText: 'Recovery key'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialog), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(dialog, controller.text), + child: const Text('Verify'), + ), + ], + ), + ); + if (input == null || input.trim().isEmpty) return; + final encryption = ref.read(clientProvider).encryption!; + await _run(() async { + try { + await verifyWithRecoveryKey(encryption, input); + } on InvalidPassphraseException { + throw Exception("That recovery key isn't right."); + } + }); + if (mounted && _error == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'This session is verified. Older messages are being restored.', + ), + ), + ); + } + } + + Future _verifyWithSession() async { + final client = ref.read(clientProvider); + await _run(() async { + final keys = client.userDeviceKeys[client.userID!]; + if (keys == null) throw Exception('Your sessions are still loading.'); + final request = await keys.startVerification(); + if (mounted) await showVerificationSheet(context, request); + }); + } + + @override + Widget build(BuildContext context) { + ref.watch(syncProvider); + final client = ref.watch(clientProvider); + final encryption = client.encryption; + final c = context.warp; + final text = Theme.of(context).textTheme; + + final List body; + if (encryption == null) { + body = [ + const _Para('End-to-end encryption is not available in this build.'), + ]; + } else if (!encryption.crossSigning.enabled) { + body = [ + const _Para( + 'Encryption keeps your messages readable only by the people in ' + 'the conversation. Set it up once and you get a recovery key: with ' + 'it, any new session you sign in to can verify itself and read ' + 'your older messages.', + ), + const SizedBox(height: 8), + const _Para( + 'You will be asked for your password. Keep the recovery key ' + 'somewhere safe, like a password manager.', + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _busy ? null : () => _setUp(reset: false), + child: const Text('Set up encryption'), + ), + ]; + } else if (client.isUnknownSession) { + body = [ + const _Para( + 'Prove this session is you, so it can read your encrypted messages ' + 'and the people you talk to trust it.', + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _busy ? null : _verifyWithSession, + icon: const Icon(Icons.devices_outlined), + label: const Text('Use another session'), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _busy ? null : _verifyWithKey, + icon: const Icon(Icons.key_outlined), + label: const Text('Use recovery key'), + ), + const SizedBox(height: 32), + TextButton( + onPressed: _busy ? null : () => _setUp(reset: true), + style: TextButton.styleFrom(foregroundColor: c.danger), + child: const Text('Lost your recovery key? Reset encryption'), + ), + ]; + } else { + body = [ + _Status( + ok: true, + title: 'This session is verified', + detail: 'It can read your encrypted messages.', + ), + _Status( + ok: encryption.keyManager.enabled, + title: encryption.keyManager.enabled + ? 'Key backup is on' + : 'Key backup is off', + detail: encryption.keyManager.enabled + ? 'Your message keys are backed up, encrypted with your ' + 'recovery key.' + : 'New sessions may not be able to read older messages.', + ), + const SizedBox(height: 32), + OutlinedButton( + onPressed: _busy ? null : () => _setUp(reset: true), + style: OutlinedButton.styleFrom(foregroundColor: c.danger), + child: const Text('Reset encryption'), + ), + ]; + } + + return Scaffold( + appBar: AppBar(title: const Text('Encryption')), + body: ListView( + padding: const EdgeInsets.all(24), + children: [ + if (_busy) ...[ + const LinearProgressIndicator(minHeight: 2), + const SizedBox(height: 16), + ], + if (_error != null) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: c.dangerSoft, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _error!, + style: text.bodyMedium?.copyWith(color: c.danger), + ), + ), + const SizedBox(height: 16), + ], + ...body, + ], + ), + ); + } +} + +class _Para extends StatelessWidget { + const _Para(this.text); + final String text; + + @override + Widget build(BuildContext context) => + Text(text, style: Theme.of(context).textTheme.bodyLarge); +} + +class _Status extends StatelessWidget { + const _Status({required this.ok, required this.title, required this.detail}); + final bool ok; + final String title; + final String detail; + + @override + Widget build(BuildContext context) { + final c = context.warp; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + ok ? Icons.check_circle_outline : Icons.error_outline, + color: ok ? c.tealText : c.warning, + ), + title: Text(title), + subtitle: Text(detail), + ); + } +} + +/// The new recovery key, shown once. The user has to say they saved it. +class _RecoveryKeyScreen extends StatefulWidget { + const _RecoveryKeyScreen({required this.recoveryKey}); + final String recoveryKey; + + @override + State<_RecoveryKeyScreen> createState() => _RecoveryKeyScreenState(); +} + +class _RecoveryKeyScreenState extends State<_RecoveryKeyScreen> { + bool _saved = false; + + @override + Widget build(BuildContext context) { + final c = context.warp; + final text = Theme.of(context).textTheme; + return PopScope( + canPop: _saved, + child: Scaffold( + appBar: AppBar( + title: const Text('Your recovery key'), + automaticallyImplyLeading: false, + ), + body: ListView( + padding: const EdgeInsets.all(24), + children: [ + Text( + 'Save this key somewhere safe, like a password manager. It is ' + 'the only way to read your encrypted messages on a new session ' + "if you aren't signed in anywhere else. It won't be shown again.", + style: text.bodyLarge, + ), + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: c.surfaceSunken, + border: Border.all(color: c.line), + borderRadius: BorderRadius.circular(12), + ), + child: SelectableText( + widget.recoveryKey, + textAlign: TextAlign.center, + style: text.titleMedium?.copyWith( + fontFamily: fontMono, + letterSpacing: 1, + height: 1.6, + ), + ), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: () { + Clipboard.setData(ClipboardData(text: widget.recoveryKey)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Recovery key copied')), + ); + }, + icon: const Icon(Icons.copy), + label: const Text('Copy'), + ), + const SizedBox(height: 24), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + value: _saved, + onChanged: (v) => setState(() => _saved = v ?? false), + title: const Text("I've saved my recovery key"), + controlAffinity: ListTileControlAffinity.leading, + ), + const SizedBox(height: 12), + FilledButton( + onPressed: _saved ? () => Navigator.pop(context) : null, + child: const Text('Done'), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/lib/screens/home_screen.dart b/apps/mobile/lib/screens/home_screen.dart new file mode 100644 index 0000000..54a23f3 --- /dev/null +++ b/apps/mobile/lib/screens/home_screen.dart @@ -0,0 +1,553 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; + +import '../util/format.dart'; +import '../matrix/client.dart'; +import '../state.dart'; +import '../theme.dart'; +import '../widgets/avatar.dart'; +import '../widgets/new_chat_sheet.dart'; +import 'encryption_screen.dart'; +import 'room_screen.dart'; +import 'settings_screen.dart'; + +enum _Filter { all, unread, people, rooms } + +class HomeScreen extends ConsumerStatefulWidget { + const HomeScreen({super.key}); + + @override + ConsumerState createState() => _HomeScreenState(); +} + +class _HomeScreenState extends ConsumerState { + _Filter _filter = _Filter.all; + final _search = TextEditingController(); + bool _searching = false; + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + bool _matches(Room room) { + if (room.isSpace) return false; + final query = _search.text.trim().toLowerCase(); + if (query.isNotEmpty && + !room.getLocalizedDisplayname().toLowerCase().contains(query) && + !room.canonicalAlias.toLowerCase().contains(query)) { + return false; + } + return switch (_filter) { + _Filter.all => true, + _Filter.unread => room.isUnreadOrInvited, + _Filter.people => room.isDirectChat, + _Filter.rooms => !room.isDirectChat, + }; + } + + @override + Widget build(BuildContext context) { + ref.watch(syncProvider); + final client = ref.watch(clientProvider); + final offline = ref.watch(syncErrorProvider).value == true; + final rooms = client.rooms.where(_matches).toList(); + final invites = rooms + .where((r) => r.membership == Membership.invite) + .toList(); + final joined = rooms.where((r) => r.membership == Membership.join).toList(); + + return Scaffold( + appBar: AppBar( + titleSpacing: 16, + title: _searching + ? TextField( + controller: _search, + autofocus: true, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + hintText: 'Search rooms', + isDense: true, + ), + ) + : Row( + children: [ + Image.asset( + 'assets/logo/warp-symbol.png', + width: 28, + height: 28, + ), + const SizedBox(width: 10), + const Text('Chats'), + ], + ), + actions: [ + IconButton( + tooltip: _searching ? 'Close search' : 'Search', + icon: Icon(_searching ? Icons.close : Icons.search), + onPressed: () => setState(() { + _searching = !_searching; + if (!_searching) _search.clear(); + }), + ), + Padding( + padding: const EdgeInsets.only(right: 8), + child: IconButton( + tooltip: 'Settings', + icon: _OwnAvatar(client: client), + onPressed: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const SettingsScreen())), + ), + ), + ], + ), + floatingActionButton: FloatingActionButton( + tooltip: 'New chat', + onPressed: () => showNewChatSheet(context), + child: const Icon(Icons.edit_outlined), + ), + body: Column( + children: [ + if (offline) + const _Banner( + icon: Icons.cloud_off_outlined, + text: "Can't reach the server. Retrying…", + ), + _SecurityBanner(client: client), + SizedBox( + height: 52, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + children: [ + for (final f in _Filter.values) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: ChoiceChip( + label: Text(switch (f) { + _Filter.all => 'All', + _Filter.unread => 'Unread', + _Filter.people => 'People', + _Filter.rooms => 'Rooms', + }), + selected: _filter == f, + showCheckmark: false, + onSelected: (_) => setState(() => _filter = f), + ), + ), + ], + ), + ), + Expanded( + child: rooms.isEmpty + ? _Empty( + loading: client.prevBatch == null, + filtered: _filter != _Filter.all || _search.text.isNotEmpty, + ) + : ListView( + padding: const EdgeInsets.only(bottom: 88), + children: [ + if (invites.isNotEmpty) ...[ + const _SectionHeader('Invites'), + for (final room in invites) _InviteTile(room: room), + if (joined.isNotEmpty) const _SectionHeader('Chats'), + ], + for (final room in joined) RoomTile(room: room), + ], + ), + ), + ], + ), + ); + } +} + +class _OwnAvatar extends StatelessWidget { + const _OwnAvatar({required this.client}); + final Client client; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: client.fetchOwnProfile(), + builder: (context, snapshot) { + final profile = snapshot.data; + return Avatar( + name: profile?.displayName ?? client.userID ?? '?', + seed: client.userID ?? '', + mxc: profile?.avatarUrl, + size: 32, + ); + }, + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader(this.text); + final String text; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + text.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + letterSpacing: 0.6, + fontWeight: FontWeight.w600, + ), + ), + ); +} + +class _Banner extends StatelessWidget { + const _Banner({ + required this.icon, + required this.text, + this.action, + this.onTap, + this.tone = _Tone.warning, + }); + final IconData icon; + final String text; + final String? action; + final VoidCallback? onTap; + final _Tone tone; + + @override + Widget build(BuildContext context) { + final c = context.warp; + final (bg, fg) = switch (tone) { + _Tone.warning => (c.warningSoft, c.warning), + _Tone.info => (c.tealSoft, c.tealText), + }; + return Material( + color: bg, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + Icon(icon, size: 20, color: fg), + const SizedBox(width: 12), + Expanded( + child: Text( + text, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: fg), + ), + ), + if (action != null) + Text( + action!, + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: fg), + ), + ], + ), + ), + ), + ); + } +} + +enum _Tone { warning, info } + +/// Asks for encryption to be set up on an account that has none, or for +/// this session to be verified when the account has it and this session +/// can't yet read the older messages. +class _SecurityBanner extends StatelessWidget { + const _SecurityBanner({required this.client}); + final Client client; + + @override + Widget build(BuildContext context) { + final encryption = client.encryption; + if (encryption == null || client.prevBatch == null) { + return const SizedBox.shrink(); + } + void open() => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const EncryptionScreen())); + if (!encryption.crossSigning.enabled) { + return _Banner( + icon: Icons.key_outlined, + text: + 'Set up encryption to keep your messages readable on new ' + 'sessions.', + action: 'Set up', + tone: _Tone.info, + onTap: open, + ); + } + if (client.isUnknownSession) { + return _Banner( + icon: Icons.verified_user_outlined, + text: 'Verify this session to read your encrypted messages.', + action: 'Verify', + onTap: open, + ); + } + return const SizedBox.shrink(); + } +} + +class _Empty extends StatelessWidget { + const _Empty({required this.loading, required this.filtered}); + final bool loading; + final bool filtered; + + @override + Widget build(BuildContext context) { + if (loading) return const Center(child: CircularProgressIndicator()); + final text = Theme.of(context).textTheme; + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.forum_outlined, + size: 48, + color: context.warp.lineStrong, + ), + const SizedBox(height: 16), + Text( + filtered ? 'Nothing here' : 'No chats yet', + style: text.titleMedium, + ), + const SizedBox(height: 4), + Text( + filtered + ? 'No rooms match.' + : 'Start a chat with someone, or create or join a room.', + textAlign: TextAlign.center, + style: text.bodySmall, + ), + ], + ), + ), + ); + } +} + +class RoomTile extends StatelessWidget { + const RoomTile({super.key, required this.room}); + final Room room; + + @override + Widget build(BuildContext context) { + final c = context.warp; + final text = Theme.of(context).textTheme; + final last = room.lastEvent; + final unread = room.notificationCount; + final highlight = room.highlightCount > 0; + final bold = room.isUnread; + final preview = last == null + ? '' + : last.calcLocalizedBodyFallback( + const MatrixDefaultLocalizations(), + withSenderNamePrefix: !room.isDirectChat, + hideReply: true, + hideEdit: true, + plaintextBody: false, + removeMarkdown: true, + ); + + return InkWell( + onTap: () => Navigator.of(context).push(RoomScreen.route(room.id)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + Avatar( + name: room.getLocalizedDisplayname(), + seed: room.id, + mxc: room.avatar, + size: 48, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + room.getLocalizedDisplayname(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: text.titleSmall?.copyWith( + fontSize: 15, + fontWeight: bold + ? FontWeight.w700 + : FontWeight.w600, + ), + ), + ), + if (last != null) + Text( + formatListTime(last.originServerTs), + style: text.labelSmall?.copyWith( + color: bold ? c.tealText : c.inkMuted, + ), + ), + ], + ), + const SizedBox(height: 2), + Row( + children: [ + if (room.encrypted) ...[ + Icon(Icons.lock_outline, size: 13, color: c.inkMuted), + const SizedBox(width: 4), + ], + Expanded( + child: Text( + preview, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: text.bodySmall?.copyWith( + color: bold ? c.ink : c.inkMuted, + ), + ), + ), + if (unread > 0 || room.markedUnread) + Container( + margin: const EdgeInsets.only(left: 8), + padding: const EdgeInsets.symmetric( + horizontal: 7, + vertical: 2, + ), + constraints: const BoxConstraints(minWidth: 20), + decoration: BoxDecoration( + color: highlight ? c.danger : c.tealStrong, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + unread > 99 + ? '99+' + : unread > 0 + ? '$unread' + : '', + textAlign: TextAlign.center, + style: text.labelMedium?.copyWith( + color: highlight ? Colors.white : c.onTeal, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _InviteTile extends ConsumerStatefulWidget { + const _InviteTile({required this.room}); + final Room room; + + @override + ConsumerState<_InviteTile> createState() => _InviteTileState(); +} + +class _InviteTileState extends ConsumerState<_InviteTile> { + bool _busy = false; + + Future _act(Future Function() action) async { + setState(() => _busy = true); + try { + await action(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(describeError(e)))); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final room = widget.room; + final text = Theme.of(context).textTheme; + final inviter = room + .getState(EventTypes.RoomMember, room.client.userID!) + ?.senderId; + final inviterName = inviter == null + ? null + : room.unsafeGetUserFromMemoryOrFallback(inviter).calcDisplayname(); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + Avatar( + name: room.getLocalizedDisplayname(), + seed: room.id, + mxc: room.avatar, + size: 48, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + room.getLocalizedDisplayname(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: text.titleSmall?.copyWith(fontSize: 15), + ), + Text( + inviterName == null + ? 'Invited you' + : '$inviterName invited you', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: text.bodySmall, + ), + ], + ), + ), + if (_busy) + const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else ...[ + IconButton( + tooltip: 'Decline', + icon: const Icon(Icons.close), + onPressed: () => _act(room.leave), + ), + IconButton.filled( + tooltip: 'Join', + icon: const Icon(Icons.check), + onPressed: () => _act(() async { + await room.join(); + if (context.mounted) { + Navigator.of(context).push(RoomScreen.route(room.id)); + } + }), + ), + ], + ], + ), + ); + } +} diff --git a/apps/mobile/lib/screens/login_screen.dart b/apps/mobile/lib/screens/login_screen.dart new file mode 100644 index 0000000..65b13ee --- /dev/null +++ b/apps/mobile/lib/screens/login_screen.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; + +import '../matrix/client.dart'; +import '../state.dart'; +import '../theme.dart'; +import 'register_screen.dart'; + +class LoginScreen extends ConsumerStatefulWidget { + const LoginScreen({super.key}); + + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + final _server = TextEditingController(); + final _username = TextEditingController(); + final _password = TextEditingController(); + bool _busy = false; + String? _error; + + @override + void dispose() { + _server.dispose(); + _username.dispose(); + _password.dispose(); + super.dispose(); + } + + Future _signIn() async { + final client = ref.read(clientProvider); + final username = _username.text.trim(); + final server = _server.text.trim().isNotEmpty + ? _server.text.trim() + : serverOfUserId(username); + if (server == null) { + setState(() => _error = 'Enter your server, or your full user ID.'); + return; + } + if (username.isEmpty || _password.text.isEmpty) { + setState(() => _error = 'Enter your username and password.'); + return; + } + setState(() { + _busy = true; + _error = null; + }); + try { + await resolveHomeserver(client, server); + await client.login( + LoginType.mLoginPassword, + identifier: AuthenticationUserIdentifier(user: username), + password: _password.text, + initialDeviceDisplayName: deviceDisplayName, + ); + // The app switches to the room list when the login state changes. + } catch (e) { + if (!mounted) return; + setState(() { + _busy = false; + _error = e is MatrixException && e.error == MatrixError.M_FORBIDDEN + ? 'Wrong username or password.' + : describeError(e); + }); + } + } + + @override + Widget build(BuildContext context) { + return AuthLayout( + title: 'Sign in', + subtitle: 'Use your Matrix account on any server.', + error: _error, + children: [ + TextField( + controller: _server, + enabled: !_busy, + keyboardType: TextInputType.url, + autocorrect: false, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Server', + hintText: 'example.org', + helperText: 'Leave empty if you type your full user ID below.', + ), + ), + const SizedBox(height: 16), + AutofillGroup( + child: Column( + children: [ + TextField( + controller: _username, + enabled: !_busy, + autocorrect: false, + autofillHints: const [AutofillHints.username], + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Username', + hintText: 'alice or @alice:example.org', + ), + ), + const SizedBox(height: 16), + TextField( + controller: _password, + enabled: !_busy, + obscureText: true, + autofillHints: const [AutofillHints.password], + textInputAction: TextInputAction.done, + onSubmitted: (_) => _signIn(), + decoration: const InputDecoration(labelText: 'Password'), + ), + ], + ), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _busy ? null : _signIn, + child: _busy + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Sign in'), + ), + const SizedBox(height: 12), + TextButton( + onPressed: _busy + ? null + : () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => RegisterScreen(server: _server.text), + ), + ), + child: const Text('Create an account'), + ), + ], + ); + } +} + +/// The frame of the sign-in and sign-up screens: logo, title, form. +class AuthLayout extends StatelessWidget { + const AuthLayout({ + super.key, + required this.title, + required this.subtitle, + required this.children, + this.error, + }); + + final String title; + final String subtitle; + final String? error; + final List children; + + @override + Widget build(BuildContext context) { + final c = context.warp; + final text = Theme.of(context).textTheme; + return Scaffold( + appBar: Navigator.of(context).canPop() + ? AppBar(shape: const Border()) + : null, + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Image.asset( + 'assets/logo/warp-symbol.png', + width: 40, + height: 40, + ), + const SizedBox(width: 12), + Text( + 'Warp', + style: text.headlineMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 32), + Text(title, style: text.titleLarge), + const SizedBox(height: 4), + Text(subtitle, style: text.bodySmall), + const SizedBox(height: 24), + if (error != null) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: c.dangerSoft, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + error!, + style: text.bodyMedium?.copyWith(color: c.danger), + ), + ), + const SizedBox(height: 16), + ], + ...children, + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/mobile/lib/screens/register_screen.dart b/apps/mobile/lib/screens/register_screen.dart new file mode 100644 index 0000000..b30b003 --- /dev/null +++ b/apps/mobile/lib/screens/register_screen.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; + +import '../matrix/client.dart'; +import '../state.dart'; +import 'login_screen.dart'; + +/// The registration token stage (Matrix 1.2). +const _tokenStage = 'm.login.registration_token'; + +/// Stages the app can complete. Anything else (a captcha, an email) needs a +/// browser; such a server's sign-up is done elsewhere. +const _supportedStages = {AuthenticationTypes.dummy, _tokenStage}; + +class RegisterScreen extends ConsumerStatefulWidget { + const RegisterScreen({super.key, this.server = ''}); + final String server; + + @override + ConsumerState createState() => _RegisterScreenState(); +} + +class _RegisterScreenState extends ConsumerState { + late final _server = TextEditingController(text: widget.server); + final _username = TextEditingController(); + final _password = TextEditingController(); + final _confirm = TextEditingController(); + final _token = TextEditingController(); + bool _busy = false; + bool _needsToken = false; + String? _error; + + @override + void dispose() { + for (final c in [_server, _username, _password, _confirm, _token]) { + c.dispose(); + } + super.dispose(); + } + + Future _signUp() async { + final client = ref.read(clientProvider); + final username = _username.text.trim().replaceFirst(RegExp(r'^@'), ''); + String? problem; + if (_server.text.trim().isEmpty) { + problem = 'Enter the server to sign up on.'; + } else if (username.isEmpty) { + problem = 'Choose a username.'; + } else if (_password.text.length < 8) { + problem = 'Use a password of at least 8 characters.'; + } else if (_password.text != _confirm.text) { + problem = "The passwords don't match."; + } else if (_needsToken && _token.text.trim().isEmpty) { + problem = 'Enter your registration token.'; + } + if (problem != null) { + setState(() => _error = problem); + return; + } + + setState(() { + _busy = true; + _error = null; + }); + try { + await resolveHomeserver(client, _server.text); + await _register(client, username); + } catch (e) { + if (!mounted) return; + setState(() { + _busy = false; + _error = describeError(e); + }); + } + } + + /// Registers, walking the server's user-interactive authentication: the + /// first attempt returns the flows, then each stage of a flow the app can + /// do is completed in turn. + Future _register(Client client, String username) async { + AuthenticationData? auth; + for (var attempt = 0; attempt < 5; attempt++) { + try { + await client.register( + username: username, + password: _password.text, + initialDeviceDisplayName: deviceDisplayName, + auth: auth, + ); + return; + } on MatrixException catch (e) { + if (!e.requireAdditionalAuthentication) rethrow; + final flows = e.authenticationFlows ?? []; + final done = e.completedAuthenticationFlows; + final flow = flows + .where((f) => f.stages.every(_supportedStages.contains)) + .firstOrNull; + if (flow == null) { + if (flows.isEmpty) rethrow; + throw Exception( + 'This server asks for a sign-up step Warp can\'t do yet. ' + 'Sign up on the server\'s website, then sign in here.', + ); + } + final next = flow.stages.where((s) => !done.contains(s)).firstOrNull; + if (next == null) rethrow; + if (next == _tokenStage) { + final token = _token.text.trim(); + if (token.isEmpty) { + setState(() => _needsToken = true); + throw Exception('This server needs a registration token.'); + } + auth = AuthenticationData( + type: _tokenStage, + session: e.session, + additionalFields: {'token': token}, + ); + } else { + auth = AuthenticationData(type: next, session: e.session); + } + } + } + throw Exception('Sign-up did not finish. Try again.'); + } + + @override + Widget build(BuildContext context) { + return AuthLayout( + title: 'Create an account', + subtitle: 'Your user ID will be @username:server.', + error: _error, + children: [ + TextField( + controller: _server, + enabled: !_busy, + keyboardType: TextInputType.url, + autocorrect: false, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Server', + hintText: 'example.org', + ), + ), + const SizedBox(height: 16), + TextField( + controller: _username, + enabled: !_busy, + autocorrect: false, + autofillHints: const [AutofillHints.newUsername], + textInputAction: TextInputAction.next, + decoration: const InputDecoration(labelText: 'Username'), + ), + const SizedBox(height: 16), + TextField( + controller: _password, + enabled: !_busy, + obscureText: true, + autofillHints: const [AutofillHints.newPassword], + textInputAction: TextInputAction.next, + decoration: const InputDecoration(labelText: 'Password'), + ), + const SizedBox(height: 16), + TextField( + controller: _confirm, + enabled: !_busy, + obscureText: true, + textInputAction: _needsToken + ? TextInputAction.next + : TextInputAction.done, + onSubmitted: _needsToken ? null : (_) => _signUp(), + decoration: const InputDecoration(labelText: 'Confirm password'), + ), + if (_needsToken) ...[ + const SizedBox(height: 16), + TextField( + controller: _token, + enabled: !_busy, + autofocus: true, + autocorrect: false, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _signUp(), + decoration: const InputDecoration( + labelText: 'Registration token', + helperText: "The server's admin gives these out.", + ), + ), + ], + const SizedBox(height: 24), + FilledButton( + onPressed: _busy ? null : _signUp, + child: _busy + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Create account'), + ), + ], + ); + } +} diff --git a/apps/mobile/lib/screens/room_info_screen.dart b/apps/mobile/lib/screens/room_info_screen.dart new file mode 100644 index 0000000..add85a2 --- /dev/null +++ b/apps/mobile/lib/screens/room_info_screen.dart @@ -0,0 +1,425 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; + +import '../matrix/client.dart'; +import '../state.dart'; +import '../theme.dart'; +import '../widgets/avatar.dart'; +import '../widgets/verification_sheet.dart'; + +class RoomInfoScreen extends ConsumerStatefulWidget { + const RoomInfoScreen({super.key, required this.roomId}); + final String roomId; + + @override + ConsumerState createState() => _RoomInfoScreenState(); +} + +class _RoomInfoScreenState extends ConsumerState { + late Future> _members; + + Room get _room => ref.read(clientProvider).getRoomById(widget.roomId)!; + + @override + void initState() { + super.initState(); + _members = _room.requestParticipants(); + } + + void _snack(Object error) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(describeError(error)))); + } + + Future _ask(String title, String label, {String initial = ''}) { + final controller = TextEditingController(text: initial); + return showDialog( + context: context, + builder: (dialog) => AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration(labelText: label), + onSubmitted: (v) => Navigator.pop(dialog, v), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialog), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(dialog, controller.text), + child: const Text('Save'), + ), + ], + ), + ); + } + + Future _invite() async { + final room = _room; + var id = (await _ask('Invite someone', 'User ID'))?.trim(); + if (id == null || id.isEmpty) return; + if (!id.startsWith('@')) id = '@$id'; + if (!id.contains(':')) id = '$id:${room.client.userID!.domain}'; + try { + await room.invite(id); + setState(() => _members = room.requestParticipants()); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Invited $id'))); + } + } catch (e) { + _snack(e); + } + } + + Future _leave() async { + final room = _room; + final ok = await showDialog( + context: context, + builder: (dialog) => AlertDialog( + title: const Text('Leave room?'), + content: Text( + room.joinRules == JoinRules.public + ? 'You can join again later.' + : "It's private: you'll need a new invite to come back.", + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialog, false), + child: const Text('Cancel'), + ), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: context.warp.danger), + onPressed: () => Navigator.pop(dialog, true), + child: const Text('Leave'), + ), + ], + ), + ); + if (ok != true) return; + try { + await room.leave(); + if (mounted) Navigator.of(context).popUntil((r) => r.isFirst); + } catch (e) { + _snack(e); + } + } + + Future _enableEncryption() async { + final ok = await showDialog( + context: context, + builder: (dialog) => AlertDialog( + title: const Text('Turn on encryption?'), + content: const Text( + "Once it's on, it can't be turned off. Messages sent before stay " + 'unencrypted.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialog, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(dialog, true), + child: const Text('Turn on'), + ), + ], + ), + ); + if (ok != true) return; + try { + await _room.enableEncryption(); + } catch (e) { + _snack(e); + } + } + + @override + Widget build(BuildContext context) { + ref.watch(syncProvider); + final client = ref.watch(clientProvider); + final room = client.getRoomById(widget.roomId); + if (room == null) return Scaffold(appBar: AppBar()); + final c = context.warp; + final text = Theme.of(context).textTheme; + final name = room.getLocalizedDisplayname(); + + return Scaffold( + appBar: AppBar(title: const Text('Room info')), + body: ListView( + children: [ + const SizedBox(height: 24), + Center( + child: Avatar( + name: name, + seed: room.id, + mxc: room.avatar, + size: 88, + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + name, + textAlign: TextAlign.center, + style: text.headlineSmall, + ), + ), + if (room.canonicalAlias.isNotEmpty) + Text( + room.canonicalAlias, + textAlign: TextAlign.center, + style: text.bodySmall?.copyWith(fontFamily: fontMono), + ), + if (room.topic.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 0), + child: Text( + room.topic, + textAlign: TextAlign.center, + style: text.bodyMedium, + ), + ), + const SizedBox(height: 16), + const Divider(), + ListTile( + leading: Icon( + room.encrypted ? Icons.lock_outline : Icons.lock_open_outlined, + color: room.encrypted ? c.tealText : c.warning, + ), + title: Text( + room.encrypted ? 'End-to-end encrypted' : 'Not encrypted', + ), + subtitle: Text( + room.encrypted + ? 'Only the people in the room can read its messages.' + : 'The server can read messages in this room.', + ), + trailing: + !room.encrypted && + client.encryptionEnabled && + room.canChangeStateEvent(EventTypes.Encryption) + ? TextButton( + onPressed: _enableEncryption, + child: const Text('Turn on'), + ) + : null, + ), + ListTile( + leading: const Icon(Icons.notifications_outlined), + title: const Text('Notifications'), + trailing: DropdownButton( + value: room.pushRuleState, + underline: const SizedBox.shrink(), + items: const [ + DropdownMenuItem( + value: PushRuleState.notify, + child: Text('All messages'), + ), + DropdownMenuItem( + value: PushRuleState.mentionsOnly, + child: Text('Mentions only'), + ), + DropdownMenuItem( + value: PushRuleState.dontNotify, + child: Text('Off'), + ), + ], + onChanged: (v) { + if (v != null) room.setPushRuleState(v).catchError(_snack); + }, + ), + ), + if (room.canChangeStateEvent(EventTypes.RoomName)) + ListTile( + leading: const Icon(Icons.edit_outlined), + title: const Text('Rename'), + onTap: () async { + final value = await _ask('Rename room', 'Name', initial: name); + if (value != null && value.trim().isNotEmpty) { + room.setName(value.trim()).then((_) {}, onError: _snack); + } + }, + ), + if (room.canChangeStateEvent(EventTypes.RoomTopic)) + ListTile( + leading: const Icon(Icons.subject), + title: const Text('Change topic'), + onTap: () async { + final value = await _ask('Topic', 'Topic', initial: room.topic); + if (value != null) { + room + .setDescription(value.trim()) + .then((_) {}, onError: _snack); + } + }, + ), + const Divider(), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 4), + child: Row( + children: [ + Expanded(child: Text('Members', style: text.titleMedium)), + if (room.canInvite) + TextButton.icon( + onPressed: _invite, + icon: const Icon(Icons.person_add_alt_1_outlined), + label: const Text('Invite'), + ), + ], + ), + ), + FutureBuilder>( + future: _members, + builder: (context, snapshot) { + final members = snapshot.data; + if (members == null) { + return const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ); + } + members.sort((a, b) { + final p = b.powerLevel.level.compareTo(a.powerLevel.level); + return p != 0 + ? p + : a.calcDisplayname().toLowerCase().compareTo( + b.calcDisplayname().toLowerCase(), + ); + }); + return Column( + children: [ + for (final m in members) _MemberTile(user: m, room: room), + ], + ); + }, + ), + const Divider(), + ListTile( + leading: Icon(Icons.logout, color: c.danger), + title: Text('Leave room', style: TextStyle(color: c.danger)), + onTap: _leave, + ), + const SizedBox(height: 24), + ], + ), + ); + } +} + +class _MemberTile extends StatelessWidget { + const _MemberTile({required this.user, required this.room}); + final User user; + final Room room; + + @override + Widget build(BuildContext context) { + final c = context.warp; + final client = room.client; + final keys = client.userDeviceKeys[user.id]; + final me = user.id == client.userID; + final verified = keys?.verified == UserVerifiedStatus.verified; + final role = switch (user.powerLevel.role) { + PowerLevelRole.owner || PowerLevelRole.admin => 'Admin', + PowerLevelRole.moderator => 'Moderator', + PowerLevelRole.user => null, + }; + return ListTile( + leading: Avatar( + name: user.calcDisplayname(), + seed: user.id, + mxc: user.avatarUrl, + ), + title: Row( + children: [ + Flexible( + child: Text( + user.calcDisplayname(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (room.encrypted && keys != null && !me) ...[ + const SizedBox(width: 6), + Icon( + verified ? Icons.verified_user : Icons.shield_outlined, + size: 16, + color: verified ? c.tealText : c.inkMuted, + ), + ], + ], + ), + subtitle: Text( + [ + user.id, + if (user.membership == Membership.invite) 'invited', + ?role, + ].join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: me || keys == null || !client.encryptionEnabled + ? null + : () => _showMember(context, keys, verified), + ); + } + + Future _showMember( + BuildContext context, + DeviceKeysList keys, + bool verified, + ) async { + final verify = await showModalBottomSheet( + context: context, + builder: (sheet) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + user.calcDisplayname(), + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + verified + ? "You've verified ${user.calcDisplayname()}. Messages " + 'from their verified sessions are trusted.' + : 'Verify ${user.calcDisplayname()} to be sure their ' + 'messages come from them. You compare emoji on ' + 'both your screens, ideally in person or on a call.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () => Navigator.pop(sheet, true), + child: Text(verified ? 'Verify again' : 'Verify'), + ), + ], + ), + ), + ), + ); + if (verify != true || !context.mounted) return; + try { + final request = await keys.startVerification(); + if (context.mounted) await showVerificationSheet(context, request); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(describeError(e)))); + } + } + } +} diff --git a/apps/mobile/lib/screens/room_screen.dart b/apps/mobile/lib/screens/room_screen.dart new file mode 100644 index 0000000..5b2ef7f --- /dev/null +++ b/apps/mobile/lib/screens/room_screen.dart @@ -0,0 +1,708 @@ +import 'dart:async'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:matrix/matrix.dart'; + +import '../matrix/client.dart'; +import '../matrix/notifications.dart'; +import '../matrix/timeline.dart'; +import '../state.dart'; +import '../theme.dart'; +import '../util/format.dart'; +import '../widgets/avatar.dart'; +import '../widgets/message.dart'; +import 'room_info_screen.dart'; + +class RoomScreen extends ConsumerStatefulWidget { + const RoomScreen({super.key, required this.roomId}); + final String roomId; + + static Route route(String roomId) => MaterialPageRoute( + settings: RouteSettings(name: 'room/$roomId'), + builder: (_) => RoomScreen(roomId: roomId), + ); + + @override + ConsumerState createState() => _RoomScreenState(); +} + +class _RoomScreenState extends ConsumerState + with WidgetsBindingObserver { + Timeline? _timeline; + Object? _loadError; + final _scroll = ScrollController(); + final _composer = TextEditingController(); + final _focus = FocusNode(); + bool _loadingHistory = false; + Timer? _typingTimer; + bool _typing = false; + StreamSubscription? _syncSub; + + Room? get _room => ref.read(clientProvider).getRoomById(widget.roomId); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + Notifications.activeRoomId = widget.roomId; + Notifications.clearRoom(widget.roomId); + _scroll.addListener(_onScroll); + _composer.addListener(_onComposerChanged); + _syncSub = ref.read(clientProvider).onSync.stream.listen((_) { + if (mounted) setState(() {}); + }); + _load(); + } + + Future _load() async { + final room = _room; + if (room == null) return; + try { + final timeline = await room.getTimeline( + onUpdate: _onTimelineUpdate, + onNewEvent: _markRead, + ); + if (!mounted) { + timeline.cancelSubscriptions(); + return; + } + setState(() => _timeline = timeline); + _markRead(); + // A short room may not fill the screen: fetch more until it does. + WidgetsBinding.instance.addPostFrameCallback((_) => _onScroll()); + } catch (e) { + if (mounted) setState(() => _loadError = e); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + if (Notifications.activeRoomId == widget.roomId) { + Notifications.activeRoomId = null; + } + _timeline?.cancelSubscriptions(); + _syncSub?.cancel(); + _typingTimer?.cancel(); + if (_typing) _room?.setTyping(false); + _scroll.dispose(); + _composer.dispose(); + _focus.dispose(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) _markRead(); + } + + void _onTimelineUpdate() { + if (mounted) setState(() {}); + } + + /// Sends a read receipt for the newest event, while the room is on screen + /// and the app is in front. + void _markRead() { + final timeline = _timeline; + final room = _room; + if (timeline == null || room == null) return; + if (WidgetsBinding.instance.lifecycleState != AppLifecycleState.resumed) { + return; + } + if (room.membership != Membership.join) return; + if (room.notificationCount == 0 && + !room.markedUnread && + room.receiptState.global.latestOwnReceipt?.eventId == + timeline.events.firstOrNull?.eventId) { + return; + } + if (room.markedUnread) room.markUnread(false); + timeline.setReadMarker().catchError((Object e) { + Logs().w('Could not send a read receipt', e); + }); + Notifications.clearRoom(room.id); + } + + void _onScroll() { + final timeline = _timeline; + if (timeline == null || _loadingHistory || !timeline.canRequestHistory) { + return; + } + if (!_scroll.hasClients) return; + final position = _scroll.position; + if (position.pixels < position.maxScrollExtent - 600) return; + setState(() => _loadingHistory = true); + timeline + .requestHistory() + .catchError((Object e) => Logs().w('Could not load history', e)) + .whenComplete(() { + if (mounted) setState(() => _loadingHistory = false); + }); + } + + void _onComposerChanged() { + final room = _room; + if (room == null) return; + final hasText = _composer.text.trim().isNotEmpty; + if (hasText && !_typing) { + _typing = true; + room.setTyping(true, timeout: 30000).catchError((_) {}); + } + _typingTimer?.cancel(); + if (hasText) { + _typingTimer = Timer(const Duration(seconds: 5), _stopTyping); + } else { + _stopTyping(); + } + setState(() {}); + } + + void _stopTyping() { + if (!_typing) return; + _typing = false; + _room?.setTyping(false).catchError((_) {}); + } + + Future _send() async { + final room = _room; + final text = _composer.text.trim(); + if (room == null || text.isEmpty) return; + _composer.clear(); + _stopTyping(); + try { + // The pending message shows at once as "Sending"; a failure leaves it + // in the timeline with Retry and Delete. + await room.sendTextEvent(text); + } catch (e) { + _snack(describeError(e)); + } + } + + Future _sendFile(MatrixFile file) async { + final room = _room; + if (room == null) return; + try { + await room.sendFileEvent( + file, + shrinkImageMaxDimension: 1600, + extraContent: {'body': file.name}, + ); + } catch (e) { + _snack(describeError(e)); + } + } + + Future _attach() async { + final choice = await showModalBottomSheet( + context: context, + builder: (sheet) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.photo_library_outlined), + title: const Text('Photo or video'), + onTap: () => Navigator.pop(sheet, 'gallery'), + ), + ListTile( + leading: const Icon(Icons.photo_camera_outlined), + title: const Text('Take a photo'), + onTap: () => Navigator.pop(sheet, 'camera'), + ), + ListTile( + leading: const Icon(Icons.insert_drive_file_outlined), + title: const Text('File'), + onTap: () => Navigator.pop(sheet, 'file'), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + if (choice == null) return; + try { + if (choice == 'file') { + for (final f in await FilePicker.pickFiles()) { + final bytes = await f.xFile.readAsBytes(); + await _sendFile(MatrixFile.fromMimeType(bytes: bytes, name: f.name)); + } + return; + } + final picker = ImagePicker(); + final picked = choice == 'camera' + ? [ + ?await picker.pickImage( + source: ImageSource.camera, + imageQuality: 90, + ), + ] + : await picker.pickMultipleMedia(imageQuality: 90); + for (final x in picked) { + final bytes = await x.readAsBytes(); + await _sendFile( + MatrixFile.fromMimeType( + bytes: bytes, + name: x.name, + mimeType: x.mimeType, + ), + ); + } + } on PlatformException catch (e) { + _snack(e.message ?? "Couldn't open that."); + } + } + + void _snack(String text) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); + } + + @override + Widget build(BuildContext context) { + final room = _room; + if (room == null) { + return Scaffold( + appBar: AppBar(), + body: const Center(child: Text("This room isn't available.")), + ); + } + return Scaffold( + appBar: _RoomAppBar(room: room), + body: Column( + children: [ + Expanded(child: _buildTimeline(room)), + if (room.membership == Membership.invite) + _InviteBar(room: room) + else if (room.membership == Membership.join) + room.canSendDefaultMessages + ? _Composer( + controller: _composer, + focus: _focus, + encrypted: room.encrypted, + onSend: _send, + onAttach: _attach, + ) + : const _ReadOnlyBar(), + ], + ), + ); + } + + Widget _buildTimeline(Room room) { + final timeline = _timeline; + if (_loadError != null) { + return Center(child: Text(describeError(_loadError!))); + } + if (timeline == null) { + return const Center(child: CircularProgressIndicator()); + } + final events = timeline.events; + final items = projectTimeline([ + for (final e in events) + (sender: e.senderId, time: e.originServerTs, kind: kindOf(e)), + ]); + final readUpTo = _readByOthersIndex(events, room); + final typing = room.typingUsers + .where((u) => u.id != room.client.userID) + .toList(); + + return ListView.builder( + controller: _scroll, + reverse: true, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: items.length + 2, + itemBuilder: (context, i) { + if (i == 0) return _TypingRow(users: typing); + if (i == items.length + 1) { + return _HistoryEnd( + loading: _loadingHistory || timeline.canRequestHistory, + room: room, + ); + } + final item = items[i - 1]; + return switch (item) { + DayItem(:final day) => _DaySeparator(day: day), + EventItem(:final index, :final kind) when kind == EntryKind.notice => + NoticeRow(event: events[index]), + EventItem(:final index, :final firstInGroup, :final lastInGroup) => + MessageRow( + key: ValueKey(events[index].eventId), + event: events[index], + timeline: timeline, + firstInGroup: firstInGroup, + lastInGroup: lastInGroup, + readByOthers: readUpTo != null && index >= readUpTo, + ), + }; + }, + ); + } + + /// The index (newest first) of the newest event someone else has read, + /// so the user's messages from there back say "Read". + static int? _readByOthersIndex(List events, Room room) { + final me = room.client.userID; + for (var i = 0; i < events.length; i++) { + if (events[i].receipts.any((r) => r.user.id != me)) return i; + } + return null; + } +} + +class _RoomAppBar extends StatelessWidget implements PreferredSizeWidget { + const _RoomAppBar({required this.room}); + final Room room; + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); + + @override + Widget build(BuildContext context) { + final c = context.warp; + final text = Theme.of(context).textTheme; + // A room fresh from /createRoom may have no summary yet. + final members = + room.summary.mJoinedMemberCount ?? room.getParticipants().length; + final subtitle = room.isDirectChat + ? room.directChatMatrixID ?? '' + : members == 1 + ? '1 member' + : '$members members'; + return AppBar( + titleSpacing: 0, + title: InkWell( + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => RoomInfoScreen(roomId: room.id)), + ), + child: Row( + children: [ + Avatar( + name: room.getLocalizedDisplayname(), + seed: room.id, + mxc: room.avatar, + size: 36, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + room.getLocalizedDisplayname(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: text.titleMedium, + ), + Row( + children: [ + if (room.encrypted) ...[ + Icon(Icons.lock_outline, size: 12, color: c.tealText), + const SizedBox(width: 3), + ], + Flexible( + child: Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: text.labelSmall, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + actions: [ + IconButton( + tooltip: 'Room info', + icon: const Icon(Icons.info_outline), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => RoomInfoScreen(roomId: room.id)), + ), + ), + ], + ); + } +} + +class _DaySeparator extends StatelessWidget { + const _DaySeparator({required this.day}); + final DateTime day; + + @override + Widget build(BuildContext context) { + final c = context.warp; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: Row( + children: [ + Expanded(child: Divider(color: c.line)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + formatDay(day), + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(color: c.inkMuted), + ), + ), + Expanded(child: Divider(color: c.line)), + ], + ), + ); + } +} + +class _HistoryEnd extends StatelessWidget { + const _HistoryEnd({required this.loading, required this.room}); + final bool loading; + final Room room; + + @override + Widget build(BuildContext context) { + if (loading) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center( + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + final text = Theme.of(context).textTheme; + return Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Column( + children: [ + Avatar( + name: room.getLocalizedDisplayname(), + seed: room.id, + mxc: room.avatar, + size: 64, + ), + const SizedBox(height: 12), + Text( + room.getLocalizedDisplayname(), + style: text.titleLarge, + textAlign: TextAlign.center, + ), + if (room.topic.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + room.topic, + style: text.bodySmall, + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 8), + Text( + 'This is the beginning of the conversation.', + style: text.bodySmall, + ), + ], + ), + ); + } +} + +class _TypingRow extends StatelessWidget { + const _TypingRow({required this.users}); + final List users; + + @override + Widget build(BuildContext context) { + if (users.isEmpty) return const SizedBox(height: 4); + final names = users.map((u) => u.calcDisplayname()).toList(); + final who = switch (names.length) { + 1 => '${names[0]} is typing…', + 2 => '${names[0]} and ${names[1]} are typing…', + _ => 'Several people are typing…', + }; + return Padding( + padding: const EdgeInsets.fromLTRB(60, 4, 16, 8), + child: Text( + who, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: context.warp.inkMuted, + fontStyle: FontStyle.italic, + ), + ), + ); + } +} + +class _Composer extends StatelessWidget { + const _Composer({ + required this.controller, + required this.focus, + required this.encrypted, + required this.onSend, + required this.onAttach, + }); + + final TextEditingController controller; + final FocusNode focus; + final bool encrypted; + final VoidCallback onSend; + final VoidCallback onAttach; + + @override + Widget build(BuildContext context) { + final c = context.warp; + final canSend = controller.text.trim().isNotEmpty; + return Container( + decoration: BoxDecoration( + color: c.surface, + border: Border(top: BorderSide(color: c.line)), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(4, 6, 8, 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + IconButton( + tooltip: 'Attach', + icon: const Icon(Icons.add_circle_outline), + color: c.inkMuted, + onPressed: onAttach, + ), + Expanded( + child: TextField( + controller: controller, + focusNode: focus, + minLines: 1, + maxLines: 6, + textCapitalization: TextCapitalization.sentences, + keyboardType: TextInputType.multiline, + decoration: InputDecoration( + hintText: encrypted ? 'Encrypted message' : 'Message', + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(22), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(22), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(22), + borderSide: BorderSide(color: c.tealStrong), + ), + ), + ), + ), + const SizedBox(width: 6), + IconButton.filled( + tooltip: 'Send', + onPressed: canSend ? onSend : null, + icon: const Icon(Icons.send_rounded), + ), + ], + ), + ), + ), + ); + } +} + +class _InviteBar extends StatefulWidget { + const _InviteBar({required this.room}); + final Room room; + + @override + State<_InviteBar> createState() => _InviteBarState(); +} + +class _InviteBarState extends State<_InviteBar> { + bool _busy = false; + + Future _act(bool join) async { + setState(() => _busy = true); + try { + if (join) { + await widget.room.join(); + } else { + await widget.room.leave(); + if (mounted) Navigator.pop(context); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(describeError(e)))); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "You've been invited to this room.", + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _busy ? null : () => _act(false), + child: const Text('Decline'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: _busy ? null : () => _act(true), + child: const Text('Join'), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _ReadOnlyBar extends StatelessWidget { + const _ReadOnlyBar(); + + @override + Widget build(BuildContext context) => SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + "You can't send messages in this room.", + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ); +} diff --git a/apps/mobile/lib/screens/sessions_screen.dart b/apps/mobile/lib/screens/sessions_screen.dart new file mode 100644 index 0000000..0c5871b --- /dev/null +++ b/apps/mobile/lib/screens/sessions_screen.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; + +import '../matrix/client.dart'; +import '../state.dart'; +import '../theme.dart'; +import '../util/format.dart'; +import '../widgets/verification_sheet.dart'; + +/// The account's sessions: which are verified, verifying one, and signing +/// one out. +class SessionsScreen extends ConsumerStatefulWidget { + const SessionsScreen({super.key}); + + @override + ConsumerState createState() => _SessionsScreenState(); +} + +class _SessionsScreenState extends ConsumerState { + late Future?> _devices; + + @override + void initState() { + super.initState(); + _devices = ref.read(clientProvider).getDevices(); + } + + void _reload() => + setState(() => _devices = ref.read(clientProvider).getDevices()); + + void _snack(Object e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(describeError(e)))); + } + + Future _signOut(Device device) async { + final ok = await showDialog( + context: context, + builder: (dialog) => AlertDialog( + title: const Text('Sign out this session?'), + content: Text(device.displayName ?? device.deviceId), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialog, false), + child: const Text('Cancel'), + ), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: context.warp.danger), + onPressed: () => Navigator.pop(dialog, true), + child: const Text('Sign out'), + ), + ], + ), + ); + if (ok != true) return; + final client = ref.read(clientProvider); + try { + // Deleting a device needs the password: the request goes through the + // app's UIA handler. + await client.uiaRequestBackground( + (auth) => client.deleteDevice(device.deviceId, auth: auth), + ); + _reload(); + } catch (e) { + _snack(e); + } + } + + Future _verify(DeviceKeys keys) async { + try { + final request = await keys.startVerification(); + if (mounted) await showVerificationSheet(context, request); + _reload(); + } catch (e) { + _snack(e); + } + } + + @override + Widget build(BuildContext context) { + final client = ref.watch(clientProvider); + final c = context.warp; + return Scaffold( + appBar: AppBar(title: const Text('Sessions')), + body: FutureBuilder?>( + future: _devices, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center(child: Text(describeError(snapshot.error!))); + } + final devices = snapshot.data; + if (devices == null) { + return const Center(child: CircularProgressIndicator()); + } + devices.sort((a, b) { + if (a.deviceId == client.deviceID) return -1; + if (b.deviceId == client.deviceID) return 1; + return (b.lastSeenTs ?? 0).compareTo(a.lastSeenTs ?? 0); + }); + final ownKeys = client.userDeviceKeys[client.userID]; + return RefreshIndicator( + onRefresh: () async => _reload(), + child: ListView( + children: [ + for (final device in devices) + Builder( + builder: (context) { + final current = device.deviceId == client.deviceID; + final keys = ownKeys?.deviceKeys[device.deviceId]; + final verified = keys?.verified == true; + final seen = device.lastSeenTs; + return ListTile( + leading: Icon( + verified + ? Icons.verified_user + : Icons.gpp_maybe_outlined, + color: verified ? c.tealText : c.warning, + ), + title: Text( + [ + device.displayName ?? device.deviceId, + if (current) '(this session)', + ].join(' '), + ), + subtitle: Text( + [ + device.deviceId, + verified ? 'verified' : 'not verified', + if (seen != null && !current) + 'seen ${formatListTime(DateTime.fromMillisecondsSinceEpoch(seen))}', + ].join(' · '), + ), + trailing: current + ? null + : PopupMenuButton( + onSelected: (action) { + if (action == 'verify' && keys != null) { + _verify(keys); + } else if (action == 'signout') { + _signOut(device); + } + }, + itemBuilder: (_) => [ + if (!verified && + keys != null && + client.encryptionEnabled) + const PopupMenuItem( + value: 'verify', + child: Text('Verify'), + ), + const PopupMenuItem( + value: 'signout', + child: Text('Sign out'), + ), + ], + ), + ); + }, + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/apps/mobile/lib/screens/settings_screen.dart b/apps/mobile/lib/screens/settings_screen.dart new file mode 100644 index 0000000..88f64d8 --- /dev/null +++ b/apps/mobile/lib/screens/settings_screen.dart @@ -0,0 +1,413 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:matrix/matrix.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../matrix/client.dart'; +import '../matrix/notifications.dart'; +import '../matrix/push.dart'; +import '../state.dart'; +import '../theme.dart'; +import '../widgets/avatar.dart'; +import 'encryption_screen.dart'; +import 'sessions_screen.dart'; + +class SettingsScreen extends ConsumerStatefulWidget { + const SettingsScreen({super.key}); + + @override + ConsumerState createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends ConsumerState { + late Future _profile; + late Future _distributor; + bool _busy = false; + + @override + void initState() { + super.initState(); + _profile = ref.read(clientProvider).fetchOwnProfile(); + _distributor = Push.currentDistributor(); + } + + void _snack(String text) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); + } + + Future _run(Future Function() action) async { + setState(() => _busy = true); + try { + await action(); + } catch (e) { + _snack(describeError(e)); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + void _reloadProfile() { + final client = ref.read(clientProvider); + setState( + () => _profile = client.getProfileFromUserId( + client.userID!, + maxCacheAge: Duration.zero, + ), + ); + } + + Future _editName(String current) async { + final controller = TextEditingController(text: current); + final name = await showDialog( + context: context, + builder: (dialog) => AlertDialog( + title: const Text('Display name'), + content: TextField( + controller: controller, + autofocus: true, + textCapitalization: TextCapitalization.words, + onSubmitted: (v) => Navigator.pop(dialog, v), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialog), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(dialog, controller.text), + child: const Text('Save'), + ), + ], + ), + ); + if (name == null || name.trim().isEmpty) return; + final client = ref.read(clientProvider); + await _run( + () => client.setProfileField(client.userID!, 'displayname', { + 'displayname': name.trim(), + }), + ); + _reloadProfile(); + } + + Future _editAvatar() async { + final picked = await ImagePicker().pickImage( + source: ImageSource.gallery, + maxWidth: 1024, + maxHeight: 1024, + imageQuality: 90, + ); + if (picked == null) return; + final client = ref.read(clientProvider); + final bytes = await picked.readAsBytes(); + await _run( + () => client.setAvatar(MatrixImageFile(bytes: bytes, name: picked.name)), + ); + _reloadProfile(); + } + + Future _togglePush(bool on) async { + if (!on) { + await _run(Push.disable); + setState(() => _distributor = Push.currentDistributor()); + return; + } + await Notifications.requestPermission(); + final options = await Push.distributors(); + if (!mounted) return; + if (options.isEmpty) { + await showDialog( + context: context, + builder: (dialog) => AlertDialog( + title: const Text('Install a push app'), + content: const Text( + 'Warp receives push notifications through UnifiedPush, without ' + 'Google services. Install a distributor such as ntfy, then turn ' + 'this on again.\n\nWithout one, you are notified while Warp is ' + 'open or running in the background.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialog), + child: const Text('Not now'), + ), + FilledButton( + onPressed: () { + Navigator.pop(dialog); + launchUrl( + Uri.parse('https://unifiedpush.org/users/distributors/'), + mode: LaunchMode.externalApplication, + ); + }, + child: const Text('Find one'), + ), + ], + ), + ); + return; + } + String? choice = options.length == 1 ? options.single : null; + choice ??= await showDialog( + context: context, + builder: (dialog) => SimpleDialog( + title: const Text('Push through'), + children: [ + for (final d in options) + SimpleDialogOption( + onPressed: () => Navigator.pop(dialog, d), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text(d), + ), + ), + ], + ), + ); + if (choice == null) return; + final distributor = choice; + await _run(() async { + await Push.enable(distributor: distributor); + }); + setState(() => _distributor = Push.currentDistributor()); + } + + Future _signOut() async { + final client = ref.read(clientProvider); + final hasBackup = client.encryption?.keyManager.enabled == true; + final ok = await showDialog( + context: context, + builder: (dialog) => AlertDialog( + title: const Text('Sign out?'), + content: Text( + hasBackup + ? "This session's keys are deleted from this phone. To read " + 'your encrypted messages after you sign in again, you ' + 'need your recovery key or another verified session.' + : "This session's keys are deleted from this phone, and " + "encryption isn't set up for your account: encrypted " + "messages only this session can read will be lost.", + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialog, false), + child: const Text('Cancel'), + ), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: context.warp.danger), + onPressed: () => Navigator.pop(dialog, true), + child: const Text('Sign out'), + ), + ], + ), + ); + if (ok != true) return; + await _run(() async { + await Push.forget(); + await Notifications.clearAll(); + try { + await client.logout(); + } catch (e) { + // The session may already be gone on the server; sign out here + // regardless. + Logs().w('Logout request failed, clearing locally', e); + await client.clear(); + } + }); + } + + @override + Widget build(BuildContext context) { + final client = ref.watch(clientProvider); + final themeMode = ref.watch(themeModeProvider); + final c = context.warp; + final text = Theme.of(context).textTheme; + + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: AbsorbPointer( + absorbing: _busy, + child: ListView( + children: [ + if (_busy) const LinearProgressIndicator(minHeight: 2), + FutureBuilder( + future: _profile, + builder: (context, snapshot) { + final profile = snapshot.data; + final name = profile?.displayName ?? client.userID!.localpart!; + return Padding( + padding: const EdgeInsets.all(24), + child: Row( + children: [ + GestureDetector( + onTap: _editAvatar, + child: Stack( + children: [ + Avatar( + name: name, + seed: client.userID!, + mxc: profile?.avatarUrl, + size: 72, + ), + Positioned( + right: 0, + bottom: 0, + child: CircleAvatar( + radius: 12, + backgroundColor: c.tealStrong, + child: Icon( + Icons.photo_camera, + size: 14, + color: c.onTeal, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + name, + style: text.titleLarge, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + tooltip: 'Change display name', + icon: const Icon( + Icons.edit_outlined, + size: 18, + ), + onPressed: () => _editName(name), + ), + ], + ), + Text( + client.userID!, + style: text.bodySmall?.copyWith( + fontFamily: fontMono, + ), + ), + ], + ), + ), + ], + ), + ); + }, + ), + const Divider(), + _Header('Security'), + ListTile( + leading: const Icon(Icons.key_outlined), + title: const Text('Encryption'), + subtitle: Text(_encryptionSummary(client)), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const EncryptionScreen()), + ), + ), + ListTile( + leading: const Icon(Icons.devices_outlined), + title: const Text('Sessions'), + subtitle: const Text('Where you are signed in'), + onTap: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const SessionsScreen())), + ), + const Divider(), + _Header('Notifications'), + FutureBuilder( + future: _distributor, + builder: (context, snapshot) { + final distributor = snapshot.data; + return SwitchListTile( + secondary: const Icon(Icons.notifications_active_outlined), + title: const Text('Push notifications'), + subtitle: Text( + distributor == null + ? 'Off: notified only while Warp runs' + : 'Through $distributor', + ), + value: distributor != null, + onChanged: _togglePush, + ); + }, + ), + const Divider(), + _Header('Appearance'), + RadioGroup( + groupValue: themeMode, + onChanged: (m) { + if (m != null) ref.read(themeModeProvider.notifier).set(m); + }, + child: Column( + children: [ + for (final (mode, label) in [ + (ThemeMode.system, 'System'), + (ThemeMode.light, 'Light'), + (ThemeMode.dark, 'Dark'), + ]) + RadioListTile(value: mode, title: Text(label)), + ], + ), + ), + const Divider(), + _Header('About'), + ListTile( + leading: const Icon(Icons.dns_outlined), + title: const Text('Homeserver'), + subtitle: Text(client.homeserver?.toString() ?? ''), + ), + ListTile( + leading: const Icon(Icons.smartphone), + title: const Text('This session'), + subtitle: Text( + '${client.deviceName ?? deviceDisplayName} · ' + '${client.deviceID}', + ), + ), + const Divider(), + ListTile( + leading: Icon(Icons.logout, color: c.danger), + title: Text('Sign out', style: TextStyle(color: c.danger)), + onTap: _signOut, + ), + const SizedBox(height: 32), + ], + ), + ), + ); + } + + static String _encryptionSummary(Client client) { + final encryption = client.encryption; + if (encryption == null) return 'Not available'; + if (!encryption.crossSigning.enabled) return 'Not set up'; + if (client.isUnknownSession) return 'This session is not verified'; + return 'Set up · this session is verified'; + } +} + +class _Header extends StatelessWidget { + const _Header(this.text); + final String text; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + text, + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: context.warp.tealText), + ), + ); +} diff --git a/apps/mobile/lib/state.dart b/apps/mobile/lib/state.dart new file mode 100644 index 0000000..3553767 --- /dev/null +++ b/apps/mobile/lib/state.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:matrix/matrix.dart'; + +/// The one Matrix client, created in main() before the app starts. +final clientProvider = Provider( + (ref) => throw UnimplementedError('overridden in main()'), +); + +/// Signed in or not. Starts from the restored session. +final loginStateProvider = StreamProvider((ref) async* { + final client = ref.watch(clientProvider); + yield client.isLogged() ? LoginState.loggedIn : LoginState.loggedOut; + yield* client.onLoginStateChanged.stream; +}); + +/// Bumps on every sync. The SDK's objects change in place, so screens that +/// show rooms watch this and read the client again; that is the whole +/// trick that keeps them current. +final syncProvider = StreamProvider((ref) async* { + final client = ref.watch(clientProvider); + var n = 0; + yield n; + await for (final _ in client.onSync.stream) { + yield ++n; + } +}); + +/// Whether the connection to the homeserver is working, from the sync +/// loop's status. Null until the first sync after start finishes. +final syncErrorProvider = StreamProvider((ref) async* { + final client = ref.watch(clientProvider); + await for (final status in client.onSyncStatus.stream) { + if (status.status == SyncStatus.error) yield true; + if (status.status == SyncStatus.finished) yield false; + } +}); + +/// The theme the user picked: the system's, light or dark. +class ThemeModeNotifier extends Notifier { + static const _key = 'warp.theme'; + static const _storage = FlutterSecureStorage(); + + @override + ThemeMode build() { + _storage.read(key: _key).then((value) { + final mode = ThemeMode.values.where((m) => m.name == value).firstOrNull; + if (mode != null) state = mode; + }); + return ThemeMode.system; + } + + void set(ThemeMode mode) { + state = mode; + _storage.write(key: _key, value: mode.name); + } +} + +final themeModeProvider = NotifierProvider( + ThemeModeNotifier.new, +); diff --git a/apps/mobile/lib/theme.dart b/apps/mobile/lib/theme.dart new file mode 100644 index 0000000..aea3d87 --- /dev/null +++ b/apps/mobile/lib/theme.dart @@ -0,0 +1,284 @@ +import 'package:flutter/material.dart'; + +/// Warp's design tokens (apps/ui/src/styles/tokens.css) as a Flutter theme. +/// +/// The token names are kept so a colour can be traced back to the brand +/// package: [WarpColors.light] and [WarpColors.dark] are `:root` and +/// `[data-theme="dark"]`. +@immutable +class WarpColors extends ThemeExtension { + const WarpColors({ + required this.surface, + required this.surfaceSunken, + required this.surfaceRaised, + required this.line, + required this.lineStrong, + required this.ink, + required this.inkMuted, + required this.teal, + required this.tealStrong, + required this.onTeal, + required this.tealText, + required this.tealSoft, + required this.bubbleOther, + required this.danger, + required this.dangerSoft, + required this.warning, + required this.warningSoft, + required this.info, + required this.infoSoft, + required this.presenceOnline, + required this.presenceAway, + required this.presenceOffline, + }); + + final Color surface; + final Color surfaceSunken; + final Color surfaceRaised; + final Color line; + final Color lineStrong; + final Color ink; + final Color inkMuted; + final Color teal; + final Color tealStrong; + final Color onTeal; + final Color tealText; + final Color tealSoft; + final Color bubbleOther; + final Color danger; + final Color dangerSoft; + final Color warning; + final Color warningSoft; + final Color info; + final Color infoSoft; + final Color presenceOnline; + final Color presenceAway; + final Color presenceOffline; + + static const light = WarpColors( + surface: Color(0xFFFFFFFF), + surfaceSunken: Color(0xFFF3F5F4), + surfaceRaised: Color(0xFFFFFFFF), + line: Color(0xFFE1E6E4), + lineStrong: Color(0xFF7B8682), + ink: Color(0xFF111114), + inkMuted: Color(0xFF5B6461), + teal: Color(0xFF00A389), + tealStrong: Color(0xFF00806A), + onTeal: Color(0xFFFFFFFF), + tealText: Color(0xFF00735F), + tealSoft: Color(0xFFDDF4EE), + bubbleOther: Color(0xFFEFF2F1), + danger: Color(0xFFB42318), + dangerSoft: Color(0xFFFDE8E6), + warning: Color(0xFF8A5300), + warningSoft: Color(0xFFFFF2DB), + info: Color(0xFF1D5FD1), + infoSoft: Color(0xFFE3EDFF), + presenceOnline: Color(0xFF00A389), + presenceAway: Color(0xFFD99A00), + presenceOffline: Color(0xFF8A9490), + ); + + static const dark = WarpColors( + surface: Color(0xFF0F1213), + surfaceSunken: Color(0xFF161A1C), + surfaceRaised: Color(0xFF1B2022), + line: Color(0xFF2A3134), + lineStrong: Color(0xFF6E7976), + ink: Color(0xFFECEEEE), + inkMuted: Color(0xFF9AA5A1), + teal: Color(0xFF22C3A6), + tealStrong: Color(0xFF22C3A6), + onTeal: Color(0xFF062A23), + tealText: Color(0xFF3ED6B8), + tealSoft: Color(0xFF123A33), + bubbleOther: Color(0xFF1E2427), + danger: Color(0xFFF97066), + dangerSoft: Color(0xFF3A1512), + warning: Color(0xFFF5B547), + warningSoft: Color(0xFF33260B), + info: Color(0xFF7EB1FF), + infoSoft: Color(0xFF14233F), + presenceOnline: Color(0xFF22C3A6), + presenceAway: Color(0xFFF5B547), + presenceOffline: Color(0xFF6E7976), + ); + + @override + WarpColors copyWith() => this; + + @override + WarpColors lerp(WarpColors? other, double t) => + other == null || t < 0.5 ? this : other; +} + +extension WarpTheme on BuildContext { + WarpColors get warp => Theme.of(this).extension()!; +} + +const fontDisplay = 'Outfit'; +const fontSans = 'Figtree'; +const fontMono = 'JetBrains Mono'; + +ThemeData warpTheme(Brightness brightness) { + final c = brightness == Brightness.dark ? WarpColors.dark : WarpColors.light; + final scheme = ColorScheme( + brightness: brightness, + primary: c.tealStrong, + onPrimary: c.onTeal, + primaryContainer: c.tealSoft, + onPrimaryContainer: c.tealText, + secondary: c.teal, + onSecondary: c.onTeal, + error: c.danger, + onError: brightness == Brightness.dark ? c.surface : Colors.white, + errorContainer: c.dangerSoft, + onErrorContainer: c.danger, + surface: c.surface, + onSurface: c.ink, + onSurfaceVariant: c.inkMuted, + surfaceContainerLowest: c.surface, + surfaceContainerLow: c.surfaceSunken, + surfaceContainer: c.surfaceSunken, + surfaceContainerHigh: c.surfaceRaised, + surfaceContainerHighest: c.bubbleOther, + outline: c.lineStrong, + outlineVariant: c.line, + ); + + TextStyle display(double size, double height, FontWeight w) => TextStyle( + fontFamily: fontDisplay, + fontSize: size, + height: height / size, + fontWeight: w, + color: c.ink, + ); + TextStyle sans(double size, double height, FontWeight w, [Color? color]) => + TextStyle( + fontFamily: fontSans, + fontSize: size, + height: height / size, + fontWeight: w, + color: color ?? c.ink, + ); + + final text = TextTheme( + displaySmall: display(40, 44, FontWeight.w700), + headlineMedium: display(28, 34, FontWeight.w600), + headlineSmall: display(24, 30, FontWeight.w600), + titleLarge: display(20, 26, FontWeight.w600), + titleMedium: display(16, 22, FontWeight.w600), + titleSmall: sans(14, 20, FontWeight.w600), + bodyLarge: sans(15, 22, FontWeight.w400), + bodyMedium: sans(14, 20, FontWeight.w400), + bodySmall: sans(13, 18, FontWeight.w400, c.inkMuted), + labelLarge: sans(14, 20, FontWeight.w600), + labelMedium: sans(12, 16, FontWeight.w500), + labelSmall: sans(11, 14, FontWeight.w500, c.inkMuted), + ); + + final radius = BorderRadius.circular(12); + return ThemeData( + useMaterial3: true, + brightness: brightness, + colorScheme: scheme, + fontFamily: fontSans, + textTheme: text, + scaffoldBackgroundColor: c.surface, + extensions: [c], + dividerTheme: DividerThemeData(color: c.line, space: 1, thickness: 1), + appBarTheme: AppBarTheme( + backgroundColor: c.surface, + foregroundColor: c.ink, + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + titleTextStyle: display(18, 24, FontWeight.w600), + shape: Border(bottom: BorderSide(color: c.line)), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: c.surfaceSunken, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + border: OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide(color: c.line), + ), + enabledBorder: OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide(color: c.line), + ), + focusedBorder: OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide(color: c.tealStrong, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide(color: c.danger), + ), + labelStyle: sans(14, 20, FontWeight.w500, c.inkMuted), + hintStyle: sans(14, 20, FontWeight.w400, c.inkMuted), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: c.tealStrong, + foregroundColor: c.onTeal, + minimumSize: const Size(64, 44), + textStyle: sans(14, 20, FontWeight.w600), + shape: RoundedRectangleBorder(borderRadius: radius), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: c.ink, + minimumSize: const Size(64, 44), + side: BorderSide(color: c.line), + textStyle: sans(14, 20, FontWeight.w600), + shape: RoundedRectangleBorder(borderRadius: radius), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: c.tealText, + textStyle: sans(14, 20, FontWeight.w600), + ), + ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: c.tealStrong, + foregroundColor: c.onTeal, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), + ), + dialogTheme: DialogThemeData( + backgroundColor: c.surfaceRaised, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), + titleTextStyle: display(20, 26, FontWeight.w600), + ), + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: c.surfaceRaised, + surfaceTintColor: Colors.transparent, + showDragHandle: true, + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + backgroundColor: c.ink, + contentTextStyle: sans(14, 20, FontWeight.w500, c.surface), + shape: RoundedRectangleBorder(borderRadius: radius), + ), + listTileTheme: ListTileThemeData( + iconColor: c.inkMuted, + titleTextStyle: sans(15, 22, FontWeight.w500), + subtitleTextStyle: sans(13, 18, FontWeight.w400, c.inkMuted), + ), + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith( + (s) => s.contains(WidgetState.selected) ? c.onTeal : c.lineStrong, + ), + trackColor: WidgetStateProperty.resolveWith( + (s) => s.contains(WidgetState.selected) ? c.tealStrong : c.line, + ), + ), + progressIndicatorTheme: ProgressIndicatorThemeData(color: c.teal), + ); +} diff --git a/apps/mobile/lib/util/format.dart b/apps/mobile/lib/util/format.dart new file mode 100644 index 0000000..9702885 --- /dev/null +++ b/apps/mobile/lib/util/format.dart @@ -0,0 +1,45 @@ +import 'package:intl/intl.dart'; + +/// The time next to a room in the list: the time today, the weekday this +/// week, the date before that. +String formatListTime(DateTime time, {DateTime? now}) { + final local = time.toLocal(); + final today = _day(now ?? DateTime.now()); + final day = _day(local); + final days = today.difference(day).inDays; + if (days <= 0) return DateFormat.Hm().format(local); + if (days == 1) return 'Yesterday'; + if (days < 7) return DateFormat.E().format(local); + if (local.year == today.year) return DateFormat.MMMd().format(local); + return DateFormat.yMd().format(local); +} + +/// The separator above a day's messages. +String formatDay(DateTime day, {DateTime? now}) { + final today = _day(now ?? DateTime.now()); + final days = today.difference(_day(day)).inDays; + if (days == 0) return 'Today'; + if (days == 1) return 'Yesterday'; + if (days < 7) return DateFormat.EEEE().format(day); + if (day.year == today.year) return DateFormat.MMMMEEEEd().format(day); + return DateFormat.yMMMMEEEEd().format(day); +} + +/// The time under a message. +String formatTime(DateTime time) => DateFormat.Hm().format(time.toLocal()); + +/// A file size for people: 980 B, 12 KB, 3.4 MB. +String formatBytes(int bytes) { + if (bytes < 1000) return '$bytes B'; + const units = ['KB', 'MB', 'GB']; + var value = bytes / 1000; + var unit = 0; + while (value >= 1000 && unit < units.length - 1) { + value /= 1000; + unit++; + } + final digits = value < 10 ? 1 : 0; + return '${value.toStringAsFixed(digits)} ${units[unit]}'; +} + +DateTime _day(DateTime t) => DateTime(t.year, t.month, t.day); diff --git a/apps/mobile/lib/widgets/avatar.dart b/apps/mobile/lib/widgets/avatar.dart new file mode 100644 index 0000000..6a71aa0 --- /dev/null +++ b/apps/mobile/lib/widgets/avatar.dart @@ -0,0 +1,115 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../matrix/media.dart'; +import '../state.dart'; +import '../theme.dart'; + +/// A round avatar: the picture at [mxc] when there is one, else the first +/// letter of [name] on a colour picked from [seed] (the user or room ID), so +/// the same person always gets the same colour. +class Avatar extends ConsumerStatefulWidget { + const Avatar({ + super.key, + required this.name, + required this.seed, + this.mxc, + this.size = 40, + }); + + final String name; + final String seed; + final Uri? mxc; + final double size; + + @override + ConsumerState createState() => _AvatarState(); +} + +class _AvatarState extends ConsumerState { + Future? _bytes; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void didUpdateWidget(Avatar old) { + super.didUpdateWidget(old); + if (old.mxc != widget.mxc || old.size != widget.size) _load(); + } + + void _load() { + final mxc = widget.mxc; + _bytes = mxc == null || mxc.scheme != 'mxc' + ? null + : Media.thumbnail(ref.read(clientProvider), mxc, widget.size.round()); + } + + static const _palette = [ + Color(0xFF00A389), + Color(0xFF1D5FD1), + Color(0xFF8A5300), + Color(0xFFB42318), + Color(0xFF6D4AC2), + Color(0xFF00806A), + Color(0xFFC2410C), + Color(0xFF0E7490), + ]; + + @override + Widget build(BuildContext context) { + final size = widget.size; + final hash = widget.seed.codeUnits.fold( + 0, + (h, c) => (h * 31 + c) & 0x7fffffff, + ); + final colour = _palette[hash.abs() % _palette.length]; + final letter = _initial(widget.name); + final fallback = Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration(color: colour, shape: BoxShape.circle), + child: Text( + letter, + style: TextStyle( + fontFamily: fontDisplay, + fontWeight: FontWeight.w600, + fontSize: size * 0.42, + color: Colors.white, + height: 1, + ), + ), + ); + final bytes = _bytes; + if (bytes == null) return fallback; + return FutureBuilder( + future: bytes, + builder: (context, snapshot) { + final data = snapshot.data; + if (data == null) return fallback; + return ClipOval( + child: Image.memory( + data, + width: size, + height: size, + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => fallback, + ), + ); + }, + ); + } + + static String _initial(String name) { + final trimmed = name.replaceFirst(RegExp(r'^[@#!+]'), '').trim(); + if (trimmed.isEmpty) return '?'; + return String.fromCharCode(trimmed.runes.first).toUpperCase(); + } +} diff --git a/apps/mobile/lib/widgets/message.dart b/apps/mobile/lib/widgets/message.dart new file mode 100644 index 0000000..4d9fdc7 --- /dev/null +++ b/apps/mobile/lib/widgets/message.dart @@ -0,0 +1,694 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:matrix/matrix.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../matrix/client.dart'; +import '../matrix/media.dart'; +import '../theme.dart'; +import '../util/format.dart'; +import 'avatar.dart'; + +const _i18n = MatrixDefaultLocalizations(); + +/// A one-line room event in the middle of the timeline: "Alice joined". +class NoticeRow extends StatelessWidget { + const NoticeRow({super.key, required this.event}); + final Event event; + + @override + Widget build(BuildContext context) { + final c = context.warp; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 6), + child: Text( + event.calcLocalizedBodyFallback(_i18n), + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(color: c.inkMuted), + ), + ); + } +} + +/// A message: a bubble on the right for the user's own, on the left with +/// the sender's avatar and name for everyone else's. +class MessageRow extends StatelessWidget { + const MessageRow({ + super.key, + required this.event, + required this.timeline, + required this.firstInGroup, + required this.lastInGroup, + required this.readByOthers, + }); + + final Event event; + final Timeline timeline; + final bool firstInGroup; + final bool lastInGroup; + final bool readByOthers; + + bool get _mine => event.senderId == event.room.client.userID; + + @override + Widget build(BuildContext context) { + final c = context.warp; + final text = Theme.of(context).textTheme; + final display = event.getDisplayEvent(timeline); + final sender = event.senderFromMemoryOrFallback; + final showSender = !_mine && firstInGroup && !event.room.isDirectChat; + final bubbleColour = _mine ? c.tealSoft : c.bubbleOther; + final isMedia = + !display.redacted && + (display.messageType == MessageTypes.Image || + display.messageType == MessageTypes.Sticker); + + final radius = Radius.circular(18); + final tight = Radius.circular(6); + final shape = BorderRadius.only( + topLeft: !_mine && !firstInGroup ? tight : radius, + bottomLeft: !_mine && !lastInGroup ? tight : radius, + topRight: _mine && !firstInGroup ? tight : radius, + bottomRight: _mine && !lastInGroup ? tight : radius, + ); + + final body = _MessageBody(event: display, original: event, mine: _mine); + final bubble = GestureDetector( + onLongPress: () => _showActions(context, display), + child: isMedia + ? ClipRRect(borderRadius: shape, child: body) + : Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), + decoration: BoxDecoration( + color: bubbleColour, + borderRadius: shape, + ), + child: body, + ), + ); + + return Padding( + padding: EdgeInsets.fromLTRB( + _mine ? 56 : 12, + firstInGroup ? 8 : 2, + _mine ? 12 : 56, + lastInGroup ? 4 : 0, + ), + child: Row( + mainAxisAlignment: _mine + ? MainAxisAlignment.end + : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!_mine) ...[ + SizedBox( + width: 32, + child: lastInGroup + ? Avatar( + name: sender.calcDisplayname(), + seed: sender.id, + mxc: sender.avatarUrl, + size: 32, + ) + : null, + ), + const SizedBox(width: 8), + ], + Flexible( + child: Column( + crossAxisAlignment: _mine + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + if (showSender) + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 2), + child: Text( + sender.calcDisplayname(), + style: text.labelMedium?.copyWith( + color: c.tealText, + fontWeight: FontWeight.w600, + ), + ), + ), + bubble, + if (lastInGroup || event.status.isError) + Padding( + padding: const EdgeInsets.only(top: 3, left: 4, right: 4), + child: _Status( + event: event, + edited: + display != event || + event.hasAggregatedEvents( + timeline, + RelationshipTypes.edit, + ), + mine: _mine, + read: readByOthers, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Future _showActions(BuildContext context, Event display) async { + final hasText = + !display.redacted && + display.type == EventTypes.Message && + !display.hasAttachment; + final canSave = !display.redacted && display.hasAttachment; + final canDelete = + !event.redacted && + event.status.isSent && + event.canRedact && + event.room.canRedact; + final failed = event.status.isError; + + final action = await showModalBottomSheet( + context: context, + builder: (sheet) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (failed) ...[ + ListTile( + leading: const Icon(Icons.refresh), + title: const Text('Retry'), + onTap: () => Navigator.pop(sheet, 'retry'), + ), + ListTile( + leading: const Icon(Icons.delete_outline), + title: const Text('Delete'), + onTap: () => Navigator.pop(sheet, 'cancel'), + ), + ], + if (hasText) + ListTile( + leading: const Icon(Icons.copy), + title: const Text('Copy text'), + onTap: () => Navigator.pop(sheet, 'copy'), + ), + if (canSave) + ListTile( + leading: const Icon(Icons.download_outlined), + title: const Text('Save'), + onTap: () => Navigator.pop(sheet, 'save'), + ), + if (canDelete) + ListTile( + leading: Icon(Icons.delete_outline, color: context.warp.danger), + title: Text( + 'Delete', + style: TextStyle(color: context.warp.danger), + ), + onTap: () => Navigator.pop(sheet, 'redact'), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + if (!context.mounted) return; + try { + switch (action) { + case 'retry': + await event.sendAgain(); + case 'cancel': + await event.cancelSend(); + case 'copy': + await Clipboard.setData( + ClipboardData( + text: display.calcLocalizedBodyFallback( + _i18n, + hideReply: true, + plaintextBody: false, + ), + ), + ); + case 'save': + await saveAttachment(context, display); + case 'redact': + final ok = await showDialog( + context: context, + builder: (dialog) => AlertDialog( + title: const Text('Delete message?'), + content: const Text( + 'It is removed for everyone in the room. This can\'t be ' + 'undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialog, false), + child: const Text('Cancel'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: context.warp.danger, + ), + onPressed: () => Navigator.pop(dialog, true), + child: const Text('Delete'), + ), + ], + ), + ); + if (ok == true) await event.redactEvent(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(describeError(e)))); + } + } + } +} + +/// Under a group: the time, "edited", and for the user's own messages +/// whether it is sending, sent, read, or failed. +class _Status extends StatelessWidget { + const _Status({ + required this.event, + required this.edited, + required this.mine, + required this.read, + }); + + final Event event; + final bool edited; + final bool mine; + final bool read; + + @override + Widget build(BuildContext context) { + final c = context.warp; + final style = Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: c.inkMuted); + final status = event.status; + final parts = [ + TextSpan(text: formatTime(event.originServerTs)), + if (edited) const TextSpan(text: ' · edited'), + ]; + if (mine) { + if (status.isError) { + parts.add( + TextSpan( + text: ' · Not sent. Hold for Retry', + style: style?.copyWith(color: c.danger), + ), + ); + } else if (status.isSending) { + parts.add(const TextSpan(text: ' · Sending')); + } else if (read) { + parts.add( + TextSpan( + text: ' · Read', + style: style?.copyWith(color: c.tealText), + ), + ); + } else { + parts.add(const TextSpan(text: ' · Sent')); + } + } + return Text.rich(TextSpan(children: parts), style: style); + } +} + +class _MessageBody extends StatelessWidget { + const _MessageBody({ + required this.event, + required this.original, + required this.mine, + }); + + /// The event as shown: the latest edit of [original]. + final Event event; + final Event original; + final bool mine; + + @override + Widget build(BuildContext context) { + final c = context.warp; + final text = Theme.of(context).textTheme; + final muted = text.bodyLarge?.copyWith( + color: c.inkMuted, + fontStyle: FontStyle.italic, + ); + + if (event.redacted) { + return Text('Message deleted', style: muted); + } + if (event.type == EventTypes.Encrypted) { + return _UndecryptableBody(event: event); + } + + switch (event.messageType) { + case MessageTypes.Image: + case MessageTypes.Sticker: + return _ImageBody(event: event); + case MessageTypes.File: + case MessageTypes.Video: + case MessageTypes.Audio: + return _FileBody(event: event); + case MessageTypes.BadEncrypted: + return _UndecryptableBody(event: event); + } + + final body = event.calcLocalizedBodyFallback( + _i18n, + hideReply: true, + plaintextBody: false, + ); + final style = event.messageType == MessageTypes.Emote + ? text.bodyLarge?.copyWith(fontStyle: FontStyle.italic) + : event.messageType == MessageTypes.Notice + ? text.bodyLarge?.copyWith(color: c.inkMuted) + : text.bodyLarge; + final emote = event.messageType == MessageTypes.Emote + ? '* ${event.senderFromMemoryOrFallback.calcDisplayname()} ' + : ''; + return LinkifiedText('$emote$body', style: style); + } +} + +/// A message this session has no key for: why, in words, rather than just +/// "encrypted". +class _UndecryptableBody extends StatelessWidget { + const _UndecryptableBody({required this.event}); + final Event event; + + @override + Widget build(BuildContext context) { + final c = context.warp; + final client = event.room.client; + final String reason; + if (!client.encryptionEnabled) { + reason = "Encrypted. This app can't decrypt messages."; + } else if (client.isUnknownSession) { + reason = + "Can't decrypt this yet. Verify this session in Settings to read " + 'older messages.'; + } else if (event.content['can_request_session'] == true) { + reason = + 'Waiting for the key to this message. It may have been sent ' + 'before this session signed in.'; + } else { + reason = "Couldn't decrypt this message."; + } + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.lock_clock_outlined, size: 16, color: c.inkMuted), + const SizedBox(width: 6), + Flexible( + child: Text( + reason, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: c.inkMuted, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ); + } +} + +class _ImageBody extends StatefulWidget { + const _ImageBody({required this.event}); + final Event event; + + @override + State<_ImageBody> createState() => _ImageBodyState(); +} + +class _ImageBodyState extends State<_ImageBody> { + late Future _bytes = Media.attachment( + widget.event, + thumbnail: true, + ); + + @override + Widget build(BuildContext context) { + final c = context.warp; + final info = widget.event.infoMap; + final w = (info['w'] as num?)?.toDouble(); + final h = (info['h'] as num?)?.toDouble(); + final maxWidth = MediaQuery.sizeOf(context).width * 0.66; + var width = maxWidth.clamp(120.0, 320.0); + var height = width * 0.75; + if (w != null && h != null && w > 0 && h > 0) { + height = (width * h / w).clamp(80.0, 360.0); + if (h > w) width = (height * w / h).clamp(80.0, width); + } + + return FutureBuilder( + future: _bytes, + builder: (context, snapshot) { + final data = snapshot.data; + final placeholder = Container( + width: width, + height: height, + color: c.bubbleOther, + alignment: Alignment.center, + child: snapshot.connectionState == ConnectionState.done + ? IconButton( + tooltip: 'Retry', + icon: Icon(Icons.broken_image_outlined, color: c.inkMuted), + onPressed: () => setState(() { + Media.evict(widget.event); + _bytes = Media.attachment(widget.event, thumbnail: true); + }), + ) + : const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + if (data == null) return placeholder; + return GestureDetector( + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => ImageViewer(event: widget.event)), + ), + child: Image.memory( + data, + width: width, + height: height, + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => placeholder, + ), + ); + }, + ); + } +} + +class _FileBody extends StatefulWidget { + const _FileBody({required this.event}); + final Event event; + + @override + State<_FileBody> createState() => _FileBodyState(); +} + +class _FileBodyState extends State<_FileBody> { + bool _saving = false; + + @override + Widget build(BuildContext context) { + final c = context.warp; + final text = Theme.of(context).textTheme; + final event = widget.event; + final size = event.infoMap['size']; + final icon = switch (event.messageType) { + MessageTypes.Video => Icons.movie_outlined, + MessageTypes.Audio => Icons.audiotrack_outlined, + _ => Icons.insert_drive_file_outlined, + }; + return InkWell( + onTap: _saving + ? null + : () async { + setState(() => _saving = true); + try { + await saveAttachment(context, event); + } finally { + if (mounted) setState(() => _saving = false); + } + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(10), + ), + child: _saving + ? const Padding( + padding: EdgeInsets.all(10), + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon(icon, color: c.tealText), + ), + const SizedBox(width: 10), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + event.body, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: text.titleSmall, + ), + Text( + [ + if (size is int) formatBytes(size), + 'Tap to save', + ].join(' · '), + style: text.labelSmall, + ), + ], + ), + ), + ], + ), + ); + } +} + +/// Downloads (and decrypts) an attachment and asks where to save it. +Future saveAttachment(BuildContext context, Event event) async { + final messenger = ScaffoldMessenger.of(context); + try { + final file = await event.downloadAndDecryptAttachment(); + final saved = await FilePicker.saveFile( + fileName: file.name, + bytes: file.bytes, + ); + if (saved != null) { + messenger.showSnackBar(SnackBar(content: Text('Saved ${file.name}'))); + } + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text(describeError(e)))); + } +} + +/// An image full screen, pinch to zoom. +class ImageViewer extends StatelessWidget { + const ImageViewer({super.key, required this.event}); + final Event event; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + shape: const Border(), + title: Text( + event.body, + style: const TextStyle(color: Colors.white, fontSize: 16), + ), + actions: [ + IconButton( + tooltip: 'Save', + icon: const Icon(Icons.download_outlined), + onPressed: () => saveAttachment(context, event), + ), + ], + ), + body: FutureBuilder( + future: Media.attachment(event), + builder: (context, snapshot) { + final data = snapshot.data; + if (data == null) { + return Center( + child: snapshot.connectionState == ConnectionState.done + ? const Text( + "Couldn't load the image.", + style: TextStyle(color: Colors.white), + ) + : const CircularProgressIndicator(), + ); + } + return InteractiveViewer( + maxScale: 6, + child: Center(child: Image.memory(data)), + ); + }, + ), + ); + } +} + +/// Text with web links that open in the browser. +class LinkifiedText extends StatefulWidget { + const LinkifiedText(this.text, {super.key, this.style}); + final String text; + final TextStyle? style; + + static final _url = RegExp( + r'''\bhttps?://[^\s<>"']+[^\s<>"'.,;:!?)\]]''', + caseSensitive: false, + ); + + @override + State createState() => _LinkifiedTextState(); +} + +class _LinkifiedTextState extends State { + final _recognizers = []; + + @override + void dispose() { + for (final r in _recognizers) { + r.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + for (final r in _recognizers) { + r.dispose(); + } + _recognizers.clear(); + final text = widget.text; + final matches = LinkifiedText._url.allMatches(text).toList(); + if (matches.isEmpty) return Text(text, style: widget.style); + + final link = widget.style?.copyWith( + color: context.warp.tealText, + decoration: TextDecoration.underline, + decorationColor: context.warp.tealText, + ); + final spans = []; + var at = 0; + for (final m in matches) { + if (m.start > at) spans.add(TextSpan(text: text.substring(at, m.start))); + final url = m.group(0)!; + final recognizer = TapGestureRecognizer() + ..onTap = () => + launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + _recognizers.add(recognizer); + spans.add(TextSpan(text: url, style: link, recognizer: recognizer)); + at = m.end; + } + if (at < text.length) spans.add(TextSpan(text: text.substring(at))); + return Text.rich(TextSpan(children: spans), style: widget.style); + } +} diff --git a/apps/mobile/lib/widgets/new_chat_sheet.dart b/apps/mobile/lib/widgets/new_chat_sheet.dart new file mode 100644 index 0000000..6b7e6c8 --- /dev/null +++ b/apps/mobile/lib/widgets/new_chat_sheet.dart @@ -0,0 +1,329 @@ +import 'package:flutter/material.dart' hide Visibility; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; + +import '../matrix/client.dart'; +import '../screens/room_screen.dart'; +import '../state.dart'; + +/// Start a direct chat, create a room or join one. +Future showNewChatSheet(BuildContext context) => + showModalBottomSheet( + context: context, + builder: (sheet) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.person_add_alt_1_outlined), + title: const Text('Message someone'), + subtitle: const Text('A direct chat, end-to-end encrypted'), + onTap: () { + Navigator.pop(sheet); + _open(context, const _StartChatDialog()); + }, + ), + ListTile( + leading: const Icon(Icons.group_add_outlined), + title: const Text('New room'), + subtitle: const Text('For a group or a topic'), + onTap: () { + Navigator.pop(sheet); + _open(context, const _CreateRoomDialog()); + }, + ), + ListTile( + leading: const Icon(Icons.login), + title: const Text('Join a room'), + subtitle: const Text('By its address, like #room:example.org'), + onTap: () { + Navigator.pop(sheet); + _open(context, const _JoinRoomDialog()); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + +Future _open(BuildContext context, Widget dialog) async { + final roomId = await showDialog( + context: context, + builder: (_) => dialog, + ); + if (roomId != null && context.mounted) { + Navigator.of(context).push(RoomScreen.route(roomId)); + } +} + +/// A dialog with one form and a busy state, returning a room ID. +class _FormDialog extends StatefulWidget { + const _FormDialog({ + required this.title, + required this.action, + required this.fields, + required this.submit, + }); + + final String title; + final String action; + final List fields; + final Future Function() submit; + + @override + State<_FormDialog> createState() => _FormDialogState(); +} + +class _FormDialogState extends State<_FormDialog> { + bool _busy = false; + String? _error; + + Future _go() async { + setState(() { + _busy = true; + _error = null; + }); + try { + final roomId = await widget.submit(); + if (mounted) Navigator.pop(context, roomId); + } catch (e) { + if (mounted) { + setState(() { + _busy = false; + _error = describeError(e); + }); + } + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ...widget.fields, + if (_error != null) ...[ + const SizedBox(height: 12), + Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: _busy ? null : () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _busy ? null : _go, + child: _busy + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(widget.action), + ), + ], + ); + } +} + +class _StartChatDialog extends ConsumerStatefulWidget { + const _StartChatDialog(); + + @override + ConsumerState<_StartChatDialog> createState() => _StartChatDialogState(); +} + +class _StartChatDialogState extends ConsumerState<_StartChatDialog> { + final _userId = TextEditingController(); + + @override + void dispose() { + _userId.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final client = ref.watch(clientProvider); + return _FormDialog( + title: 'Message someone', + action: 'Start chat', + fields: [ + TextField( + controller: _userId, + autofocus: true, + autocorrect: false, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + labelText: 'User ID', + hintText: '@alice:${client.userID?.domain ?? 'example.org'}', + ), + ), + ], + submit: () async { + var id = _userId.text.trim(); + if (!id.startsWith('@')) id = '@$id'; + if (!id.contains(':')) id = '$id:${client.userID!.domain}'; + if (!id.isValidMatrixIdStrict()) { + throw const FormatException("That isn't a user ID."); + } + if (id == client.userID) { + throw const FormatException("That's you."); + } + // Check they exist before creating a room for nobody. + await client.getUserProfile(id); + return client.startDirectChat( + id, + enableEncryption: client.encryptionEnabled, + ); + }, + ); + } +} + +class _CreateRoomDialog extends ConsumerStatefulWidget { + const _CreateRoomDialog(); + + @override + ConsumerState<_CreateRoomDialog> createState() => _CreateRoomDialogState(); +} + +class _CreateRoomDialogState extends ConsumerState<_CreateRoomDialog> { + final _name = TextEditingController(); + final _topic = TextEditingController(); + bool _public = false; + bool _encrypted = true; + + @override + void dispose() { + _name.dispose(); + _topic.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final client = ref.watch(clientProvider); + return _FormDialog( + title: 'New room', + action: 'Create', + fields: [ + TextField( + controller: _name, + autofocus: true, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration(labelText: 'Name'), + ), + const SizedBox(height: 12), + TextField( + controller: _topic, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration(labelText: 'Topic (optional)'), + ), + const SizedBox(height: 8), + StatefulBuilder( + builder: (context, setLocal) => Column( + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Public'), + subtitle: const Text('Anyone can find and join it'), + value: _public, + onChanged: (v) => setLocal(() => _public = v), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('End-to-end encrypted'), + subtitle: const Text("Can't be turned off later"), + value: _encrypted, + onChanged: client.encryptionEnabled + ? (v) => setLocal(() => _encrypted = v) + : null, + ), + ], + ), + ), + ], + submit: () async { + final name = _name.text.trim(); + if (name.isEmpty) throw const FormatException('Give the room a name.'); + final topic = _topic.text.trim(); + final roomId = await client.createGroupChat( + groupName: name, + enableEncryption: _encrypted && client.encryptionEnabled, + preset: _public + ? CreateRoomPreset.publicChat + : CreateRoomPreset.privateChat, + visibility: _public ? Visibility.public : Visibility.private, + initialState: [ + if (topic.isNotEmpty) + StateEvent(type: EventTypes.RoomTopic, content: {'topic': topic}), + ], + ); + return roomId; + }, + ); + } +} + +class _JoinRoomDialog extends ConsumerStatefulWidget { + const _JoinRoomDialog(); + + @override + ConsumerState<_JoinRoomDialog> createState() => _JoinRoomDialogState(); +} + +class _JoinRoomDialogState extends ConsumerState<_JoinRoomDialog> { + final _address = TextEditingController(); + + @override + void dispose() { + _address.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final client = ref.watch(clientProvider); + return _FormDialog( + title: 'Join a room', + action: 'Join', + fields: [ + TextField( + controller: _address, + autofocus: true, + autocorrect: false, + decoration: InputDecoration( + labelText: 'Room address', + hintText: '#room:${client.userID?.domain ?? 'example.org'}', + ), + ), + ], + submit: () async { + var address = _address.text.trim(); + if (address.isEmpty) { + throw const FormatException('Enter a room address.'); + } + if (!address.startsWith('#') && !address.startsWith('!')) { + address = '#$address'; + } + if (!address.contains(':')) { + address = '$address:${client.userID!.domain}'; + } + final roomId = await client.joinRoom(address); + if (client.getRoomById(roomId)?.membership != Membership.join) { + await client.waitForRoomInSync(roomId, join: true); + } + return roomId; + }, + ); + } +} diff --git a/apps/mobile/lib/widgets/uia.dart b/apps/mobile/lib/widgets/uia.dart new file mode 100644 index 0000000..15c6d35 --- /dev/null +++ b/apps/mobile/lib/widgets/uia.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; + +/// Answers a user-interactive authentication request: the homeserver asking +/// the user to prove it's them before a sensitive change, such as uploading +/// cross-signing keys or deleting a session. +/// +/// Warp asks for the account password (`m.login.password`); a server that +/// needs nothing (`m.login.dummy`) is answered without asking. Any other +/// stage can't be done in the app and cancels the request. +Future handleUia( + BuildContext context, + Client client, + UiaRequest request, +) async { + if (request.state != UiaRequestState.waitForUser) return; + final stages = request.nextStages; + if (stages.contains(AuthenticationTypes.dummy)) { + await request.completeStage( + AuthenticationData( + type: AuthenticationTypes.dummy, + session: request.session, + ), + ); + return; + } + if (!stages.contains(AuthenticationTypes.password)) { + request.cancel( + Exception( + "This server asks for a sign-in step Warp " + "can't do: ${stages.join(', ')}.", + ), + ); + return; + } + + final password = await askPassword(context); + if (password == null) { + request.cancel(Exception('Canceled')); + return; + } + await request.completeStage( + AuthenticationPassword( + session: request.session, + password: password, + identifier: AuthenticationUserIdentifier(user: client.userID!), + ), + ); +} + +Future askPassword( + BuildContext context, { + String title = 'Confirm your password', + String message = 'To make this change, enter your account password.', +}) { + final controller = TextEditingController(); + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(message), + const SizedBox(height: 16), + TextField( + controller: controller, + autofocus: true, + obscureText: true, + autofillHints: const [AutofillHints.password], + decoration: const InputDecoration(labelText: 'Password'), + onSubmitted: (v) => Navigator.pop(context, v), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, controller.text), + child: const Text('Continue'), + ), + ], + ), + ); +} diff --git a/apps/mobile/lib/widgets/verification_sheet.dart b/apps/mobile/lib/widgets/verification_sheet.dart new file mode 100644 index 0000000..b952452 --- /dev/null +++ b/apps/mobile/lib/widgets/verification_sheet.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/encryption.dart'; +import 'package:matrix/matrix.dart'; + +import '../theme.dart'; + +/// Runs a verification, incoming or one this session started, to the end: +/// accept, compare emoji, done. Closing the sheet early cancels it. +/// +/// Emoji (or numbers) only for now; scanning QR codes is a later addition. +Future showVerificationSheet( + BuildContext context, + KeyVerification request, +) => showModalBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: false, + builder: (_) => _VerificationSheet(request), +); + +class _VerificationSheet extends StatefulWidget { + const _VerificationSheet(this.request); + final KeyVerification request; + + @override + State<_VerificationSheet> createState() => _VerificationSheetState(); +} + +class _VerificationSheetState extends State<_VerificationSheet> { + KeyVerification get request => widget.request; + bool _busy = false; + + @override + void initState() { + super.initState(); + request.onUpdate = _onUpdate; + _onUpdate(); + } + + @override + void dispose() { + request.onUpdate = null; + if (!request.isDone) request.cancel('m.user'); + super.dispose(); + } + + void _onUpdate() { + // Steps that need no decision from the user. + if (request.state == KeyVerificationState.askChoice && + request.possibleMethods.contains(EventTypes.Sas)) { + request.continueVerification(EventTypes.Sas); + } else if (request.state == KeyVerificationState.askSSSS) { + request.openSSSS(skip: true); + } + if (mounted) setState(() {}); + } + + Future _run(Future Function() action) async { + setState(() => _busy = true); + try { + await action(); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + bool get _isSelf => request.userId == request.client.userID; + + String get _who { + if (_isSelf) return 'your other session'; + final user = request.room?.unsafeGetUserFromMemoryOrFallback( + request.userId, + ); + return user?.calcDisplayname() ?? request.userId; + } + + @override + Widget build(BuildContext context) { + final c = context.warp; + final text = Theme.of(context).textTheme; + final (title, body, actions) = switch (request.state) { + KeyVerificationState.askAccept => ( + _isSelf ? 'Verify this session?' : 'Verification request', + _isSelf + ? 'Another of your sessions wants to verify this one. Accept, ' + 'then compare the emoji on both screens.' + : '$_who wants to verify you. Accept, then compare the emoji ' + 'on both screens.', + [ + OutlinedButton( + onPressed: _busy ? null : () => _run(request.rejectVerification), + child: const Text('Decline'), + ), + FilledButton( + onPressed: _busy ? null : () => _run(request.acceptVerification), + child: const Text('Accept'), + ), + ], + ), + KeyVerificationState.askSas => ( + 'Compare emoji', + 'Check that these appear, in the same order, on $_who\'s screen.', + [ + OutlinedButton( + onPressed: _busy ? null : () => _run(request.rejectSas), + child: const Text("They don't match"), + ), + FilledButton( + onPressed: _busy ? null : () => _run(request.acceptSas), + child: const Text('They match'), + ), + ], + ), + KeyVerificationState.waitingSas => ( + 'Waiting…', + 'Waiting for $_who to confirm the emoji match.', + [], + ), + KeyVerificationState.done => ( + 'Verified', + _isSelf + ? 'This session is verified. It can read your encrypted ' + 'messages, and the people you talk to will trust it.' + : '$_who is verified. Messages from their verified sessions ' + 'are trusted.', + [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: const Text('Done'), + ), + ], + ), + KeyVerificationState.error => ( + 'Verification stopped', + _cancelText(request.canceledCode, request.canceledReason), + [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ), + _ => ('Verifying…', 'Waiting for $_who to accept.', []), + }; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(title, style: text.titleLarge), + const SizedBox(height: 8), + Text(body, style: text.bodyMedium?.copyWith(color: c.inkMuted)), + if (request.state == KeyVerificationState.askSas) ...[ + const SizedBox(height: 24), + _SasView(request), + ], + if (actions.isEmpty && + request.state != KeyVerificationState.done) ...[ + const SizedBox(height: 24), + const Center(child: CircularProgressIndicator()), + const SizedBox(height: 16), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ], + if (actions.isNotEmpty) ...[ + const SizedBox(height: 24), + Row( + children: [ + for (final (i, a) in actions.indexed) ...[ + if (i > 0) const SizedBox(width: 12), + Expanded(child: a), + ], + ], + ), + ], + ], + ), + ), + ); + } + + static String _cancelText(String? code, String? reason) => switch (code) { + 'm.user' => 'The verification was canceled.', + 'm.timeout' => 'The verification took too long.', + 'm.mismatched_sas' || + 'm.key_mismatch' => "The emoji didn't match. Nobody was verified.", + 'm.accepted' => 'Another of your sessions answered the request.', + _ => reason ?? 'Something went wrong.', + }; +} + +class _SasView extends StatelessWidget { + const _SasView(this.request); + final KeyVerification request; + + @override + Widget build(BuildContext context) { + final text = Theme.of(context).textTheme; + if (!request.sasTypes.contains('emoji')) { + return Text( + request.sasNumbers.join(' '), + textAlign: TextAlign.center, + style: text.headlineMedium?.copyWith(fontFamily: fontMono), + ); + } + return Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 16, + children: [ + for (final emoji in request.sasEmojis) + SizedBox( + width: 72, + child: Column( + children: [ + Text(emoji.emoji, style: const TextStyle(fontSize: 36)), + const SizedBox(height: 4), + Text( + emoji.name, + textAlign: TextAlign.center, + style: text.labelMedium, + ), + ], + ), + ), + ], + ); + } +} diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock new file mode 100644 index 0000000..922d487 --- /dev/null +++ b/apps/mobile/pubspec.lock @@ -0,0 +1,1250 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + android_file_picker: + dependency: transitive + description: + name: android_file_picker + sha256: e94229cc41c4bb5fb6a2254146340a8b650664c99b5c93b9001f66c3375d0060 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + archive: + dependency: transitive + description: + name: archive + sha256: "0d58d1fa1fddf6588251ccf2819f3fb3b6d0452c1b2813ac7413a05592bfbefe" + url: "https://pub.dev" + source: hosted + version: "4.5.2" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + base58check: + dependency: transitive + description: + name: base58check + sha256: "6c300dfc33e598d2fe26319e13f6243fea81eaf8204cb4c6b69ef20a625319a5" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + blurhash_dart: + dependency: transitive + description: + name: blurhash_dart + sha256: "43955b6c2e30a7d440028d1af0fa185852f3534b795cc6eb81fbf397b464409f" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + canonical_json: + dependency: transitive + description: + name: canonical_json + sha256: "618db0b1ea4238a3ef2f325908beee6269550d73b69dc5220456b75ce3cec072" + url: "https://pub.dev" + source: hosted + version: "1.1.3" + change_case: + dependency: transitive + description: + name: change_case + sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003 + url: "https://pub.dev" + source: hosted + version: "2.2.0" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + dbus: + dependency: transitive + description: + name: dbus + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 + url: "https://pub.dev" + source: hosted + version: "0.7.15" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "98c0b156b6380ba55bc767a14e2e6b3feb246e713ad40092424596fa79005bea" + url: "https://pub.dev" + source: hosted + version: "13.1.0" + file_picker_darwin: + dependency: transitive + description: + name: file_picker_darwin + sha256: a5a48960703e7faa880544ef188491f442a93959b6afb5cae57575e5dc836589 + url: "https://pub.dev" + source: hosted + version: "2.3.2" + file_picker_linux: + dependency: transitive + description: + name: file_picker_linux + sha256: e7db600f50672ce5ebbe422ac0906e0f3d2a476188cc93e7126afee12bf08063 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + file_picker_platform_interface: + dependency: transitive + description: + name: file_picker_platform_interface + sha256: "336e7168306834a2b68e6168ed768e00d3fabeac23154d5793e277ca9f97cf20" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + file_picker_web: + dependency: transitive + description: + name: file_picker_web + sha256: a2288008280aabe371aff029466b3405e60838e0a1d714767488c59b926c8b8c + url: "https://pub.dev" + source: hosted + version: "4.2.0" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab + url: "https://pub.dev" + source: hosted + version: "0.9.4+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 + url: "https://pub.dev" + source: hosted + version: "0.9.5+1" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.dev" + source: hosted + version: "0.9.3+6" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "9ee28742343dd13df758bb4185e8ded37ac2d1aeca6648c3ba7862c99797517e" + url: "https://pub.dev" + source: hosted + version: "22.3.2" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "3b89a4bf095fa5f8a99beeabf982c5bd506c3ad25e7b9e61da945e994dc5b615" + url: "https://pub.dev" + source: hosted + version: "8.0.2" + 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_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "2b7f9d2a3c730ac1a98221b420cef966b990fca7ab546ede324642ca57a533e3" + url: "https://pub.dev" + source: hosted + version: "3.4.3" + flutter_rust_bridge: + dependency: transitive + description: + name: flutter_rust_bridge + sha256: ec69331e2335ab4d456c0a9fc700892c2c85b72b9c0cb39df3ecfe8833d5d5c8 + url: "https://pub.dev" + source: hosted + version: "2.13.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: d4e1fb6b2cb524868929e78dc0282fa000554b22060fb53789dc481c9fc95bb8 + url: "https://pub.dev" + source: hosted + version: "11.2.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: a031ceac9b070e62ef183cd7f7d1a8798ebde402d5049f0fe6eb3a51bd797a21 + url: "https://pub.dev" + source: hosted + version: "0.4.3" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: caa75bd78f017422912e3a904933113b2428eb79f981ac37acd2dd2a700458ea + url: "https://pub.dev" + source: hosted + version: "3.0.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "64951127f001f546891c86f414972bf49ef2c4c8a4c92c65f5790cc0c9100045" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_vodozemac: + dependency: "direct main" + description: + name: flutter_vodozemac + sha256: "209e7661da113a5f889ae55d574c48f245145def64df9a9fcac04d147e6f9d4e" + url: "https://pub.dev" + source: hosted + version: "0.8.1" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + html: + dependency: transitive + description: + name: html + sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" + url: "https://pub.dev" + source: hosted + version: "0.15.7" + html_unescape: + dependency: transitive + description: + name: html_unescape + sha256: "15362d7a18f19d7b742ef8dcb811f5fd2a2df98db9f80ea393c075189e0b61e3" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: e71b1bd9e40124041128d60fc3034c7b1598e1bba4e570bba0c70301c817013a + url: "https://pub.dev" + source: hosted + version: "1.2.4" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "8a7823d5d3e884f7780829d85d92110d072bdcd9ed48e2be158d2abe91a912d4" + url: "https://pub.dev" + source: hosted + version: "0.8.13+25" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "4fb88d4350ffc208c2a09fc321db1f80282688716b8cfe10c8c80acc5ae8934a" + url: "https://pub.dev" + source: hosted + version: "0.8.13+10" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "2dee4b4e3be429db71472c9c559c981498070a61af1ba82e0e4986fc5dadc042" + url: "https://pub.dev" + source: hosted + version: "2.11.2" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" + jni: + dependency: transitive + description: + name: jni + sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191" + url: "https://pub.dev" + source: hosted + version: "1.0.4+1" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + listen: + dependency: transitive + description: + name: listen + sha256: "47501a08016a43fcad79252439d723f50f14f88fa7bfd8a177e0a417e5c9e1f2" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 + url: "https://pub.dev" + source: hosted + version: "7.3.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + matrix: + dependency: "direct main" + description: + name: matrix + sha256: "5c7afb10467790769c87285545eb2de79c40696dcc1f4fe515b63c69674671da" + url: "https://pub.dev" + source: hosted + version: "14.1.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mime: + dependency: "direct main" + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + native_toolchain_cmake: + dependency: transitive + description: + name: native_toolchain_cmake + sha256: de90e8952bad0684bdcff4d674c53d961f5cd1fdcf3a0255a669a35986c4ac1d + url: "https://pub.dev" + source: hosted + version: "0.2.7" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + random_string: + dependency: transitive + description: + name: random_string + sha256: "03b52435aae8cbdd1056cf91bfc5bf845e9706724dd35ae2e99fa14a1ef79d02" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "484dfc873ea4c4f4240e5635444fa066f87b07862b7279ebd004a2b71fba4b7a" + url: "https://pub.dev" + source: hosted + version: "3.4.3" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: "7b52006a5ceae1f3d5af7f77188c3290d6e7d8ded16d99809bea84967c65c257" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: a1489b99cce597c45a54b9aae1cd94c8d4705353b7e0bb2457a6e4de44e0ad8a + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: "94a5535277510a63184ca178ce12a1449bc0b38618879aa1c18bf57369c5064a" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: dafc6922b0bfbf1d48cf3ccbf519b4fff47bdcb820da1728ea6db675fecc9324 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + sdp_transform: + dependency: transitive + description: + name: sdp_transform + sha256: "73e412a5279a5c2de74001535208e20fff88f225c9a4571af0f7146202755e45" + url: "https://pub.dev" + source: hosted + version: "0.3.2" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + slugify: + dependency: transitive + description: + name: slugify + sha256: b272501565cb28050cac2d96b7bf28a2d24c8dae359280361d124f3093d337c3 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: "273218a1678360a3127fcf15e543381d9b813d413d783bc6e9a53eed4d2eb8fe" + url: "https://pub.dev" + source: hosted + version: "2.4.4+2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: e4f659c14bdd14464127fcd234a7a96f61fe0f539b8568e5a38f99f5a568a835 + url: "https://pub.dev" + source: hosted + version: "2.4.4+1" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: b80e94ff0d551d5f1f8c7d14348d29b576cca3ae30f6e195a7c380552c32b1e0 + url: "https://pub.dev" + source: hosted + version: "2.5.14" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "8de68fc306040f992aaf378750c16f95e4298c4bb150dd45091d75e42b1f08ee" + url: "https://pub.dev" + source: hosted + version: "2.4.4+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: c9177b2f3150ed0647b90a94307c28452fe0a5401863044216aed83addd6c7c5 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "4c7fe79840389aaeaf05fd093f795b631b5a98e2bd28d54e555c100f4a9c7a1c" + url: "https://pub.dev" + source: hosted + version: "3.5.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: b3d516dbd3757ff2a458735e4f6cbac9ff261f576f6e87bcb17feabb39e657c5 + url: "https://pub.dev" + source: hosted + version: "3.5.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + timezone: + dependency: transitive + description: + name: timezone + sha256: "9f4e5a1a0e4fef7dd3424842c0575db01bfb7a11730e8d55753fcd5c5c98767b" + url: "https://pub.dev" + source: hosted + version: "0.11.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + unifiedpush: + dependency: "direct main" + description: + name: unifiedpush + sha256: "8ed9767f750a1dc6159a77e2171641d0cb825dc87682d1ce1b8618689b79f58e" + url: "https://pub.dev" + source: hosted + version: "6.2.0" + unifiedpush_android: + dependency: transitive + description: + name: unifiedpush_android + sha256: a902e505e91c8e28b54b379bb27f255f66df409274a97af24d6719f12f0d13dc + url: "https://pub.dev" + source: hosted + version: "3.5.0" + unifiedpush_linux: + dependency: transitive + description: + name: unifiedpush_linux + sha256: c062d5eedd1cec70bcd33270cc4e01ae0ff6501f33d471167c06b34a968adfeb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + unifiedpush_platform_interface: + dependency: transitive + description: + name: unifiedpush_platform_interface + sha256: "83372bc8d794b8b12ef6993b518d7be907dcfc2191bdf6de0ece5c4445d89880" + url: "https://pub.dev" + source: hosted + 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" + unorm_dart: + dependency: transitive + description: + name: unorm_dart + sha256: b4f4d2aff27d65a5fc37f400d4c09e55d7535359315acbe3b895a9add4de3a23 + url: "https://pub.dev" + source: hosted + version: "0.3.3" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" + url: "https://pub.dev" + source: hosted + version: "6.3.33" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" + url: "https://pub.dev" + source: hosted + version: "6.4.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" + url: "https://pub.dev" + source: hosted + version: "3.2.6" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78" + url: "https://pub.dev" + source: hosted + version: "15.4.0" + vodozemac: + dependency: transitive + description: + name: vodozemac + sha256: efce261575f7de97ebd052b16775cd57f940e8212af47d288050c166b9f9a167 + url: "https://pub.dev" + source: hosted + version: "0.8.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + webcrypto: + dependency: transitive + description: + name: webcrypto + sha256: "9885ed99a846191542dd6ca820c83a098117df56be57453832233a3fef68f3f2" + url: "https://pub.dev" + source: hosted + version: "0.6.1" + webpush_encryption: + dependency: transitive + description: + name: webpush_encryption + sha256: "5b83272b91acda6ae515fcd980c94f06bf413702282497c5a68f5dfc64fed27f" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + webrtc_interface: + dependency: transitive + description: + name: webrtc_interface + sha256: c6f100eac5057d9a817a60473126f9828c796d42884d498af4f339c97b21014f + url: "https://pub.dev" + source: hosted + version: "1.5.1" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + window_manager: + dependency: transitive + description: + name: window_manager + sha256: "05c231fd7b23d2380f14c5cc10b7b93d60d4fa4a2fb4e0f032de27e44b5560e9" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + windows_file_picker: + dependency: transitive + description: + name: windows_file_picker + sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.12.2 <4.0.0" + flutter: ">=3.44.0" diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml new file mode 100644 index 0000000..fa3517d --- /dev/null +++ b/apps/mobile/pubspec.yaml @@ -0,0 +1,93 @@ +name: warp +description: "Warp: a Matrix client" +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.12.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + matrix: ^14.1.0 + flutter_vodozemac: ^0.8.1 + flutter_riverpod: ^3.4.3 + flutter_secure_storage: ^11.2.0 + sqflite: ^2.4.4+2 + path_provider: ^2.1.6 + path: ^1.9.1 + file_picker: ^13.1.0 + image_picker: ^1.2.4 + flutter_local_notifications: ^22.3.2 + unifiedpush: ^6.2.0 + http: ^1.6.0 + intl: ^0.20.3 + url_launcher: ^6.3.3 + mime: ^2.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + flutter_launcher_icons: ^0.14.4 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + uses-material-design: true + assets: + - assets/logo/ + # The brand fonts (SIL Open Font License), converted from the woff2 files + # in apps/ui/src/styles/fonts: Flutter can't load woff2. + fonts: + - family: Outfit + fonts: + - asset: assets/fonts/Outfit-Variable.ttf + - family: Figtree + fonts: + - asset: assets/fonts/Figtree-Variable.ttf + - family: JetBrains Mono + fonts: + - asset: assets/fonts/JetBrainsMono-Variable.ttf + +# `dart run flutter_launcher_icons` regenerates the Android and iOS icons. +flutter_launcher_icons: + android: true + ios: true + image_path: tool/icons/app-icon.png + adaptive_icon_background: "#00A389" + adaptive_icon_foreground: tool/icons/foreground.png + adaptive_icon_foreground_inset: 28 + remove_alpha_ios: true diff --git a/apps/mobile/test/format_test.dart b/apps/mobile/test/format_test.dart new file mode 100644 index 0000000..66cf29d --- /dev/null +++ b/apps/mobile/test/format_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:warp/matrix/client.dart'; +import 'package:warp/util/format.dart'; + +void main() { + group('formatBytes', () { + test('bytes', () => expect(formatBytes(980), '980 B')); + test('kilobytes', () => expect(formatBytes(12345), '12 KB')); + test('small megabytes keep a decimal', () { + expect(formatBytes(3400000), '3.4 MB'); + }); + test('gigabytes', () => expect(formatBytes(2000000000), '2.0 GB')); + }); + + group('formatListTime', () { + final now = DateTime(2026, 10, 18, 15, 30); + test('today is the time', () { + expect(formatListTime(DateTime(2026, 10, 18, 9, 5), now: now), '09:05'); + }); + test('yesterday', () { + expect(formatListTime(DateTime(2026, 10, 17, 23), now: now), 'Yesterday'); + }); + test('this week is the weekday', () { + expect(formatListTime(DateTime(2026, 10, 14), now: now), 'Wed'); + }); + }); + + group('formatDay', () { + final now = DateTime(2026, 10, 18, 15, 30); + test( + 'today', + () => expect(formatDay(DateTime(2026, 10, 18), now: now), 'Today'), + ); + test('yesterday', () { + expect(formatDay(DateTime(2026, 10, 17), now: now), 'Yesterday'); + }); + }); + + group('serverOfUserId', () { + test('a full user ID', () { + expect(serverOfUserId('@alice:example.org'), 'example.org'); + }); + test('with a port', () { + expect(serverOfUserId(' @bob:localhost:8008 '), 'localhost:8008'); + }); + test('a bare username', () => expect(serverOfUserId('alice'), isNull)); + test('no server', () => expect(serverOfUserId('@alice:'), isNull)); + }); +} diff --git a/apps/mobile/test/timeline_test.dart b/apps/mobile/test/timeline_test.dart new file mode 100644 index 0000000..4c908dc --- /dev/null +++ b/apps/mobile/test/timeline_test.dart @@ -0,0 +1,95 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:warp/matrix/timeline.dart'; + +Entry msg(String sender, DateTime time) => + (sender: sender, time: time, kind: EntryKind.message); +Entry notice(DateTime time) => + (sender: '@admin:x', time: time, kind: EntryKind.notice); +Entry hidden(DateTime time) => + (sender: '@a:x', time: time, kind: EntryKind.hidden); + +/// Describes items compactly: `D` for a day, `m`, +/// `n` for a notice. +List describe(List items) => [ + for (final item in items) + switch (item) { + DayItem() => 'D', + EventItem(:final index, :final kind) when kind == EntryKind.notice => + 'n$index', + EventItem(:final index, :final firstInGroup, :final lastInGroup) => + 'm$index${firstInGroup ? 'F' : ''}${lastInGroup ? 'L' : ''}', + }, +]; + +void main() { + final t0 = DateTime(2026, 10, 18, 12); + + test('empty', () { + expect(projectTimeline([]), isEmpty); + }); + + test('a run from one sender is one group', () { + // Newest first, as the SDK keeps them. + final items = projectTimeline([ + msg('@a:x', t0.add(const Duration(minutes: 2))), + msg('@a:x', t0.add(const Duration(minutes: 1))), + msg('@a:x', t0), + ]); + expect(describe(items), ['m0L', 'm1', 'm2F', 'D']); + }); + + test('a new sender starts a new group', () { + final items = projectTimeline([ + msg('@b:x', t0.add(const Duration(minutes: 1))), + msg('@a:x', t0), + ]); + expect(describe(items), ['m0FL', 'm1FL', 'D']); + }); + + test('a long pause starts a new group', () { + final items = projectTimeline([ + msg('@a:x', t0.add(const Duration(minutes: 6))), + msg('@a:x', t0), + ]); + expect(describe(items), ['m0FL', 'm1FL', 'D']); + }); + + test('hidden events are dropped and do not break a group', () { + final items = projectTimeline([ + msg('@a:x', t0.add(const Duration(minutes: 2))), + hidden(t0.add(const Duration(minutes: 1))), + msg('@a:x', t0), + ]); + expect(describe(items), ['m0L', 'm2F', 'D']); + }); + + test('a notice breaks a group', () { + final items = projectTimeline([ + msg('@a:x', t0.add(const Duration(minutes: 2))), + notice(t0.add(const Duration(minutes: 1))), + msg('@a:x', t0), + ]); + expect(describe(items), ['m0FL', 'n1', 'm2FL', 'D']); + }); + + test('a day separator above the first event of each day', () { + final yesterday = t0.subtract(const Duration(days: 1)); + final items = projectTimeline([ + msg('@a:x', t0), + msg('@a:x', yesterday.add(const Duration(minutes: 1))), + msg('@a:x', yesterday), + ]); + expect(describe(items), ['m0FL', 'D', 'm1L', 'm2F', 'D']); + expect((items[1] as DayItem).day, DateTime(2026, 10, 18)); + expect((items[4] as DayItem).day, DateTime(2026, 10, 17)); + }); + + test('messages either side of midnight are separate groups', () { + final midnight = DateTime(2026, 10, 18); + final items = projectTimeline([ + msg('@a:x', midnight.add(const Duration(minutes: 1))), + msg('@a:x', midnight.subtract(const Duration(minutes: 1))), + ]); + expect(describe(items), ['m0FL', 'D', 'm1FL', 'D']); + }); +} diff --git a/apps/mobile/tool/icons/app-icon.png b/apps/mobile/tool/icons/app-icon.png new file mode 100644 index 0000000..682ee6c Binary files /dev/null and b/apps/mobile/tool/icons/app-icon.png differ diff --git a/apps/mobile/tool/icons/foreground.png b/apps/mobile/tool/icons/foreground.png new file mode 100644 index 0000000..eaf2317 Binary files /dev/null and b/apps/mobile/tool/icons/foreground.png differ