Android app (apps/mobile) with Flutter
apps/mobile is a Matrix client for Android on the famedly Matrix Dart SDK, with vodozemac (compiled from Rust during the build) for end-to-end encryption. It speaks the plain Client-Server API and uses the web client's design tokens, fonts and symbol. - Sign in by server name (.well-known), homeserver address or full user ID; sign up with the dummy and registration token stages. Sessions survive restarts: SQLite in private storage, credentials also in the Keystore, both excluded from backups and device transfers. - Room list with unread and mention counts, invites, search and filters. Timeline with grouping, day separators, notices, history on scroll, links, images and files (authenticated media). Sending text, photos, camera shots and files, with Sending/Sent/Read/Not sent and Retry. Typing, receipts, deleting. New rooms and DMs, encrypted by default; joining by address; room info with members, invite, rename, topic, notification level, leave. - Encryption: one-step setup behind a recovery key, verifying a new session with the recovery key (restoring the backup) or another session, emoji verification of people and sessions, reset. Undecryptable messages say why. Session list with verify and sign out. - Notifications while running, and push through UnifiedPush (no Firebase): the distributor's endpoint becomes an event_id_only pusher, the gateway is discovered, and a push starts the app headless to fetch, decrypt and show the event. Make targets: mobile (release APKs per ABI into bin/), mobile-run, mobile-test. Kotlin incremental compilation is off (Windows, project and pub cache on different drives), and Tink's JVM artifact is excluded in favour of tink-android. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No files matched your search
@@ -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 .
|
||||
|
||||
@@ -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/
|
||||
```
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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 <endpoint origin>/_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-<abi>.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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 = "../.."
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<!-- Debug builds may talk to a homeserver over plain HTTP, such as a
|
||||
development server at http://10.0.2.2:8008 from the emulator.
|
||||
Release builds are HTTPS only. -->
|
||||
<application android:usesCleartextTraffic="true" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,58 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<application
|
||||
android:label="Warp"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<!-- Links in messages open in the browser (url_launcher). -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="https" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package io.github.dariuszjaniak.warp
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="28%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#00A389</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Keep the session and the encryption keys out of cloud backups and device
|
||||
transfers: restored onto another device they would be a copy of this
|
||||
session, with its keys, that the homeserver doesn't know about. -->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" />
|
||||
<exclude domain="file" />
|
||||
<exclude domain="database" />
|
||||
<exclude domain="sharedpref" />
|
||||
<exclude domain="external" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" />
|
||||
<exclude domain="file" />
|
||||
<exclude domain="database" />
|
||||
<exclude domain="sharedpref" />
|
||||
<exclude domain="external" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -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<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
After Width: | Height: | Size: 6.5 KiB |
@@ -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
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>App</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>io.flutter.flutter.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>App</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -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 = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||
);
|
||||
name = Flutter;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146E51CF9000F007C117D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9740EEB11CF90186004384FC /* Flutter */,
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146EF1CF9000F007C117D /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C147001CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,119 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Run Prepare Flutter Framework Script"
|
||||
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
||||
BuildableName = "RunnerTests.xctest"
|
||||
BlueprintName = "RunnerTests"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
enableGPUValidationMode = "1"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Profile"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"}}
|
||||
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 667 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 900 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
@@ -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.
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Flutter View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Warp</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>warp</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
@@ -0,0 +1,6 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: FlutterSceneDelegate {
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<NavigatorState>();
|
||||
|
||||
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 = <StreamSubscription>[];
|
||||
|
||||
@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();
|
||||
}
|
||||
@@ -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<void> main(List<String> 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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<Client> 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<void> resolveHomeserver(Client client, String input) async {
|
||||
final text = input.trim();
|
||||
if (text.isEmpty) throw const FormatException('Enter a server.');
|
||||
|
||||
final candidates = <Uri>[];
|
||||
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: ', '');
|
||||
}
|
||||
@@ -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<String> setUpEncryption(Encryption encryption) {
|
||||
final done = Completer<String>();
|
||||
BootstrapState? handled;
|
||||
|
||||
Future<void> 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<void> 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 = <String, Future<Uint8List?>>{};
|
||||
|
||||
static Future<Uint8List?> _remember(
|
||||
String key,
|
||||
Future<Uint8List?> 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<Uint8List?> 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<Uint8List?> 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');
|
||||
}
|
||||
}
|
||||
@@ -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<String>.broadcast();
|
||||
static bool _ready = false;
|
||||
|
||||
/// Room IDs of notifications the user tapped while the app was running.
|
||||
static Stream<String> 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<void> 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<String?> 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<bool> requestPermission() async =>
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.requestNotificationsPermission() ??
|
||||
true;
|
||||
|
||||
/// Shows [event] unless the user is looking at its room.
|
||||
static Future<void> 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<void> 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<void> clearRoom(String roomId) async {
|
||||
if (!_ready) return;
|
||||
await _plugin.cancel(id: _idFor(roomId));
|
||||
}
|
||||
|
||||
static Future<void> 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<Event> watch(Client client) =>
|
||||
client.onNotification.stream.listen((event) {
|
||||
if (kDebugMode) debugPrint('notify ${event.eventId}');
|
||||
showEvent(event);
|
||||
});
|
||||
}
|
||||
@@ -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<void> 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<List<String>> distributors() => UnifiedPush.getDistributors();
|
||||
|
||||
static Future<String?> 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<bool> 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<void> 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<void> forget() async {
|
||||
try {
|
||||
await UnifiedPush.unregister();
|
||||
} catch (_) {}
|
||||
await _storage.delete(key: _endpointKey);
|
||||
}
|
||||
|
||||
static Future<void> _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<void> _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<void> _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<String> _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<void> _onMessage(PushMessage message) async {
|
||||
final client = _client;
|
||||
Map<String, Object?> notification;
|
||||
try {
|
||||
final json = jsonDecode(utf8.decode(message.content));
|
||||
notification = Map<String, Object?>.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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<TimelineItem> projectTimeline(List<Entry> entries) {
|
||||
final shown = <int>[
|
||||
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 = <TimelineItem>[];
|
||||
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;
|
||||
}
|
||||
@@ -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<EncryptionScreen> createState() => _EncryptionScreenState();
|
||||
}
|
||||
|
||||
class _EncryptionScreenState extends ConsumerState<EncryptionScreen> {
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _run(Future<void> 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<void> _setUp({required bool reset}) async {
|
||||
final encryption = ref.read(clientProvider).encryption!;
|
||||
if (reset) {
|
||||
final ok = await showDialog<bool>(
|
||||
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<void> _verifyWithKey() async {
|
||||
final controller = TextEditingController();
|
||||
final input = await showDialog<String>(
|
||||
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<void> _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<Widget> 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
_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<Profile>(
|
||||
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<void> _act(Future<void> 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));
|
||||
}
|
||||
}),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
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<void> _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<Widget> 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,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<RegisterScreen> createState() => _RegisterScreenState();
|
||||
}
|
||||
|
||||
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
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<void> _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<void> _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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<RoomInfoScreen> createState() => _RoomInfoScreenState();
|
||||
}
|
||||
|
||||
class _RoomInfoScreenState extends ConsumerState<RoomInfoScreen> {
|
||||
late Future<List<User>> _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<String?> _ask(String title, String label, {String initial = ''}) {
|
||||
final controller = TextEditingController(text: initial);
|
||||
return showDialog<String>(
|
||||
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<void> _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<void> _leave() async {
|
||||
final room = _room;
|
||||
final ok = await showDialog<bool>(
|
||||
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<void> _enableEncryption() async {
|
||||
final ok = await showDialog<bool>(
|
||||
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<PushRuleState>(
|
||||
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<List<User>>(
|
||||
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<void> _showMember(
|
||||
BuildContext context,
|
||||
DeviceKeysList keys,
|
||||
bool verified,
|
||||
) async {
|
||||
final verify = await showModalBottomSheet<bool>(
|
||||
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))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void> route(String roomId) => MaterialPageRoute(
|
||||
settings: RouteSettings(name: 'room/$roomId'),
|
||||
builder: (_) => RoomScreen(roomId: roomId),
|
||||
);
|
||||
|
||||
@override
|
||||
ConsumerState<RoomScreen> createState() => _RoomScreenState();
|
||||
}
|
||||
|
||||
class _RoomScreenState extends ConsumerState<RoomScreen>
|
||||
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<void> _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<void> _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<void> _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<void> _attach() async {
|
||||
final choice = await showModalBottomSheet<String>(
|
||||
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<Event> 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<User> 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<void> _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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<SessionsScreen> createState() => _SessionsScreenState();
|
||||
}
|
||||
|
||||
class _SessionsScreenState extends ConsumerState<SessionsScreen> {
|
||||
late Future<List<Device>?> _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<void> _signOut(Device device) async {
|
||||
final ok = await showDialog<bool>(
|
||||
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<void> _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<List<Device>?>(
|
||||
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<String>(
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||