feat: Phase 1 complete — Matrix login, rooms, chat, profile
- Direct m.login.password auth against matrix.m8chat.au - Room list with unread badges, last message, timestamps - Chat timeline (text, images, files, replies, reactions) - Profile screen with expandable Notifications and Security sections - Olm E2EE initialisation (web WASM bootstrap) - Global error handler preventing Matrix SDK crashes - GoRouter with refreshListenable (no recreation on auth change) - Feature-first clean architecture: Riverpod + GoRouter + Drift - Deployed to https://app2.m8chat.au Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.packages
|
||||||
|
build/
|
||||||
|
*.g.dart
|
||||||
|
*.freezed.dart
|
||||||
|
pubspec.lock
|
||||||
|
web/olm.js
|
||||||
|
web/olm.wasm
|
||||||
19
.idea/libraries/Dart_SDK.xml
generated
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<component name="libraryTable">
|
||||||
|
<library name="Dart SDK">
|
||||||
|
<CLASSES>
|
||||||
|
<root url="file:///mnt/georgeai/home/help4bis/flutter/bin/cache/dart-sdk/lib/async" />
|
||||||
|
<root url="file:///mnt/georgeai/home/help4bis/flutter/bin/cache/dart-sdk/lib/collection" />
|
||||||
|
<root url="file:///mnt/georgeai/home/help4bis/flutter/bin/cache/dart-sdk/lib/convert" />
|
||||||
|
<root url="file:///mnt/georgeai/home/help4bis/flutter/bin/cache/dart-sdk/lib/core" />
|
||||||
|
<root url="file:///mnt/georgeai/home/help4bis/flutter/bin/cache/dart-sdk/lib/developer" />
|
||||||
|
<root url="file:///mnt/georgeai/home/help4bis/flutter/bin/cache/dart-sdk/lib/html" />
|
||||||
|
<root url="file:///mnt/georgeai/home/help4bis/flutter/bin/cache/dart-sdk/lib/io" />
|
||||||
|
<root url="file:///mnt/georgeai/home/help4bis/flutter/bin/cache/dart-sdk/lib/isolate" />
|
||||||
|
<root url="file:///mnt/georgeai/home/help4bis/flutter/bin/cache/dart-sdk/lib/math" />
|
||||||
|
<root url="file:///mnt/georgeai/home/help4bis/flutter/bin/cache/dart-sdk/lib/mirrors" />
|
||||||
|
<root url="file:///mnt/georgeai/home/help4bis/flutter/bin/cache/dart-sdk/lib/typed_data" />
|
||||||
|
</CLASSES>
|
||||||
|
<JAVADOC />
|
||||||
|
<SOURCES />
|
||||||
|
</library>
|
||||||
|
</component>
|
||||||
15
.idea/libraries/KotlinJavaRuntime.xml
generated
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<component name="libraryTable">
|
||||||
|
<library name="KotlinJavaRuntime">
|
||||||
|
<CLASSES>
|
||||||
|
<root url="jar://$KOTLIN_BUNDLED$/lib/kotlin-stdlib.jar!/" />
|
||||||
|
<root url="jar://$KOTLIN_BUNDLED$/lib/kotlin-reflect.jar!/" />
|
||||||
|
<root url="jar://$KOTLIN_BUNDLED$/lib/kotlin-test.jar!/" />
|
||||||
|
</CLASSES>
|
||||||
|
<JAVADOC />
|
||||||
|
<SOURCES>
|
||||||
|
<root url="jar://$KOTLIN_BUNDLED$/lib/kotlin-stdlib-sources.jar!/" />
|
||||||
|
<root url="jar://$KOTLIN_BUNDLED$/lib/kotlin-reflect-sources.jar!/" />
|
||||||
|
<root url="jar://$KOTLIN_BUNDLED$/lib/kotlin-test-sources.jar!/" />
|
||||||
|
</SOURCES>
|
||||||
|
</library>
|
||||||
|
</component>
|
||||||
9
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/m8chat_app.iml" filepath="$PROJECT_DIR$/m8chat_app.iml" />
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/android/m8chat_app_android.iml" filepath="$PROJECT_DIR$/android/m8chat_app_android.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/runConfigurations/main_dart.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="main.dart" type="FlutterRunConfigurationType" factoryName="Flutter">
|
||||||
|
<option name="filePath" value="$PROJECT_DIR$/lib/main.dart" />
|
||||||
|
<method />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
36
.idea/workspace.xml
generated
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="FileEditorManager">
|
||||||
|
<leaf>
|
||||||
|
<file leaf-file-name="main.dart" pinned="false" current-in-tab="true">
|
||||||
|
<entry file="file://$PROJECT_DIR$/lib/main.dart">
|
||||||
|
<provider selected="true" editor-type-id="text-editor">
|
||||||
|
<state relative-caret-position="0">
|
||||||
|
<caret line="0" column="0" lean-forward="false" selection-start-line="0" selection-start-column="0" selection-end-line="0" selection-end-column="0" />
|
||||||
|
</state>
|
||||||
|
</provider>
|
||||||
|
</entry>
|
||||||
|
</file>
|
||||||
|
</leaf>
|
||||||
|
</component>
|
||||||
|
<component name="ToolWindowManager">
|
||||||
|
<editor active="true" />
|
||||||
|
<layout>
|
||||||
|
<window_info id="Project" active="false" anchor="left" auto_hide="false" internal_type="DOCKED" type="DOCKED" visible="true" show_stripe_button="true" weight="0.25" sideWeight="0.5" order="0" side_tool="false" content_ui="combo" />
|
||||||
|
</layout>
|
||||||
|
</component>
|
||||||
|
<component name="ProjectView">
|
||||||
|
<navigator currentView="ProjectPane" proportions="" version="1">
|
||||||
|
</navigator>
|
||||||
|
<panes>
|
||||||
|
<pane id="ProjectPane">
|
||||||
|
<option name="show-excluded-files" value="false" />
|
||||||
|
</pane>
|
||||||
|
</panes>
|
||||||
|
</component>
|
||||||
|
<component name="PropertiesComponent">
|
||||||
|
<property name="last_opened_file_path" value="$PROJECT_DIR$" />
|
||||||
|
<property name="dart.analysis.tool.window.force.activate" value="true" />
|
||||||
|
<property name="show.migrate.to.gradle.popup" value="false" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
36
.metadata
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# 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: "582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536"
|
||||||
|
channel: "stable"
|
||||||
|
|
||||||
|
project_type: app
|
||||||
|
|
||||||
|
# Tracks metadata for the flutter migrate command
|
||||||
|
migration:
|
||||||
|
platforms:
|
||||||
|
- platform: root
|
||||||
|
create_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536
|
||||||
|
base_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536
|
||||||
|
- platform: android
|
||||||
|
create_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536
|
||||||
|
base_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536
|
||||||
|
- platform: ios
|
||||||
|
create_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536
|
||||||
|
base_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536
|
||||||
|
- platform: web
|
||||||
|
create_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536
|
||||||
|
base_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536
|
||||||
|
|
||||||
|
# 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'
|
||||||
17
README.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# m8chat_app
|
||||||
|
|
||||||
|
A new Flutter project.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
This project is a starting point for a Flutter application.
|
||||||
|
|
||||||
|
A few resources to get you started if this is your first Flutter project:
|
||||||
|
|
||||||
|
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||||
|
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||||
|
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||||
|
|
||||||
|
For help getting started with Flutter development, view the
|
||||||
|
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||||
|
samples, guidance on mobile development, and a full API reference.
|
||||||
28
analysis_options.yaml
Normal file
@@ -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
|
||||||
14
android/.gitignore
vendored
Normal file
@@ -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
|
||||||
44
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("kotlin-android")
|
||||||
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "au.m8chat.m8chat_app"
|
||||||
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
|
applicationId = "au.m8chat.m8chat_app"
|
||||||
|
// You can update the following values to match your application needs.
|
||||||
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
|
minSdk = flutter.minSdkVersion
|
||||||
|
targetSdk = flutter.targetSdkVersion
|
||||||
|
versionCode = flutter.versionCode
|
||||||
|
versionName = flutter.versionName
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
// TODO: Add your own signing config for the release build.
|
||||||
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source = "../.."
|
||||||
|
}
|
||||||
7
android/app/src/debug/AndroidManifest.xml
Normal file
@@ -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>
|
||||||
45
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application
|
||||||
|
android:label="m8chat_app"
|
||||||
|
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>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package au.m8chat.m8chat_app
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal file
@@ -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>
|
||||||
12
android/app/src/main/res/drawable/launch_background.xml
Normal file
@@ -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>
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 544 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 442 B |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 721 B |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
18
android/app/src/main/res/values-night/styles.xml
Normal file
@@ -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>
|
||||||
18
android/app/src/main/res/values/styles.xml
Normal file
@@ -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>
|
||||||
7
android/app/src/profile/AndroidManifest.xml
Normal file
@@ -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>
|
||||||
24
android/build.gradle.kts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
2
android/gradle.properties
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
android.useAndroidX=true
|
||||||
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -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-8.14-all.zip
|
||||||
29
android/m8chat_app_android.iml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="JAVA_MODULE" version="4">
|
||||||
|
<component name="FacetManager">
|
||||||
|
<facet type="android" name="Android">
|
||||||
|
<configuration>
|
||||||
|
<option name="ALLOW_USER_CONFIGURATION" value="false" />
|
||||||
|
<option name="GEN_FOLDER_RELATIVE_PATH_APT" value="/gen" />
|
||||||
|
<option name="GEN_FOLDER_RELATIVE_PATH_AIDL" value="/gen" />
|
||||||
|
<option name="MANIFEST_FILE_RELATIVE_PATH" value="/app/src/main/AndroidManifest.xml" />
|
||||||
|
<option name="RES_FOLDER_RELATIVE_PATH" value="/app/src/main/res" />
|
||||||
|
<option name="ASSETS_FOLDER_RELATIVE_PATH" value="/app/src/main/assets" />
|
||||||
|
<option name="LIBS_FOLDER_RELATIVE_PATH" value="/app/src/main/libs" />
|
||||||
|
<option name="PROGUARD_LOGS_FOLDER_RELATIVE_PATH" value="/app/src/main/proguard_logs" />
|
||||||
|
</configuration>
|
||||||
|
</facet>
|
||||||
|
</component>
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/app/src/main/java" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/app/src/main/kotlin" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/gen" isTestSource="false" generated="true" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="jdk" jdkName="Android API 24 Platform" jdkType="Android SDK" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
<orderEntry type="library" name="Flutter for Android" level="project" />
|
||||||
|
<orderEntry type="library" name="KotlinJavaRuntime" level="project" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
26
android/settings.gradle.kts
Normal file
@@ -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 "8.11.1" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
include(":app")
|
||||||
BIN
assets/images/logo.png
Normal file
|
After Width: | Height: | Size: 492 KiB |
BIN
assets/images/logo_dark.png
Normal file
|
After Width: | Height: | Size: 699 KiB |
BIN
assets/images/logo_light.png
Normal file
|
After Width: | Height: | Size: 289 KiB |
BIN
assets/images/logo_transparent.png
Normal file
|
After Width: | Height: | Size: 431 KiB |
BIN
assets/images/m8chat_icon.png
Normal file
|
After Width: | Height: | Size: 695 KiB |
BIN
assets/images/m8chat_splash.png
Normal file
|
After Width: | Height: | Size: 887 KiB |
34
ios/.gitignore
vendored
Normal file
@@ -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
|
||||||
24
ios/Flutter/AppFrameworkInfo.plist
Normal file
@@ -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>
|
||||||
1
ios/Flutter/Debug.xcconfig
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
1
ios/Flutter/Release.xcconfig
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
620
ios/Runner.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,620 @@
|
|||||||
|
// !$*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 */; };
|
||||||
|
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>"; };
|
||||||
|
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 = (
|
||||||
|
);
|
||||||
|
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 = (
|
||||||
|
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;
|
||||||
|
productName = Runner;
|
||||||
|
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
97C146E61CF9000F007C117D /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = YES;
|
||||||
|
LastUpgradeCheck = 1510;
|
||||||
|
ORGANIZATIONNAME = "";
|
||||||
|
TargetAttributes = {
|
||||||
|
331C8080294A63A400263BE5 = {
|
||||||
|
CreatedOnToolsVersion = 14.0;
|
||||||
|
TestTargetID = 97C146ED1CF9000F007C117D;
|
||||||
|
};
|
||||||
|
97C146ED1CF9000F007C117D = {
|
||||||
|
CreatedOnToolsVersion = 7.3.1;
|
||||||
|
LastSwiftMigration = 1100;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
|
||||||
|
compatibilityVersion = "Xcode 9.3";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = 97C146E51CF9000F007C117D;
|
||||||
|
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
97C146ED1CF9000F007C117D /* Runner */,
|
||||||
|
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
331C807F294A63A400263BE5 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
97C146EC1CF9000F007C117D /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
|
||||||
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
||||||
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||||
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||||
|
);
|
||||||
|
name = "Thin Binary";
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||||
|
};
|
||||||
|
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "Run Script";
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||||
|
};
|
||||||
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
331C807D294A63A400263BE5 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
97C146EA1CF9000F007C117D /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
|
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 = au.m8chat.m8chatApp;
|
||||||
|
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 = au.m8chat.m8chatApp.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 = au.m8chat.m8chatApp.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 = au.m8chat.m8chatApp.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 = 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;
|
||||||
|
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 = 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;
|
||||||
|
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 = au.m8chat.m8chatApp;
|
||||||
|
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 = au.m8chat.m8chatApp;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
331C8088294A63A400263BE5 /* Debug */,
|
||||||
|
331C8089294A63A400263BE5 /* Release */,
|
||||||
|
331C808A294A63A400263BE5 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
97C147031CF9000F007C117D /* Debug */,
|
||||||
|
97C147041CF9000F007C117D /* Release */,
|
||||||
|
249021D3217E4FDB00AE95B9 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
97C147061CF9000F007C117D /* Debug */,
|
||||||
|
97C147071CF9000F007C117D /* Release */,
|
||||||
|
249021D4217E4FDB00AE95B9 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
|
}
|
||||||
7
ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -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>
|
||||||
101
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1510"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<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>
|
||||||
7
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -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>
|
||||||
16
ios/Runner/AppDelegate.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
122
ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
{
|
||||||
|
"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" : "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" : "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: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
23
ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
5
ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
vendored
Normal file
@@ -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.
|
||||||
37
ios/Runner/Base.lproj/LaunchScreen.storyboard
Normal file
@@ -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>
|
||||||
26
ios/Runner/Base.lproj/Main.storyboard
Normal file
@@ -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>
|
||||||
70
ios/Runner/Info.plist
Normal file
@@ -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>M8chat App</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>m8chat_app</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>
|
||||||
1
ios/Runner/Runner-Bridging-Header.h
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#import "GeneratedPluginRegistrant.h"
|
||||||
6
ios/Runner/SceneDelegate.swift
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class SceneDelegate: FlutterSceneDelegate {
|
||||||
|
|
||||||
|
}
|
||||||
12
ios/RunnerTests/RunnerTests.swift
Normal file
@@ -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.
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
31
lib/app/app.dart
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Version: 1.0.0 | Created: 2026-04-01
|
||||||
|
// Root MaterialApp widget. Wires together theme + router.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'router.dart';
|
||||||
|
import 'theme.dart';
|
||||||
|
|
||||||
|
/// Root application widget.
|
||||||
|
///
|
||||||
|
/// [ProviderScope] is set up in main.dart. This widget only handles
|
||||||
|
/// theme and routing — no business logic here.
|
||||||
|
class M8ChatApp extends ConsumerWidget {
|
||||||
|
const M8ChatApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final router = ref.watch(routerProvider);
|
||||||
|
|
||||||
|
return MaterialApp.router(
|
||||||
|
title: 'M8Chat',
|
||||||
|
theme: buildLightTheme(),
|
||||||
|
darkTheme: buildDarkTheme(),
|
||||||
|
// Default to dark — M8Chat is a dark-first chat app.
|
||||||
|
themeMode: ThemeMode.system,
|
||||||
|
routerConfig: router,
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
lib/app/router.dart
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// Version: 1.0.2 | Created: 2026-04-01
|
||||||
|
// All route definitions in one place.
|
||||||
|
// GoRouter is created ONCE (keepAlive) and re-evaluates redirects via
|
||||||
|
// refreshListenable — avoids the GoRouter recreation bug from ref.watch.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
import '../core/auth/auth_notifier.dart';
|
||||||
|
import '../core/auth/auth_state.dart';
|
||||||
|
import '../features/auth/presentation/login_screen.dart';
|
||||||
|
import '../features/calls/presentation/call_screen.dart';
|
||||||
|
import '../features/chat/presentation/chat_screen.dart';
|
||||||
|
import '../features/profile/presentation/profile_screen.dart';
|
||||||
|
import '../features/rooms/presentation/rooms_screen.dart';
|
||||||
|
import '../features/spaces/presentation/spaces_screen.dart';
|
||||||
|
|
||||||
|
part 'router.g.dart';
|
||||||
|
|
||||||
|
/// Route path constants — use these instead of raw strings.
|
||||||
|
abstract final class AppRoutes {
|
||||||
|
static const String root = '/';
|
||||||
|
static const String login = '/login';
|
||||||
|
static const String rooms = '/rooms';
|
||||||
|
static const String chat = '/rooms/:roomId';
|
||||||
|
static const String call = '/calls/:roomId';
|
||||||
|
static const String profile = '/profile';
|
||||||
|
static const String spaces = '/spaces';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ChangeNotifier that GoRouter listens to for redirect re-evaluation.
|
||||||
|
/// Notified by ref.listen whenever authProvider changes.
|
||||||
|
class _AuthRefreshNotifier extends ChangeNotifier {
|
||||||
|
void notify() => notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
GoRouter router(Ref ref) {
|
||||||
|
// Create once — notifier triggers re-redirect without recreating the router.
|
||||||
|
final notifier = _AuthRefreshNotifier();
|
||||||
|
ref.listen<AuthState>(authProvider, (_, __) => notifier.notify());
|
||||||
|
ref.onDispose(notifier.dispose);
|
||||||
|
|
||||||
|
return GoRouter(
|
||||||
|
initialLocation: AppRoutes.root,
|
||||||
|
refreshListenable: notifier,
|
||||||
|
debugLogDiagnostics: false,
|
||||||
|
redirect: (BuildContext context, GoRouterState state) {
|
||||||
|
// Read (not watch) so redirect does not itself trigger provider changes.
|
||||||
|
final authState = ref.read(authProvider);
|
||||||
|
final isLoggedIn = authState is AuthAuthenticated;
|
||||||
|
final isOnLogin = state.matchedLocation == AppRoutes.login;
|
||||||
|
|
||||||
|
// While restoring session, stay on splash.
|
||||||
|
if (authState is AuthInitial || authState is AuthLoading) return null;
|
||||||
|
|
||||||
|
// Unauthenticated users must go to login.
|
||||||
|
if (!isLoggedIn && !isOnLogin) return AppRoutes.login;
|
||||||
|
|
||||||
|
// Authenticated users should not see the login screen.
|
||||||
|
if (isLoggedIn && isOnLogin) return AppRoutes.rooms;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.root,
|
||||||
|
builder: (context, state) => const _SplashRedirectPage(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.login,
|
||||||
|
builder: (context, state) => const LoginScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.rooms,
|
||||||
|
builder: (context, state) => const RoomsScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.chat,
|
||||||
|
builder: (context, state) {
|
||||||
|
// safe: path param is guaranteed by route definition
|
||||||
|
final roomId = state.pathParameters['roomId']!;
|
||||||
|
return ChatScreen(roomId: roomId);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.call,
|
||||||
|
builder: (context, state) {
|
||||||
|
// safe: path param is guaranteed by route definition
|
||||||
|
final roomId = state.pathParameters['roomId']!;
|
||||||
|
return CallScreen(roomId: roomId);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.profile,
|
||||||
|
builder: (context, state) => const ProfileScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.spaces,
|
||||||
|
builder: (context, state) => const SpacesScreen(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invisible page shown at `/` while GoRouter's redirect evaluates auth state.
|
||||||
|
class _SplashRedirectPage extends StatelessWidget {
|
||||||
|
const _SplashRedirectPage();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
|
}
|
||||||
|
}
|
||||||
137
lib/app/theme.dart
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// Version: 1.0.0 | Created: 2026-04-01
|
||||||
|
// M8Chat brand theme — dark and light variants.
|
||||||
|
// Primary brand colour: #5C35C9 (deep purple). Accent: #7B5CF6.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// M8Chat brand colours.
|
||||||
|
abstract final class M8Colours {
|
||||||
|
static const Color brandPurple = Color(0xFF5C35C9);
|
||||||
|
static const Color accentPurple = Color(0xFF7B5CF6);
|
||||||
|
static const Color darkBackground = Color(0xFF0F0F1A);
|
||||||
|
static const Color darkSurface = Color(0xFF1A1A2E);
|
||||||
|
static const Color darkSurfaceVariant = Color(0xFF22223A);
|
||||||
|
static const Color onDarkSurface = Color(0xFFE8E8F0);
|
||||||
|
static const Color subtleText = Color(0xFF9898B0);
|
||||||
|
static const Color errorRed = Color(0xFFCF6679);
|
||||||
|
static const Color unreadGreen = Color(0xFF4CAF50);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dark theme — default for M8Chat.
|
||||||
|
ThemeData buildDarkTheme() {
|
||||||
|
const seed = M8Colours.brandPurple;
|
||||||
|
|
||||||
|
final scheme =
|
||||||
|
ColorScheme.fromSeed(
|
||||||
|
seedColor: seed,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
).copyWith(
|
||||||
|
surface: M8Colours.darkBackground,
|
||||||
|
surfaceContainerHighest: M8Colours.darkSurface,
|
||||||
|
primary: M8Colours.accentPurple,
|
||||||
|
onSurface: M8Colours.onDarkSurface,
|
||||||
|
error: M8Colours.errorRed,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: scheme,
|
||||||
|
scaffoldBackgroundColor: M8Colours.darkBackground,
|
||||||
|
appBarTheme: AppBarTheme(
|
||||||
|
backgroundColor: M8Colours.darkSurface,
|
||||||
|
foregroundColor: M8Colours.onDarkSurface,
|
||||||
|
elevation: 0,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
|
backgroundColor: M8Colours.darkSurface,
|
||||||
|
indicatorColor: M8Colours.accentPurple.withAlpha(51),
|
||||||
|
labelTextStyle: WidgetStateProperty.all(
|
||||||
|
const TextStyle(fontSize: 12, color: M8Colours.onDarkSurface),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
filled: true,
|
||||||
|
fillColor: M8Colours.darkSurfaceVariant,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: M8Colours.accentPurple, width: 2),
|
||||||
|
),
|
||||||
|
labelStyle: const TextStyle(color: M8Colours.subtleText),
|
||||||
|
hintStyle: const TextStyle(color: M8Colours.subtleText),
|
||||||
|
),
|
||||||
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: M8Colours.accentPurple,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size.fromHeight(52),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cardTheme: CardThemeData(
|
||||||
|
color: M8Colours.darkSurface,
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
dividerTheme: const DividerThemeData(
|
||||||
|
color: M8Colours.darkSurfaceVariant,
|
||||||
|
thickness: 1,
|
||||||
|
),
|
||||||
|
snackBarTheme: SnackBarThemeData(
|
||||||
|
backgroundColor: M8Colours.darkSurfaceVariant,
|
||||||
|
contentTextStyle: const TextStyle(color: M8Colours.onDarkSurface),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Light theme — follows system preference if user selects it.
|
||||||
|
ThemeData buildLightTheme() {
|
||||||
|
const seed = M8Colours.brandPurple;
|
||||||
|
|
||||||
|
final scheme = ColorScheme.fromSeed(
|
||||||
|
seedColor: seed,
|
||||||
|
brightness: Brightness.light,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: scheme,
|
||||||
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(52),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
filled: true,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: scheme.primary, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
snackBarTheme: SnackBarThemeData(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
104
lib/core/auth/auth_notifier.dart
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// Version: 1.0.0 | Created: 2026-04-01
|
||||||
|
// Riverpod notifier that owns the auth state machine.
|
||||||
|
// All login/logout/session-restore transitions go through here.
|
||||||
|
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
import '../../features/auth/data/auth_repository.dart';
|
||||||
|
import '../../features/auth/domain/auth_failure.dart';
|
||||||
|
import 'auth_state.dart';
|
||||||
|
import 'secure_storage.dart';
|
||||||
|
|
||||||
|
part 'auth_notifier.g.dart';
|
||||||
|
|
||||||
|
/// The single source of truth for authentication state.
|
||||||
|
///
|
||||||
|
/// keepAlive: true — auth state must persist for the entire app lifetime.
|
||||||
|
/// GoRouter watches this provider to decide which route to show.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class AuthNotifier extends _$AuthNotifier {
|
||||||
|
@override
|
||||||
|
AuthState build() {
|
||||||
|
// Kick off session restore immediately; start in [AuthInitial].
|
||||||
|
_restoreSession();
|
||||||
|
return const AuthState.initial();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tries to restore a previous session from secure storage.
|
||||||
|
Future<void> _restoreSession() async {
|
||||||
|
state = const AuthState.loading();
|
||||||
|
|
||||||
|
final storage = ref.read(secureStorageProvider);
|
||||||
|
final credentials = await storage.loadCredentials();
|
||||||
|
|
||||||
|
if (credentials == null) {
|
||||||
|
state = const AuthState.unauthenticated();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final repo = ref.read(authRepositoryProvider);
|
||||||
|
await repo.restoreSession(
|
||||||
|
accessToken: credentials.accessToken,
|
||||||
|
userId: credentials.userId,
|
||||||
|
deviceId: credentials.deviceId,
|
||||||
|
);
|
||||||
|
state = AuthState.authenticated(
|
||||||
|
userId: credentials.userId,
|
||||||
|
accessToken: credentials.accessToken,
|
||||||
|
deviceId: credentials.deviceId,
|
||||||
|
);
|
||||||
|
} on AuthFailure {
|
||||||
|
// Stored credentials are invalid; force re-login.
|
||||||
|
await storage.clearCredentials();
|
||||||
|
state = const AuthState.unauthenticated();
|
||||||
|
} on Exception {
|
||||||
|
// Network offline at startup; still land on login rather than crashing.
|
||||||
|
await storage.clearCredentials();
|
||||||
|
state = const AuthState.unauthenticated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to log in with [username] and [password].
|
||||||
|
///
|
||||||
|
/// Transitions: loading → authenticated | unauthenticated(failure).
|
||||||
|
Future<void> login({
|
||||||
|
required String username,
|
||||||
|
required String password,
|
||||||
|
}) async {
|
||||||
|
state = const AuthState.loading();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final repo = ref.read(authRepositoryProvider);
|
||||||
|
final response = await repo.login(username: username, password: password);
|
||||||
|
|
||||||
|
final storage = ref.read(secureStorageProvider);
|
||||||
|
await storage.saveCredentials(
|
||||||
|
accessToken: response.accessToken,
|
||||||
|
userId: response.userId,
|
||||||
|
deviceId: response.deviceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
state = AuthState.authenticated(
|
||||||
|
userId: response.userId,
|
||||||
|
accessToken: response.accessToken,
|
||||||
|
deviceId: response.deviceId,
|
||||||
|
);
|
||||||
|
} on AuthFailure catch (failure) {
|
||||||
|
state = AuthState.unauthenticated(failure: failure.userMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logs out the current user, clears storage, and resets to unauthenticated.
|
||||||
|
Future<void> logout() async {
|
||||||
|
state = const AuthState.loading();
|
||||||
|
|
||||||
|
final repo = ref.read(authRepositoryProvider);
|
||||||
|
await repo.logout();
|
||||||
|
|
||||||
|
final storage = ref.read(secureStorageProvider);
|
||||||
|
await storage.clearCredentials();
|
||||||
|
|
||||||
|
state = const AuthState.unauthenticated();
|
||||||
|
}
|
||||||
|
}
|
||||||
29
lib/core/auth/auth_state.dart
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// Version: 1.0.0 | Created: 2026-04-01
|
||||||
|
// Sealed auth state hierarchy.
|
||||||
|
// All auth transitions are expressed as one of these states — no raw booleans.
|
||||||
|
|
||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'auth_state.freezed.dart';
|
||||||
|
|
||||||
|
/// Represents every possible authentication state in the app.
|
||||||
|
@freezed
|
||||||
|
sealed class AuthState with _$AuthState {
|
||||||
|
/// App has just launched; trying to restore session from storage.
|
||||||
|
const factory AuthState.initial() = AuthInitial;
|
||||||
|
|
||||||
|
/// A login or session-restore attempt is in progress.
|
||||||
|
const factory AuthState.loading() = AuthLoading;
|
||||||
|
|
||||||
|
/// User is authenticated. Holds the Matrix user ID and access token.
|
||||||
|
const factory AuthState.authenticated({
|
||||||
|
required String userId,
|
||||||
|
required String accessToken,
|
||||||
|
required String deviceId,
|
||||||
|
}) = AuthAuthenticated;
|
||||||
|
|
||||||
|
/// User is not authenticated.
|
||||||
|
/// [failure] is null on first launch; non-null after a failed attempt.
|
||||||
|
const factory AuthState.unauthenticated({String? failure}) =
|
||||||
|
AuthUnauthenticated;
|
||||||
|
}
|
||||||
83
lib/core/auth/secure_storage.dart
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// Version: 1.0.0 | Created: 2026-04-01
|
||||||
|
// Typed wrapper around flutter_secure_storage.
|
||||||
|
// All token read/write operations go through this class — never call
|
||||||
|
// flutter_secure_storage directly from feature code.
|
||||||
|
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
import '../config/app_config.dart';
|
||||||
|
|
||||||
|
part 'secure_storage.g.dart';
|
||||||
|
|
||||||
|
/// Provides a configured [SecureStorage] instance.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
SecureStorage secureStorage(Ref ref) => const SecureStorage();
|
||||||
|
|
||||||
|
/// Typed wrapper around [FlutterSecureStorage].
|
||||||
|
///
|
||||||
|
/// Uses AES encryption on Android and Keychain on iOS.
|
||||||
|
/// On Web, data is stored in localStorage with encryption — acceptable for
|
||||||
|
/// access tokens but NOT for E2EE private keys (Phase 2 concern).
|
||||||
|
class SecureStorage {
|
||||||
|
const SecureStorage();
|
||||||
|
|
||||||
|
static const FlutterSecureStorage _storage = FlutterSecureStorage(
|
||||||
|
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> saveCredentials({
|
||||||
|
required String accessToken,
|
||||||
|
required String userId,
|
||||||
|
required String deviceId,
|
||||||
|
}) async {
|
||||||
|
await _storage.write(
|
||||||
|
key: AppConfig.storageKeyAccessToken,
|
||||||
|
value: accessToken,
|
||||||
|
);
|
||||||
|
await _storage.write(key: AppConfig.storageKeyUserId, value: userId);
|
||||||
|
await _storage.write(key: AppConfig.storageKeyDeviceId, value: deviceId);
|
||||||
|
await _storage.write(
|
||||||
|
key: AppConfig.storageKeyHomeserver,
|
||||||
|
value: AppConfig.matrixBaseUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StoredCredentials?> loadCredentials() async {
|
||||||
|
final accessToken = await _storage.read(
|
||||||
|
key: AppConfig.storageKeyAccessToken,
|
||||||
|
);
|
||||||
|
final userId = await _storage.read(key: AppConfig.storageKeyUserId);
|
||||||
|
final deviceId = await _storage.read(key: AppConfig.storageKeyDeviceId);
|
||||||
|
|
||||||
|
if (accessToken == null || userId == null || deviceId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return StoredCredentials(
|
||||||
|
accessToken: accessToken,
|
||||||
|
userId: userId,
|
||||||
|
deviceId: deviceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearCredentials() async {
|
||||||
|
await _storage.delete(key: AppConfig.storageKeyAccessToken);
|
||||||
|
await _storage.delete(key: AppConfig.storageKeyUserId);
|
||||||
|
await _storage.delete(key: AppConfig.storageKeyDeviceId);
|
||||||
|
await _storage.delete(key: AppConfig.storageKeyHomeserver);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds the credentials retrieved from secure storage.
|
||||||
|
class StoredCredentials {
|
||||||
|
const StoredCredentials({
|
||||||
|
required this.accessToken,
|
||||||
|
required this.userId,
|
||||||
|
required this.deviceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String accessToken;
|
||||||
|
final String userId;
|
||||||
|
final String deviceId;
|
||||||
|
}
|
||||||
24
lib/core/config/app_config.dart
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// Version: 1.0.0 | Created: 2026-04-01
|
||||||
|
// App-wide constants. Change matrixBaseUrl for different environments.
|
||||||
|
|
||||||
|
/// Central configuration for the M8Chat application.
|
||||||
|
/// All server URLs and app-level constants live here — never scatter
|
||||||
|
/// magic strings through feature code.
|
||||||
|
abstract final class AppConfig {
|
||||||
|
static const String matrixBaseUrl = 'https://matrix.m8chat.au';
|
||||||
|
static const String matrixServerName = 'matrix.m8chat.au';
|
||||||
|
static const String livekitJwtUrl =
|
||||||
|
'https://matrix.m8chat.au/_matrix/livekit/jwt';
|
||||||
|
static const List<String> turnUrls = [
|
||||||
|
'turn:matrix.m8chat.au:3478',
|
||||||
|
'turns:matrix.m8chat.au:5349',
|
||||||
|
];
|
||||||
|
static const String appName = 'M8Chat';
|
||||||
|
static const String appVersion = '1.0.0';
|
||||||
|
|
||||||
|
// Secure storage key names
|
||||||
|
static const String storageKeyAccessToken = 'access_token';
|
||||||
|
static const String storageKeyUserId = 'user_id';
|
||||||
|
static const String storageKeyDeviceId = 'device_id';
|
||||||
|
static const String storageKeyHomeserver = 'homeserver';
|
||||||
|
}
|
||||||
22
lib/core/network/matrix_client.dart
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// Version: 1.0.1 | Created: 2026-04-01
|
||||||
|
// Matrix SDK client singleton provider.
|
||||||
|
// The Matrix client is kept alive for the full app lifetime once initialised.
|
||||||
|
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
import '../config/app_config.dart';
|
||||||
|
|
||||||
|
part 'matrix_client.g.dart';
|
||||||
|
|
||||||
|
/// Provides the single [Client] instance used throughout the app.
|
||||||
|
///
|
||||||
|
/// keepAlive: true — the Matrix client must never be disposed while the app
|
||||||
|
/// is running; it holds the sync loop and all room state.
|
||||||
|
///
|
||||||
|
/// Phase 1: no databaseBuilder — uses in-memory store only (no persistence
|
||||||
|
/// across restarts). Phase 2 will wire in a drift-backed DatabaseApi.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
Client matrixClient(Ref ref) {
|
||||||
|
return Client(AppConfig.appName);
|
||||||
|
}
|
||||||
95
lib/features/auth/data/auth_repository.dart
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
// Version: 1.0.3 | Created: 2026-04-01
|
||||||
|
// Auth repository: handles all Matrix login/logout API interactions.
|
||||||
|
// Uses the Matrix Dart SDK — no raw HTTP calls for auth.
|
||||||
|
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
import '../../../core/config/app_config.dart';
|
||||||
|
import '../../../core/network/matrix_client.dart';
|
||||||
|
import '../domain/auth_failure.dart';
|
||||||
|
|
||||||
|
part 'auth_repository.g.dart';
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
AuthRepository authRepository(Ref ref) {
|
||||||
|
return AuthRepository(client: ref.watch(matrixClientProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles authentication interactions with the Matrix homeserver.
|
||||||
|
class AuthRepository {
|
||||||
|
AuthRepository({required Client client}) : _client = client;
|
||||||
|
|
||||||
|
final Client _client;
|
||||||
|
|
||||||
|
/// Attempts password login. Returns [LoginResponse] on success,
|
||||||
|
/// throws [AuthFailure] on failure.
|
||||||
|
///
|
||||||
|
/// Matrix error codes mapped:
|
||||||
|
/// M_FORBIDDEN → [InvalidCredentials]
|
||||||
|
/// M_USER_DEACTIVATED → [AccountDisabled]
|
||||||
|
/// Network error → [NetworkError]
|
||||||
|
Future<LoginResponse> login({
|
||||||
|
required String username,
|
||||||
|
required String password,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
// Set homeserver directly — avoids network round-trips and version checks
|
||||||
|
// that checkHomeserver() makes. The setter is public in matrix 0.33.0.
|
||||||
|
_client.homeserver = Uri.parse(AppConfig.matrixBaseUrl);
|
||||||
|
return await _client.login(
|
||||||
|
LoginType.mLoginPassword,
|
||||||
|
identifier: AuthenticationUserIdentifier(user: username),
|
||||||
|
password: password,
|
||||||
|
initialDeviceDisplayName: 'M8Chat',
|
||||||
|
);
|
||||||
|
} on MatrixException catch (e) {
|
||||||
|
throw switch (e.errcode) {
|
||||||
|
'M_FORBIDDEN' => const AuthFailure.invalidCredentials(),
|
||||||
|
'M_USER_DEACTIVATED' => const AuthFailure.accountDisabled(),
|
||||||
|
_ => AuthFailure.serverError(
|
||||||
|
statusCode: e.response?.statusCode,
|
||||||
|
message: e.errorMessage,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} on Exception catch (e) {
|
||||||
|
// Covers SocketException, TimeoutException, etc.
|
||||||
|
final msg = e.toString().toLowerCase();
|
||||||
|
if (msg.contains('socket') ||
|
||||||
|
msg.contains('connection') ||
|
||||||
|
msg.contains('host lookup') ||
|
||||||
|
msg.contains('timeout')) {
|
||||||
|
throw AuthFailure.networkError(message: e.toString());
|
||||||
|
}
|
||||||
|
throw AuthFailure.unknown(message: e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logs out the current session on the homeserver.
|
||||||
|
/// Silently succeeds if the token is already invalid (network-first logout).
|
||||||
|
Future<void> logout() async {
|
||||||
|
try {
|
||||||
|
await _client.logout();
|
||||||
|
} on MatrixException {
|
||||||
|
// Token already invalid — treat as successful logout.
|
||||||
|
} on Exception {
|
||||||
|
// Network offline — proceed with local cleanup regardless.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores an existing Matrix session using a stored access token.
|
||||||
|
Future<void> restoreSession({
|
||||||
|
required String accessToken,
|
||||||
|
required String userId,
|
||||||
|
required String deviceId,
|
||||||
|
}) async {
|
||||||
|
await _client.init(
|
||||||
|
newToken: accessToken,
|
||||||
|
newUserID: userId,
|
||||||
|
newDeviceID: deviceId,
|
||||||
|
newDeviceName: 'M8Chat',
|
||||||
|
newHomeserver: Uri.parse(AppConfig.matrixBaseUrl),
|
||||||
|
newOlmAccount: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
lib/features/auth/domain/auth_failure.dart
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Version: 1.0.0 | Created: 2026-04-01
|
||||||
|
// Typed failure hierarchy for authentication errors.
|
||||||
|
// UI maps these to human-readable messages — never expose raw exception text.
|
||||||
|
|
||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'auth_failure.freezed.dart';
|
||||||
|
|
||||||
|
/// All ways authentication can fail.
|
||||||
|
@freezed
|
||||||
|
sealed class AuthFailure with _$AuthFailure {
|
||||||
|
/// Username or password was incorrect.
|
||||||
|
const factory AuthFailure.invalidCredentials() = InvalidCredentials;
|
||||||
|
|
||||||
|
/// Could not reach the Matrix homeserver.
|
||||||
|
const factory AuthFailure.networkError({String? message}) = NetworkError;
|
||||||
|
|
||||||
|
/// The server returned an unexpected response.
|
||||||
|
const factory AuthFailure.serverError({int? statusCode, String? message}) =
|
||||||
|
ServerError;
|
||||||
|
|
||||||
|
/// User's account has been disabled or deactivated.
|
||||||
|
const factory AuthFailure.accountDisabled() = AccountDisabled;
|
||||||
|
|
||||||
|
/// An unexpected error that doesn't fit the categories above.
|
||||||
|
const factory AuthFailure.unknown({required String message}) = UnknownFailure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps an [AuthFailure] to a user-facing string (Australian English).
|
||||||
|
extension AuthFailureMessage on AuthFailure {
|
||||||
|
String get userMessage => switch (this) {
|
||||||
|
InvalidCredentials() => 'Incorrect username or password. Please try again.',
|
||||||
|
NetworkError() =>
|
||||||
|
'Could not connect to the server. Check your internet connection and try again.',
|
||||||
|
ServerError(statusCode: final code) =>
|
||||||
|
'The server returned an error${code != null ? ' (code $code)' : ''}. Please try again shortly.',
|
||||||
|
AccountDisabled() =>
|
||||||
|
'Your account has been disabled. Please contact the administrator.',
|
||||||
|
UnknownFailure(message: final msg) => 'Something went wrong: $msg',
|
||||||
|
};
|
||||||
|
}
|
||||||
39
lib/features/auth/presentation/login_controller.dart
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// Version: 1.0.0 | Created: 2026-04-01
|
||||||
|
// Riverpod controller for the login form.
|
||||||
|
// Owns the loading state visible to the login screen widget.
|
||||||
|
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
import '../../../core/auth/auth_notifier.dart';
|
||||||
|
import '../../../core/auth/auth_state.dart';
|
||||||
|
|
||||||
|
part 'login_controller.g.dart';
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
class LoginController extends _$LoginController {
|
||||||
|
@override
|
||||||
|
bool build() => false; // isLoading
|
||||||
|
|
||||||
|
/// Delegates to [AuthNotifier.login]. Returns the failure message if any,
|
||||||
|
/// or null on success.
|
||||||
|
Future<String?> login({
|
||||||
|
required String username,
|
||||||
|
required String password,
|
||||||
|
}) async {
|
||||||
|
state = true;
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(authProvider.notifier)
|
||||||
|
.login(username: username, password: password);
|
||||||
|
|
||||||
|
// Check if auth failed.
|
||||||
|
final authState = ref.read(authProvider);
|
||||||
|
return authState.maybeWhen(
|
||||||
|
unauthenticated: (failure) => failure,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
state = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
241
lib/features/auth/presentation/login_screen.dart
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
// Version: 1.0.0 | Created: 2026-04-01
|
||||||
|
// Login screen. Username + password only. No registration link.
|
||||||
|
// Respects system theme preference.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'login_controller.dart';
|
||||||
|
|
||||||
|
class LoginScreen extends ConsumerStatefulWidget {
|
||||||
|
const LoginScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _usernameController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
bool _passwordVisible = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_usernameController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||||
|
|
||||||
|
final failure = await ref
|
||||||
|
.read(loginControllerProvider.notifier)
|
||||||
|
.login(
|
||||||
|
username: _usernameController.text.trim(),
|
||||||
|
password: _passwordController.text,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failure != null && mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(failure),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isLoading = ref.watch(loginControllerProvider);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 400),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_LogoSection(theme: theme),
|
||||||
|
const SizedBox(height: 48),
|
||||||
|
_UsernameField(controller: _usernameController),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _passwordController,
|
||||||
|
isVisible: _passwordVisible,
|
||||||
|
onToggleVisibility: () {
|
||||||
|
setState(() => _passwordVisible = !_passwordVisible);
|
||||||
|
},
|
||||||
|
onSubmit: _submit,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
_SignInButton(isLoading: isLoading, onPressed: _submit),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_ServerLabel(theme: theme),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LogoSection extends StatelessWidget {
|
||||||
|
const _LogoSection({required this.theme});
|
||||||
|
|
||||||
|
final ThemeData theme;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Image.asset(
|
||||||
|
'assets/images/logo.png',
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
errorBuilder: (_, __, ___) => CircleAvatar(
|
||||||
|
radius: 48,
|
||||||
|
backgroundColor: theme.colorScheme.primary,
|
||||||
|
child: const Icon(Icons.chat_bubble, size: 48, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
'M8Chat',
|
||||||
|
style: theme.textTheme.headlineMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Sign in to continue',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withAlpha(153),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UsernameField extends StatelessWidget {
|
||||||
|
const _UsernameField({required this.controller});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Username',
|
||||||
|
hintText: 'Enter your username',
|
||||||
|
prefixIcon: Icon(Icons.person_outline),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'Please enter your username.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PasswordField extends StatelessWidget {
|
||||||
|
const _PasswordField({
|
||||||
|
required this.controller,
|
||||||
|
required this.isVisible,
|
||||||
|
required this.onToggleVisibility,
|
||||||
|
required this.onSubmit,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
final bool isVisible;
|
||||||
|
final VoidCallback onToggleVisibility;
|
||||||
|
final VoidCallback onSubmit;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Password',
|
||||||
|
hintText: 'Enter your password',
|
||||||
|
prefixIcon: const Icon(Icons.lock_outline),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(isVisible ? Icons.visibility_off : Icons.visibility),
|
||||||
|
onPressed: onToggleVisibility,
|
||||||
|
tooltip: isVisible ? 'Hide password' : 'Show password',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
obscureText: !isVisible,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onFieldSubmitted: (_) => onSubmit(),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Please enter your password.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SignInButton extends StatelessWidget {
|
||||||
|
const _SignInButton({required this.isLoading, required this.onPressed});
|
||||||
|
|
||||||
|
final bool isLoading;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ElevatedButton(
|
||||||
|
onPressed: isLoading ? null : onPressed,
|
||||||
|
child: isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
height: 22,
|
||||||
|
width: 22,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text('Sign In'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServerLabel extends StatelessWidget {
|
||||||
|
const _ServerLabel({required this.theme});
|
||||||
|
|
||||||
|
final ThemeData theme;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Text(
|
||||||
|
'matrix.m8chat.au',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withAlpha(102),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
lib/features/calls/domain/call_state.dart
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Version: 1.0.0 | Created: 2026-04-01
|
||||||
|
// Call state sealed hierarchy. LiveKit integration in Phase 2.
|
||||||
|
|
||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'call_state.freezed.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
sealed class CallState with _$CallState {
|
||||||
|
const factory CallState.idle() = CallIdle;
|
||||||
|
const factory CallState.connecting({required String roomId}) = CallConnecting;
|
||||||
|
const factory CallState.active({
|
||||||
|
required String roomId,
|
||||||
|
required Duration duration,
|
||||||
|
@Default(false) bool isVideoEnabled,
|
||||||
|
@Default(true) bool isAudioEnabled,
|
||||||
|
}) = CallActive;
|
||||||
|
const factory CallState.ended({String? reason}) = CallEnded;
|
||||||
|
}
|
||||||
25
lib/features/calls/presentation/call_controller.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// Version: 1.0.0 | Created: 2026-04-01
|
||||||
|
// Call controller stub. LiveKit integration deferred to Phase 2.
|
||||||
|
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
import '../domain/call_state.dart';
|
||||||
|
|
||||||
|
part 'call_controller.g.dart';
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: false)
|
||||||
|
class CallController extends _$CallController {
|
||||||
|
@override
|
||||||
|
CallState build() => const CallState.idle();
|
||||||
|
|
||||||
|
/// Phase 2: join a LiveKit room via MatrixRTC JWT endpoint.
|
||||||
|
Future<void> joinCall(String roomId) async {
|
||||||
|
state = CallState.connecting(roomId: roomId);
|
||||||
|
// TODO(phase2): fetch JWT from AppConfig.livekitJwtUrl and connect LiveKit client.
|
||||||
|
state = const CallState.ended(reason: 'Calls not yet implemented.');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> endCall() async {
|
||||||
|
state = const CallState.ended();
|
||||||
|
}
|
||||||
|
}
|
||||||
73
lib/features/calls/presentation/call_screen.dart
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// Version: 1.0.0 | Created: 2026-04-01
|
||||||
|
// Call screen skeleton. Phase 2 will wire in LiveKit video/audio.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../domain/call_state.dart';
|
||||||
|
import 'call_controller.dart';
|
||||||
|
|
||||||
|
class CallScreen extends ConsumerWidget {
|
||||||
|
const CallScreen({super.key, required this.roomId});
|
||||||
|
|
||||||
|
final String roomId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final callState = ref.watch(callControllerProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.videocam_off_outlined,
|
||||||
|
size: 80,
|
||||||
|
color: Colors.white.withAlpha(153),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
switch (callState) {
|
||||||
|
CallConnecting() => 'Connecting...',
|
||||||
|
CallEnded(:final reason) => reason ?? 'Call ended.',
|
||||||
|
_ => 'Call (Phase 2)',
|
||||||
|
},
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 18),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Video calls will be available in the next release.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white.withAlpha(153),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: FloatingActionButton(
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(callControllerProvider.notifier).endCall();
|
||||||
|
context.pop();
|
||||||
|
},
|
||||||
|
child: const Icon(Icons.call_end, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
lib/features/chat/data/chat_repository.dart
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
// Version: 1.0.1 | Created: 2026-04-01
|
||||||
|
// Chat repository. Bridges Matrix SDK timeline to app domain models.
|
||||||
|
// Uses room.getTimeline() — timeline is async in matrix 0.33.0.
|
||||||
|
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
import '../../../core/network/matrix_client.dart';
|
||||||
|
import '../domain/message_model.dart';
|
||||||
|
|
||||||
|
part 'chat_repository.g.dart';
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
ChatRepository chatRepository(Ref ref) {
|
||||||
|
return ChatRepository(client: ref.watch(matrixClientProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChatRepository {
|
||||||
|
ChatRepository({required Client client}) : _client = client;
|
||||||
|
|
||||||
|
final Client _client;
|
||||||
|
|
||||||
|
Room? _getRoom(String roomId) => _client.getRoomById(roomId);
|
||||||
|
|
||||||
|
/// Returns a stream of message lists for [roomId].
|
||||||
|
///
|
||||||
|
/// Opens the room's timeline once and then emits on every update.
|
||||||
|
/// The timeline object is closed when the stream subscription is cancelled.
|
||||||
|
Stream<List<MessageModel>> watchTimeline(String roomId) async* {
|
||||||
|
final room = _getRoom(roomId);
|
||||||
|
if (room == null) return;
|
||||||
|
|
||||||
|
final timeline = await room.getTimeline(
|
||||||
|
onUpdate: () {
|
||||||
|
// Handled by the stream controller below.
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Emit the initial state.
|
||||||
|
yield _mapTimeline(timeline, room);
|
||||||
|
|
||||||
|
// Emit on subsequent sync events that affect this room.
|
||||||
|
await for (final update in _client.onSync.stream) {
|
||||||
|
final updatesThisRoom = update.rooms?.join?.containsKey(roomId) ?? false;
|
||||||
|
if (updatesThisRoom) {
|
||||||
|
yield _mapTimeline(timeline, room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up timeline subscriptions when the stream is cancelled.
|
||||||
|
timeline.cancelSubscriptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a plain text message to [roomId].
|
||||||
|
Future<void> sendTextMessage(String roomId, String text) async {
|
||||||
|
final room = _getRoom(roomId);
|
||||||
|
if (room == null) return;
|
||||||
|
await room.sendTextEvent(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a read receipt for the latest event in [roomId].
|
||||||
|
Future<void> markAsRead(String roomId) async {
|
||||||
|
final room = _getRoom(roomId);
|
||||||
|
if (room == null) return;
|
||||||
|
final lastEventId = room.lastEvent?.eventId ?? '';
|
||||||
|
if (lastEventId.isEmpty) return;
|
||||||
|
await room.setReadMarker(lastEventId, mRead: lastEventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests older messages be loaded (pagination).
|
||||||
|
Future<void> loadMoreMessages(String roomId) async {
|
||||||
|
final room = _getRoom(roomId);
|
||||||
|
if (room == null) return;
|
||||||
|
await room.requestHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MessageModel> _mapTimeline(Timeline timeline, Room room) {
|
||||||
|
final myUserId = _client.userID ?? '';
|
||||||
|
return timeline.events
|
||||||
|
.map((e) => _toModel(e, timeline, myUserId))
|
||||||
|
.toList()
|
||||||
|
.reversed
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageModel _toModel(Event event, Timeline timeline, String myUserId) {
|
||||||
|
final senderProfile = event.room.unsafeGetUserFromMemoryOrFallback(
|
||||||
|
event.senderId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return MessageModel(
|
||||||
|
eventId: event.eventId,
|
||||||
|
roomId: event.roomId ?? '',
|
||||||
|
senderId: event.senderId,
|
||||||
|
senderDisplayName:
|
||||||
|
senderProfile.displayName ?? event.senderId.split(':').first,
|
||||||
|
senderAvatarUrl: senderProfile.avatarUrl?.toString(),
|
||||||
|
timestamp: event.originServerTs,
|
||||||
|
type: _messageType(event),
|
||||||
|
body: event.redacted ? null : event.body,
|
||||||
|
mxcUrl: _extractMxcUrl(event),
|
||||||
|
inReplyToEventId: event.relationshipEventId,
|
||||||
|
isMine: event.senderId == myUserId,
|
||||||
|
isEdited: event.hasAggregatedEvents(timeline, RelationshipTypes.edit),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageType _messageType(Event event) {
|
||||||
|
if (event.redacted) return MessageType.redacted;
|
||||||
|
|
||||||
|
return switch (event.messageType) {
|
||||||
|
MessageTypes.Text => MessageType.text,
|
||||||
|
MessageTypes.Image => MessageType.image,
|
||||||
|
MessageTypes.File => MessageType.file,
|
||||||
|
MessageTypes.Audio => MessageType.audio,
|
||||||
|
MessageTypes.Video => MessageType.video,
|
||||||
|
MessageTypes.Sticker => MessageType.sticker,
|
||||||
|
_ => MessageType.unsupported,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _extractMxcUrl(Event event) {
|
||||||
|
final content = event.content;
|
||||||
|
if (content.containsKey('url')) {
|
||||||
|
return content['url'] as String?;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
lib/features/chat/domain/message_model.dart
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Version: 1.0.0 | Created: 2026-04-01
|
||||||
|
// Immutable message model for the chat timeline.
|
||||||
|
|
||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'message_model.freezed.dart';
|
||||||
|
|
||||||
|
/// Content type of a message event.
|
||||||
|
enum MessageType {
|
||||||
|
text,
|
||||||
|
image,
|
||||||
|
file,
|
||||||
|
audio,
|
||||||
|
video,
|
||||||
|
sticker,
|
||||||
|
redacted,
|
||||||
|
unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class MessageModel with _$MessageModel {
|
||||||
|
const factory MessageModel({
|
||||||
|
required String eventId,
|
||||||
|
required String roomId,
|
||||||
|
required String senderId,
|
||||||
|
required String senderDisplayName,
|
||||||
|
String? senderAvatarUrl,
|
||||||
|
required DateTime timestamp,
|
||||||
|
required MessageType type,
|
||||||
|
// Text content (for text messages).
|
||||||
|
String? body,
|
||||||
|
// URL for media messages.
|
||||||
|
String? mediaUrl,
|
||||||
|
// MXC URI for the media (used to download from homeserver).
|
||||||
|
String? mxcUrl,
|
||||||
|
// If this is a reply, the event ID of the original message.
|
||||||
|
String? inReplyToEventId,
|
||||||
|
// Reactions: emoji → list of sender IDs.
|
||||||
|
@Default({}) Map<String, List<String>> reactions,
|
||||||
|
// Read receipts: sender IDs of users who have read up to this event.
|
||||||
|
@Default([]) List<String> readByUserIds,
|
||||||
|
@Default(false) bool isEdited,
|
||||||
|
@Default(false) bool isMine,
|
||||||
|
}) = _MessageModel;
|
||||||
|
}
|
||||||
36
lib/features/chat/presentation/chat_controller.dart
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// Version: 1.0.0 | Created: 2026-04-01
|
||||||
|
// Riverpod providers for chat timeline.
|
||||||
|
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
import '../data/chat_repository.dart';
|
||||||
|
import '../domain/message_model.dart';
|
||||||
|
|
||||||
|
part 'chat_controller.g.dart';
|
||||||
|
|
||||||
|
/// Streams the message list for [roomId].
|
||||||
|
@riverpod
|
||||||
|
Stream<List<MessageModel>> chatTimeline(Ref ref, String roomId) {
|
||||||
|
final repo = ref.watch(chatRepositoryProvider);
|
||||||
|
return repo.watchTimeline(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a text message. Returns an error string on failure, null on success.
|
||||||
|
@riverpod
|
||||||
|
class SendMessage extends _$SendMessage {
|
||||||
|
@override
|
||||||
|
bool build() => false; // isSending
|
||||||
|
|
||||||
|
Future<String?> send(String roomId, String text) async {
|
||||||
|
if (text.trim().isEmpty) return null;
|
||||||
|
state = true;
|
||||||
|
try {
|
||||||
|
await ref.read(chatRepositoryProvider).sendTextMessage(roomId, text);
|
||||||
|
return null;
|
||||||
|
} on Exception catch (e) {
|
||||||
|
return e.toString();
|
||||||
|
} finally {
|
||||||
|
state = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
161
lib/features/chat/presentation/chat_screen.dart
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
// Version: 1.0.0 | Created: 2026-04-01
|
||||||
|
// Full chat screen — timeline + message input.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../core/network/matrix_client.dart';
|
||||||
|
import 'chat_controller.dart';
|
||||||
|
import 'message_bubble.dart';
|
||||||
|
import 'message_input.dart';
|
||||||
|
|
||||||
|
class ChatScreen extends ConsumerWidget {
|
||||||
|
const ChatScreen({super.key, required this.roomId});
|
||||||
|
|
||||||
|
final String roomId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// Decode the roomId — GoRouter encodes ! as %21 etc.
|
||||||
|
final decodedRoomId = Uri.decodeComponent(roomId);
|
||||||
|
|
||||||
|
final client = ref.watch(matrixClientProvider);
|
||||||
|
final room = client.getRoomById(decodedRoomId);
|
||||||
|
final roomName = room?.getLocalizedDisplayname() ?? 'Chat';
|
||||||
|
final roomAvatar = room?.avatar?.toString();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
titleSpacing: 0,
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
_RoomAvatarSmall(name: roomName, avatarUrl: roomAvatar),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Flexible(child: Text(roomName, overflow: TextOverflow.ellipsis)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.call),
|
||||||
|
tooltip: 'Start call (Phase 2)',
|
||||||
|
onPressed: null, // Phase 2
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
tooltip: 'Room options',
|
||||||
|
onPressed: () {
|
||||||
|
// Phase 2: room settings sheet
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _Timeline(roomId: decodedRoomId)),
|
||||||
|
_Input(roomId: decodedRoomId),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RoomAvatarSmall extends StatelessWidget {
|
||||||
|
const _RoomAvatarSmall({required this.name, this.avatarUrl});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final String? avatarUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final initials = name.isNotEmpty ? name[0].toUpperCase() : '?';
|
||||||
|
if (avatarUrl != null) {
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: 18,
|
||||||
|
backgroundImage: NetworkImage(avatarUrl!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: 18,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(51),
|
||||||
|
child: Text(
|
||||||
|
initials,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Timeline extends ConsumerWidget {
|
||||||
|
const _Timeline({required this.roomId});
|
||||||
|
|
||||||
|
final String roomId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final timelineAsync = ref.watch(chatTimelineProvider(roomId));
|
||||||
|
|
||||||
|
return timelineAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (error, _) => Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text('Could not load messages: $error'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
data: (messages) {
|
||||||
|
if (messages.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'No messages yet. Say hello!',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withAlpha(102),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
reverse: true,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
itemCount: messages.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return MessageBubble(message: messages[index]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Input extends ConsumerWidget {
|
||||||
|
const _Input({required this.roomId});
|
||||||
|
|
||||||
|
final String roomId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isSending = ref.watch(sendMessageProvider);
|
||||||
|
|
||||||
|
return MessageInput(
|
||||||
|
isSending: isSending,
|
||||||
|
onSend: (text) async {
|
||||||
|
final error = await ref
|
||||||
|
.read(sendMessageProvider.notifier)
|
||||||
|
.send(roomId, text);
|
||||||
|
|
||||||
|
if (error != null && context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to send message: $error'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
286
lib/features/chat/presentation/message_bubble.dart
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
// Version: 1.0.0 | Created: 2026-04-01
|
||||||
|
// Message bubble widget. Handles text, images, files, redacted, replies.
|
||||||
|
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import '../domain/message_model.dart';
|
||||||
|
|
||||||
|
class MessageBubble extends StatelessWidget {
|
||||||
|
const MessageBubble({super.key, required this.message});
|
||||||
|
|
||||||
|
final MessageModel message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isMine = message.isMine;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
top: 2,
|
||||||
|
bottom: 2,
|
||||||
|
left: isMine ? 48 : 8,
|
||||||
|
right: isMine ? 8 : 48,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
mainAxisAlignment: isMine
|
||||||
|
? MainAxisAlignment.end
|
||||||
|
: MainAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (!isMine) ...[
|
||||||
|
_SenderAvatar(message: message),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
Flexible(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: isMine
|
||||||
|
? CrossAxisAlignment.end
|
||||||
|
: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (!isMine)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 4, bottom: 2),
|
||||||
|
child: Text(
|
||||||
|
message.senderDisplayName,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_BubbleContent(message: message, isMine: isMine),
|
||||||
|
if (message.reactions.isNotEmpty)
|
||||||
|
_ReactionsRow(reactions: message.reactions),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SenderAvatar extends StatelessWidget {
|
||||||
|
const _SenderAvatar({required this.message});
|
||||||
|
|
||||||
|
final MessageModel message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final initials = message.senderDisplayName.isNotEmpty
|
||||||
|
? message.senderDisplayName[0].toUpperCase()
|
||||||
|
: '?';
|
||||||
|
|
||||||
|
if (message.senderAvatarUrl != null) {
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: 16,
|
||||||
|
backgroundImage: CachedNetworkImageProvider(message.senderAvatarUrl!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: 16,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(51),
|
||||||
|
child: Text(
|
||||||
|
initials,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BubbleContent extends StatelessWidget {
|
||||||
|
const _BubbleContent({required this.message, required this.isMine});
|
||||||
|
|
||||||
|
final MessageModel message;
|
||||||
|
final bool isMine;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
final bgColour = isMine
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.surfaceContainerHighest;
|
||||||
|
final textColour = isMine ? Colors.white : theme.colorScheme.onSurface;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 320),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bgColour,
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: const Radius.circular(16),
|
||||||
|
topRight: const Radius.circular(16),
|
||||||
|
bottomLeft: Radius.circular(isMine ? 16 : 4),
|
||||||
|
bottomRight: Radius.circular(isMine ? 4 : 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
_MessageContentBody(message: message, textColour: textColour),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
_Timestamp(
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
isEdited: message.isEdited,
|
||||||
|
textColour: textColour.withAlpha(153),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MessageContentBody extends StatelessWidget {
|
||||||
|
const _MessageContentBody({required this.message, required this.textColour});
|
||||||
|
|
||||||
|
final MessageModel message;
|
||||||
|
final Color textColour;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return switch (message.type) {
|
||||||
|
MessageType.text => Text(
|
||||||
|
message.body ?? '',
|
||||||
|
style: TextStyle(color: textColour),
|
||||||
|
),
|
||||||
|
MessageType.image => _ImageContent(message: message),
|
||||||
|
MessageType.file => _FileContent(
|
||||||
|
message: message,
|
||||||
|
textColour: textColour,
|
||||||
|
),
|
||||||
|
MessageType.redacted => Text(
|
||||||
|
'This message was deleted.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColour.withAlpha(153),
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ => Text(
|
||||||
|
message.body ?? 'Unsupported message type',
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColour.withAlpha(153),
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImageContent extends StatelessWidget {
|
||||||
|
const _ImageContent({required this.message});
|
||||||
|
|
||||||
|
final MessageModel message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (message.mediaUrl != null) {
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: message.mediaUrl!,
|
||||||
|
width: 240,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) => const SizedBox(
|
||||||
|
height: 160,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => const SizedBox(
|
||||||
|
height: 80,
|
||||||
|
child: Center(child: Icon(Icons.broken_image)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const Icon(Icons.image_not_supported);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FileContent extends StatelessWidget {
|
||||||
|
const _FileContent({required this.message, required this.textColour});
|
||||||
|
|
||||||
|
final MessageModel message;
|
||||||
|
final Color textColour;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.attach_file, color: textColour, size: 18),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
message.body ?? 'File',
|
||||||
|
style: TextStyle(color: textColour),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Timestamp extends StatelessWidget {
|
||||||
|
const _Timestamp({
|
||||||
|
required this.timestamp,
|
||||||
|
required this.isEdited,
|
||||||
|
required this.textColour,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DateTime timestamp;
|
||||||
|
final bool isEdited;
|
||||||
|
final Color textColour;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final formatted = DateFormat('HH:mm').format(timestamp.toLocal());
|
||||||
|
final label = isEdited ? '$formatted (edited)' : formatted;
|
||||||
|
|
||||||
|
return Text(label, style: TextStyle(fontSize: 10, color: textColour));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReactionsRow extends StatelessWidget {
|
||||||
|
const _ReactionsRow({required this.reactions});
|
||||||
|
|
||||||
|
final Map<String, List<String>> reactions;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
children: reactions.entries.map((entry) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.outline.withAlpha(51),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${entry.key} ${entry.value.length}',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
lib/features/chat/presentation/message_input.dart
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// Version: 1.0.0 | Created: 2026-04-01
|
||||||
|
// Message input bar. Text field + send button.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class MessageInput extends StatefulWidget {
|
||||||
|
const MessageInput({
|
||||||
|
super.key,
|
||||||
|
required this.onSend,
|
||||||
|
required this.isSending,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Future<void> Function(String text) onSend;
|
||||||
|
final bool isSending;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MessageInput> createState() => _MessageInputState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MessageInputState extends State<MessageInput> {
|
||||||
|
final _controller = TextEditingController();
|
||||||
|
bool _hasText = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller.addListener(() {
|
||||||
|
final hasText = _controller.text.trim().isNotEmpty;
|
||||||
|
if (hasText != _hasText) {
|
||||||
|
setState(() => _hasText = hasText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
final text = _controller.text.trim();
|
||||||
|
if (text.isEmpty) return;
|
||||||
|
_controller.clear();
|
||||||
|
await widget.onSend(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(color: theme.colorScheme.outline.withAlpha(51)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
tooltip: 'Attach file (Phase 2)',
|
||||||
|
onPressed: null, // Phase 2
|
||||||
|
color: theme.colorScheme.onSurface.withAlpha(153),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _controller,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Message',
|
||||||
|
contentPadding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.send,
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
maxLines: null,
|
||||||
|
keyboardType: TextInputType.multiline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
child: widget.isSending
|
||||||
|
? const SizedBox(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.send_rounded),
|
||||||
|
onPressed: _hasText ? _submit : null,
|
||||||
|
color: _hasText
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.onSurface.withAlpha(77),
|
||||||
|
tooltip: 'Send message',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
203
lib/features/profile/presentation/profile_screen.dart
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
// Version: 1.0.1 | Created: 2026-04-01
|
||||||
|
// Profile screen. Shows current user info and logout button.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../core/auth/auth_notifier.dart';
|
||||||
|
import '../../../core/auth/auth_state.dart';
|
||||||
|
import '../../../core/network/matrix_client.dart';
|
||||||
|
|
||||||
|
class ProfileScreen extends ConsumerWidget {
|
||||||
|
const ProfileScreen({super.key, this.embedded = false});
|
||||||
|
|
||||||
|
/// When true, this screen is shown inside the bottom-nav tab of RoomsScreen.
|
||||||
|
final bool embedded;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final authState = ref.watch(authProvider);
|
||||||
|
final client = ref.watch(matrixClientProvider);
|
||||||
|
|
||||||
|
final userId = authState.maybeWhen(
|
||||||
|
authenticated: (userId, _, __) => userId,
|
||||||
|
orElse: () => '',
|
||||||
|
);
|
||||||
|
|
||||||
|
final displayName = client.userID != null
|
||||||
|
? (client.userID!.split(':').first.replaceFirst('@', ''))
|
||||||
|
: 'Unknown';
|
||||||
|
|
||||||
|
final body = ListView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
children: [
|
||||||
|
_ProfileAvatar(displayName: displayName),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
'@$displayName',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
userId,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withAlpha(153),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Settings',
|
||||||
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withAlpha(153),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ExpansionTile(
|
||||||
|
leading: const Icon(Icons.notifications_outlined),
|
||||||
|
title: const Text('Notifications'),
|
||||||
|
children: [
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Push notifications'),
|
||||||
|
subtitle: const Text('Coming in Phase 2'),
|
||||||
|
value: false,
|
||||||
|
onChanged: null,
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Notification sounds'),
|
||||||
|
subtitle: const Text('Coming in Phase 2'),
|
||||||
|
value: false,
|
||||||
|
onChanged: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ExpansionTile(
|
||||||
|
leading: const Icon(Icons.security_outlined),
|
||||||
|
title: const Text('Security & Privacy'),
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.lock_outline),
|
||||||
|
title: const Text('End-to-end encryption'),
|
||||||
|
subtitle: const Text('Active — messages are encrypted'),
|
||||||
|
enabled: false,
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.verified_user_outlined),
|
||||||
|
title: const Text('Verify devices'),
|
||||||
|
subtitle: const Text('Cross-signing setup — coming in Phase 2'),
|
||||||
|
enabled: false,
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.password_outlined),
|
||||||
|
title: const Text('Change password'),
|
||||||
|
subtitle: const Text('Managed via m8chat.au account settings'),
|
||||||
|
enabled: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_LogoutButton(
|
||||||
|
onLogout: () async {
|
||||||
|
await ref.read(authProvider.notifier).logout();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
'M8Chat 1.0.0 · matrix.m8chat.au',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withAlpha(77),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (embedded) return body;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Profile')),
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProfileAvatar extends StatelessWidget {
|
||||||
|
const _ProfileAvatar({required this.displayName});
|
||||||
|
|
||||||
|
final String displayName;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final initials = displayName.isNotEmpty
|
||||||
|
? displayName[0].toUpperCase()
|
||||||
|
: '?';
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 48,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(51),
|
||||||
|
child: Text(
|
||||||
|
initials,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 36,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LogoutButton extends StatelessWidget {
|
||||||
|
const _LogoutButton({required this.onLogout});
|
||||||
|
|
||||||
|
final VoidCallback onLogout;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: const Text('Sign out'),
|
||||||
|
content: const Text('Are you sure you want to sign out of M8Chat?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
onLogout();
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
'Sign out',
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.logout),
|
||||||
|
label: const Text('Sign out'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
side: BorderSide(color: Theme.of(context).colorScheme.error),
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
lib/features/rooms/data/rooms_repository.dart
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// Version: 1.0.1 | Created: 2026-04-01
|
||||||
|
// Rooms repository. Reads room list from the Matrix SDK client.
|
||||||
|
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
import '../../../core/network/matrix_client.dart';
|
||||||
|
import '../domain/room_model.dart';
|
||||||
|
|
||||||
|
part 'rooms_repository.g.dart';
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
RoomsRepository roomsRepository(Ref ref) {
|
||||||
|
return RoomsRepository(client: ref.watch(matrixClientProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
class RoomsRepository {
|
||||||
|
RoomsRepository({required Client client}) : _client = client;
|
||||||
|
|
||||||
|
final Client _client;
|
||||||
|
|
||||||
|
/// Returns the current room list, sorted unread-first then by last activity.
|
||||||
|
List<RoomModel> getRooms() {
|
||||||
|
final rooms = _client.rooms;
|
||||||
|
final models = rooms.map(_toModel).toList();
|
||||||
|
|
||||||
|
models.sort((a, b) {
|
||||||
|
// Unread rooms first.
|
||||||
|
if (a.unreadCount != b.unreadCount) {
|
||||||
|
return b.unreadCount.compareTo(a.unreadCount);
|
||||||
|
}
|
||||||
|
// Then by most recent activity.
|
||||||
|
final aTime = a.lastActivityAt ?? DateTime(0);
|
||||||
|
final bTime = b.lastActivityAt ?? DateTime(0);
|
||||||
|
return bTime.compareTo(aTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
return models;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits current rooms immediately, then re-emits on every sync.
|
||||||
|
/// Immediate yield prevents indefinite spinner while waiting for first sync.
|
||||||
|
Stream<List<RoomModel>> watchRooms() async* {
|
||||||
|
yield getRooms();
|
||||||
|
yield* _client.onSync.stream.map((_) => getRooms());
|
||||||
|
}
|
||||||
|
|
||||||
|
RoomModel _toModel(Room room) {
|
||||||
|
return RoomModel(
|
||||||
|
id: room.id,
|
||||||
|
displayName: room.getLocalizedDisplayname(),
|
||||||
|
avatarUrl: room.avatar?.toString(),
|
||||||
|
lastMessagePreview: _lastMessagePreview(room),
|
||||||
|
lastActivityAt: room.timeCreated,
|
||||||
|
unreadCount: room.notificationCount,
|
||||||
|
isDirectMessage: room.isDirectChat,
|
||||||
|
isDirect: room.isDirectChat,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _lastMessagePreview(Room room) {
|
||||||
|
final lastEvent = room.lastEvent;
|
||||||
|
if (lastEvent == null) return null;
|
||||||
|
|
||||||
|
return switch (lastEvent.type) {
|
||||||
|
'm.room.message' => lastEvent.body,
|
||||||
|
'm.room.encrypted' => 'Encrypted message',
|
||||||
|
'm.sticker' => 'Sticker',
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
21
lib/features/rooms/domain/room_model.dart
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// Version: 1.0.0 | Created: 2026-04-01
|
||||||
|
// Immutable room model. Wraps the data the rooms screen needs to display.
|
||||||
|
// Derived from the Matrix SDK's Room object in the repository layer.
|
||||||
|
|
||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'room_model.freezed.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class RoomModel with _$RoomModel {
|
||||||
|
const factory RoomModel({
|
||||||
|
required String id,
|
||||||
|
required String displayName,
|
||||||
|
String? avatarUrl,
|
||||||
|
String? lastMessagePreview,
|
||||||
|
DateTime? lastActivityAt,
|
||||||
|
@Default(0) int unreadCount,
|
||||||
|
@Default(false) bool isDirectMessage,
|
||||||
|
@Default(false) bool isDirect,
|
||||||
|
}) = _RoomModel;
|
||||||
|
}
|
||||||
123
lib/features/rooms/presentation/room_tile.dart
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// Version: 1.0.0 | Created: 2026-04-01
|
||||||
|
// Individual room list tile. Kept under 100 lines.
|
||||||
|
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:timeago/timeago.dart' as timeago;
|
||||||
|
|
||||||
|
import '../domain/room_model.dart';
|
||||||
|
|
||||||
|
class RoomTile extends StatelessWidget {
|
||||||
|
const RoomTile({super.key, required this.room, required this.onTap});
|
||||||
|
|
||||||
|
final RoomModel room;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final hasUnread = room.unreadCount > 0;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: _RoomAvatar(room: room),
|
||||||
|
title: Text(
|
||||||
|
room.displayName,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: hasUnread ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: room.lastMessagePreview != null
|
||||||
|
? Text(
|
||||||
|
room.lastMessagePreview!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withAlpha(153),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
trailing: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
if (room.lastActivityAt != null)
|
||||||
|
Text(
|
||||||
|
timeago.format(room.lastActivityAt!, locale: 'en_short'),
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: hasUnread
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.onSurface.withAlpha(102),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (hasUnread) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_UnreadBadge(count: room.unreadCount),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RoomAvatar extends StatelessWidget {
|
||||||
|
const _RoomAvatar({required this.room});
|
||||||
|
|
||||||
|
final RoomModel room;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final initials = room.displayName.isNotEmpty
|
||||||
|
? room.displayName[0].toUpperCase()
|
||||||
|
: '?';
|
||||||
|
|
||||||
|
if (room.avatarUrl != null) {
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: 24,
|
||||||
|
backgroundImage: CachedNetworkImageProvider(room.avatarUrl!),
|
||||||
|
backgroundColor: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: 24,
|
||||||
|
backgroundColor: theme.colorScheme.primary.withAlpha(51),
|
||||||
|
child: Text(
|
||||||
|
initials,
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UnreadBadge extends StatelessWidget {
|
||||||
|
const _UnreadBadge({required this.count});
|
||||||
|
|
||||||
|
final int count;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final label = count > 99 ? '99+' : count.toString();
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||