Merge pull request #20 from KometTeam/feature/FullStack

Feature/full stack
This commit is contained in:
klockky
2026-05-16 21:32:06 +03:00
committed by GitHub
88 changed files with 9583 additions and 1527 deletions
+116
View File
@@ -0,0 +1,116 @@
name: Build Android (FCM)
on:
workflow_dispatch:
push:
branches:
- main
- master
paths:
- 'lib/**'
- 'android/**'
- 'pubspec.yaml'
- '.github/workflows/build-android-fcm.yml'
pull_request:
paths:
- 'lib/**'
- 'android/**'
- 'pubspec.yaml'
- '.github/workflows/build-android-fcm.yml'
jobs:
build-android-fcm:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v3
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.41.5'
channel: 'stable'
- name: Get dependencies
run: flutter pub get
- name: Configure Gradle
run: |
mkdir -p ~/.gradle
echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties
echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties
- name: Setup release signing
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
run: |
if [ -z "$KEYSTORE_BASE64" ]; then
echo "KEYSTORE_BASE64 secret not set — release will be debug-signed."
exit 0
fi
echo "$KEYSTORE_BASE64" | base64 -d > android/app/komet-release.jks
{
echo "storePassword=$KEYSTORE_PASSWORD"
echo "keyPassword=$KEY_PASSWORD"
echo "keyAlias=$KEY_ALIAS"
echo "storeFile=komet-release.jks"
} > android/key.properties
echo "Release signing configured."
- name: Build Universal APK
run: flutter build apk --release --flavor oneme
- name: Build Split APKs
run: flutter build apk --release --split-per-abi --flavor oneme
- name: Upload Universal APK
uses: actions/upload-artifact@v4
with:
name: komet-android-fcm-universal
path: build/app/outputs/flutter-apk/app-oneme-release.apk
if-no-files-found: error
retention-days: 30
- name: Upload arm64-v8a APK
uses: actions/upload-artifact@v4
with:
name: komet-android-fcm-arm64-v8a
path: build/app/outputs/flutter-apk/app-arm64-v8a-oneme-release.apk
if-no-files-found: error
retention-days: 30
- name: Upload armeabi-v7a APK
uses: actions/upload-artifact@v4
with:
name: komet-android-fcm-armeabi-v7a
path: build/app/outputs/flutter-apk/app-armeabi-v7a-oneme-release.apk
if-no-files-found: error
retention-days: 30
- name: Upload x86_64 APK
uses: actions/upload-artifact@v4
with:
name: komet-android-fcm-x86_64
path: build/app/outputs/flutter-apk/app-x86_64-oneme-release.apk
if-no-files-found: error
retention-days: 30
- name: Build App Bundle
run: flutter build appbundle --release --flavor oneme
- name: Upload App Bundle artifact
uses: actions/upload-artifact@v4
with:
name: komet-android-fcm-aab
path: build/app/outputs/bundle/onemeRelease/app-oneme-release.aab
if-no-files-found: error
retention-days: 30
+34 -9
View File
@@ -45,47 +45,72 @@ jobs:
mkdir -p ~/.gradle
echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties
echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties
- name: Setup release signing
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
run: |
if [ -z "$KEYSTORE_BASE64" ]; then
echo "KEYSTORE_BASE64 secret not set — release will be debug-signed."
exit 0
fi
echo "$KEYSTORE_BASE64" | base64 -d > android/app/komet-release.jks
{
echo "storePassword=$KEYSTORE_PASSWORD"
echo "keyPassword=$KEY_PASSWORD"
echo "keyAlias=$KEY_ALIAS"
echo "storeFile=komet-release.jks"
} > android/key.properties
echo "Release signing configured."
- name: Build Universal APK
run: flutter build apk --release
run: flutter build apk --release --flavor komet
- name: Build Split APKs
run: flutter build apk --release --split-per-abi
run: flutter build apk --release --split-per-abi --flavor komet
- name: Upload Universal APK
uses: actions/upload-artifact@v4
with:
name: komet-android-universal
path: build/app/outputs/flutter-apk/app-release.apk
path: build/app/outputs/flutter-apk/app-komet-release.apk
if-no-files-found: error
retention-days: 30
- name: Upload arm64-v8a APK
uses: actions/upload-artifact@v4
with:
name: komet-android-arm64-v8a
path: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
path: build/app/outputs/flutter-apk/app-arm64-v8a-komet-release.apk
if-no-files-found: error
retention-days: 30
- name: Upload armeabi-v7a APK
uses: actions/upload-artifact@v4
with:
name: komet-android-armeabi-v7a
path: build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk
path: build/app/outputs/flutter-apk/app-armeabi-v7a-komet-release.apk
if-no-files-found: error
retention-days: 30
- name: Upload x86_64 APK
uses: actions/upload-artifact@v4
with:
name: komet-android-x86_64
path: build/app/outputs/flutter-apk/app-x86_64-release.apk
path: build/app/outputs/flutter-apk/app-x86_64-komet-release.apk
if-no-files-found: error
retention-days: 30
- name: Build App Bundle
run: flutter build appbundle --release
run: flutter build appbundle --release --flavor komet
- name: Upload App Bundle artifact
uses: actions/upload-artifact@v4
with:
name: komet-android-aab
path: build/app/outputs/bundle/release/app-release.aab
path: build/app/outputs/bundle/kometRelease/app-komet-release.aab
if-no-files-found: error
retention-days: 30
+35 -2
View File
@@ -13,10 +13,17 @@ jobs:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: 'stable'
flutter-version: '3.41.5'
channel: 'stable'
- name: Install dependencies
run: flutter pub get
@@ -24,5 +31,31 @@ jobs:
- name: Flutter analyze
run: flutter analyze
- name: Configure Gradle
run: |
mkdir -p ~/.gradle
echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties
echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties
- name: Setup release signing
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
run: |
if [ -z "$KEYSTORE_BASE64" ]; then
echo "KEYSTORE_BASE64 secret not set — release will be debug-signed."
exit 0
fi
echo "$KEYSTORE_BASE64" | base64 -d > android/app/komet-release.jks
{
echo "storePassword=$KEYSTORE_PASSWORD"
echo "keyPassword=$KEY_PASSWORD"
echo "keyAlias=$KEY_ALIAS"
echo "storeFile=komet-release.jks"
} > android/key.properties
echo "Release signing configured."
- name: Build Android APK
run: flutter build apk --release
run: flutter build apk --release --flavor komet
+89 -9
View File
@@ -6,17 +6,23 @@ on:
- 'main'
jobs:
analyze-and-build-all:
android:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: 'stable'
flutter-version: '3.41.5'
channel: 'stable'
- name: Install dependencies
run: flutter pub get
@@ -24,8 +30,54 @@ jobs:
- name: Flutter analyze
run: flutter analyze
- name: Configure Gradle
run: |
mkdir -p ~/.gradle
echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties
echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties
- name: Setup release signing
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
run: |
if [ -z "$KEYSTORE_BASE64" ]; then
echo "KEYSTORE_BASE64 secret not set — release will be debug-signed."
exit 0
fi
echo "$KEYSTORE_BASE64" | base64 -d > android/app/komet-release.jks
{
echo "storePassword=$KEYSTORE_PASSWORD"
echo "keyPassword=$KEY_PASSWORD"
echo "keyAlias=$KEY_ALIAS"
echo "storeFile=komet-release.jks"
} > android/key.properties
echo "Release signing configured."
- name: Build Android APK
run: flutter build apk --release
run: flutter build apk --release --flavor komet
web-linux:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.41.5'
channel: 'stable'
- name: Install Linux desktop dependencies
run: |
sudo apt-get update
sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev
- name: Install dependencies
run: flutter pub get
- name: Build Web
run: flutter build web --release
@@ -33,13 +85,41 @@ jobs:
- name: Build Linux
run: flutter build linux --release
windows:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.41.5'
channel: 'stable'
- name: Install dependencies
run: flutter pub get
- name: Build Windows
run: flutter build windows --release
- name: Build iOS (only macOS runners)
if: runner.os == 'macOS'
run: flutter build ios --release
apple:
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Build macOS (only macOS runners)
if: runner.os == 'macOS'
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.41.5'
channel: 'stable'
- name: Install dependencies
run: flutter pub get
- name: Build iOS
run: flutter build ios --release --no-codesign
- name: Build macOS
run: flutter build macos --release
+229
View File
@@ -0,0 +1,229 @@
name: Pre-release (dev/0.5.0)
on:
workflow_dispatch:
push:
branches:
- 'dev/0.5.0'
permissions:
contents: write
env:
FLUTTER_VERSION: '3.41.5'
jobs:
android:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
flavor: [komet, oneme]
steps:
- uses: actions/checkout@v3
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
- name: Get dependencies
run: flutter pub get
- name: Configure Gradle
run: |
mkdir -p ~/.gradle
echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties
echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties
- name: Setup release signing
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
run: |
if [ -z "$KEYSTORE_BASE64" ]; then
echo "KEYSTORE_BASE64 secret not set — release will be debug-signed."
exit 0
fi
echo "$KEYSTORE_BASE64" | base64 -d > android/app/komet-release.jks
{
echo "storePassword=$KEYSTORE_PASSWORD"
echo "keyPassword=$KEY_PASSWORD"
echo "keyAlias=$KEY_ALIAS"
echo "storeFile=komet-release.jks"
} > android/key.properties
echo "Release signing configured."
- name: Build APKs and App Bundle (${{ matrix.flavor }})
run: |
flutter build apk --release --flavor ${{ matrix.flavor }}
flutter build apk --release --split-per-abi --flavor ${{ matrix.flavor }}
flutter build appbundle --release --flavor ${{ matrix.flavor }}
- name: Collect Android artifacts (${{ matrix.flavor }})
run: |
F=${{ matrix.flavor }}
mkdir -p dist
cp build/app/outputs/flutter-apk/app-$F-release.apk dist/Komet-android-$F-universal.apk
cp build/app/outputs/flutter-apk/app-arm64-v8a-$F-release.apk dist/Komet-android-$F-arm64-v8a.apk
cp build/app/outputs/flutter-apk/app-armeabi-v7a-$F-release.apk dist/Komet-android-$F-armeabi-v7a.apk
cp build/app/outputs/flutter-apk/app-x86_64-$F-release.apk dist/Komet-android-$F-x86_64.apk
cp build/app/outputs/bundle/${F}Release/app-$F-release.aab dist/Komet-android-$F.aab
- name: Upload Android artifacts (${{ matrix.flavor }})
uses: actions/upload-artifact@v4
with:
name: android-${{ matrix.flavor }}
path: dist/*
if-no-files-found: error
retention-days: 7
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
- name: Get dependencies
run: flutter pub get
- name: Build Windows
run: flutter build windows --release
- name: Package Windows
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path dist | Out-Null
Compress-Archive -Path build/windows/x64/runner/Release/* -DestinationPath dist/Komet-windows-x64.zip
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
with:
name: windows
path: dist/*
if-no-files-found: error
retention-days: 7
linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
- name: Install Linux desktop dependencies
run: |
sudo apt-get update
sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev
- name: Get dependencies
run: flutter pub get
- name: Build Linux
run: flutter build linux --release
- name: Package Linux
run: |
mkdir -p dist
tar -C build/linux/x64/release/bundle -czf dist/Komet-linux-x64.tar.gz .
- name: Upload Linux artifact
uses: actions/upload-artifact@v4
with:
name: linux
path: dist/*
if-no-files-found: error
retention-days: 7
macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
- name: Get dependencies
run: flutter pub get
- name: Build macOS
run: flutter build macos --release
- name: Package macOS
run: |
mkdir -p dist
APP=$(find build/macos/Build/Products/Release -maxdepth 1 -name "*.app" | head -1)
ditto -c -k --keepParent "$APP" dist/Komet-macos.zip
- name: Upload macOS artifact
uses: actions/upload-artifact@v4
with:
name: macos
path: dist/*
if-no-files-found: error
retention-days: 7
ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
- name: Get dependencies
run: flutter pub get
- name: Build iOS (no codesign)
run: flutter build ios --release --no-codesign
- name: Package unsigned IPA
run: |
mkdir -p dist build/ios/Payload
cp -R build/ios/iphoneos/Runner.app build/ios/Payload/
(cd build/ios && zip -qr ../../dist/Komet-ios-unsigned.ipa Payload)
- name: Upload iOS artifact
uses: actions/upload-artifact@v4
with:
name: ios
path: dist/*
if-no-files-found: error
retention-days: 7
release:
needs: [android, windows, linux, macos, ios]
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Compute version
id: ver
run: |
BRANCH_VERSION="${GITHUB_REF_NAME#dev/}"
echo "tag=v${BRANCH_VERSION}-dev.${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT"
echo "name=Komet ${BRANCH_VERSION}-dev (build ${GITHUB_RUN_NUMBER})" >> "$GITHUB_OUTPUT"
- name: List artifacts
run: ls -lhR dist
- name: Create pre-release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.ver.outputs.tag }}
name: ${{ steps.ver.outputs.name }}
target_commitish: ${{ github.sha }}
prerelease: true
generate_release_notes: true
fail_on_unmatched_files: true
body: |
Automated pre-release built from `${{ github.ref_name }}` @ `${{ github.sha }}`.
⚠️ The iOS build is **unsigned** — install via AltStore / Sideloadly / a jailbroken device only.
files: dist/*
+9 -1
View File
@@ -125,4 +125,12 @@ app.*.symbols
!**/ios/**/default.pbxuser
!**/ios/**/default.perspectivev3
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
!/dev/ci/**/Gemfile.lock
!/dev/ci/**/Gemfile.lock
# AI / Agents
agents.md
.claude/
# Environment variables
.env
.env.*
+84
View File
@@ -0,0 +1,84 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
Komet is a cross-platform Flutter messaging client (Android, iOS, macOS, Windows, Linux, Web) that communicates via a custom packet-based protocol with MessagePack serialization and Zstd compression.
## Commands
```bash
flutter pub get # install dependencies
flutter analyze # lint / static analysis
flutter run # run on connected device (default: komet flavor)
flutter run --flavor oneme -t lib/main.dart # run oneme flavor (FCM)
# Android builds
flutter build apk --release --flavor komet
flutter build apk --release --split-per-abi --flavor komet
flutter build appbundle --release --flavor komet
# Other platforms
flutter build ios --release --no-codesign
flutter build macos --release
flutter build web --release
flutter build linux --release
flutter build windows --release
```
Android builds require **Java 17**. Gradle memory is configured to `-Xmx4096m`.
## Build Flavors
| Flavor | App ID | Notes |
|--------|--------|-------|
| `komet` | `ru.komet.app` | Default, no FCM |
| `oneme` | `ru.oneme.app` | FCM push notifications via Firebase |
Flavor-specific Android resources live in `android/app/src/komet/` and `android/app/src/oneme/`.
## Architecture
The codebase follows a strict layered architecture:
```
core/transport/ — raw socket I/O: connection, sender, receiver, dispatcher, proxy
core/protocol/ — Packet struct, opcode map, MessagePack + Zstd serialization
core/storage/ — SQLite (sqflite), secure token storage, spoofing service
core/push/ — FCM integration (oneme flavor only)
core/config/ — app config, proxy config, device presets, countries list
backend/api.dart — session lifecycle: connect, handshake, ping, auto-reconnect
backend/modules/ — feature modules: account, messages, chats, contacts, calls, folders
state/ — ChangeNotifier state classes consumed by the UI
models/ — plain data classes (User, Chat, Message, Call, Attachment, Session)
frontend/screens/ — full-page widgets grouped by feature (auth/, chats/, contacts/, calls/, profile/)
frontend/widgets/ — reusable components (message_bubble, chat_tile, avatar, etc.)
```
Data flow: UI → backend module → `api.dart` → transport layer → server.
Incoming packets: transport → dispatcher → backend module → state → UI rebuild.
## Key Conventions (from AGENTS.md)
- **No comments in code.** Write self-documenting code instead.
- **Use `showCustomNotification(context, 'text')`** for all user-facing notifications — never use SnackBars.
- When a fix can be done quickly with a hack or properly with a rewrite, **choose the proper rewrite**.
- Quality over quantity.
## Localization
Two locales supported: English (`lib/l10n/app_en.arb`) and Russian (`lib/l10n/app_ru.arb`).
Generated code is in `lib/l10n/` (produced by `flutter gen-l10n` via `l10n.yaml`).
## CI/CD
Four GitHub Actions workflows in `.github/workflows/`:
- `flutter-dev.yml` — PR lint + Android build for dev branch
- `flutter-main.yml` — PR lint + all-platform builds for main branch
- `build-android.yml` — production APKs + AAB (`komet` flavor), triggered on push to main
- `build-android-fcm.yml` — production APKs + AAB (`oneme` flavor with FCM), triggered on push to main
+2
View File
@@ -10,5 +10,7 @@ GeneratedPluginRegistrant.java
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
app/key.properties
app/komet-release.jks
**/*.keystore
**/*.jks
+47 -7
View File
@@ -1,8 +1,19 @@
import java.util.Properties
import java.io.FileInputStream
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")
id("com.google.gms.google-services")
}
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
val hasReleaseSigning = keystorePropertiesFile.exists()
if (hasReleaseSigning) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
@@ -11,6 +22,7 @@ android {
ndkVersion = flutter.ndkVersion
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
@@ -20,21 +32,45 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "ru.komet.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
minSdk = maxOf(flutter.minSdkVersion, 23)
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
flavorDimensions += "distribution"
productFlavors {
create("komet") {
dimension = "distribution"
isDefault = true
applicationId = "ru.komet.app"
}
create("oneme") {
dimension = "distribution"
applicationId = "ru.oneme.app"
}
}
signingConfigs {
if (hasReleaseSigning) {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
}
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")
signingConfig = if (hasReleaseSigning) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
}
}
}
@@ -42,3 +78,7 @@ android {
flutter {
source = "../.."
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
+22
View File
@@ -0,0 +1,22 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<provider
android:name="com.google.firebase.provider.FirebaseInitProvider"
android:authorities="${applicationId}.firebaseinitprovider"
tools:node="remove" />
<provider
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingInitProvider"
android:authorities="${applicationId}.flutterfirebasemessaginginitprovider"
tools:node="remove" />
<service
android:name="com.google.firebase.messaging.FirebaseMessagingService"
tools:node="remove" />
<service
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
tools:node="remove" />
<receiver
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingReceiver"
tools:node="remove" />
</application>
</manifest>
@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "659634599081",
"project_id": "max-messenger-app",
"storage_bucket": "max-messenger-app.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:659634599081:android:00000000000000000000ab",
"android_client_info": {
"package_name": "ru.komet.app"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyABuDYeeDXIOrKTXLkUj30Ii143ofPe63Q"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
+4
View File
@@ -2,6 +2,7 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:label="komet"
android:name="${applicationName}"
@@ -33,6 +34,9 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="komet_messages" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
@@ -1,5 +1,238 @@
package ru.komet.app
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Handler
import android.os.Looper
import android.util.Log
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.net.NetworkInterface
import java.util.Collections
import java.util.concurrent.atomic.AtomicBoolean
class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
private val channelName = "ru.komet.app/vpn_bypass"
private companion object {
const val LOG_TAG = "VpnBypass"
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
channelName,
).setMethodCallHandler { call, result ->
when (call.method) {
"detectInterfaces" -> result.success(detectInterfaces())
"bindToNonVpnNetwork" -> bindToNonVpnNetwork(result)
"unbindNetwork" -> result.success(unbindNetwork())
else -> result.notImplemented()
}
}
}
private fun connectivityManager(): ConnectivityManager =
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
// Перечисляет активные интерфейсы: есть ли tun-туннель и какие прямые.
private fun detectInterfaces(): Map<String, Any> {
val tunNames = ArrayList<String>()
val directNames = ArrayList<String>()
val interfaces = try {
Collections.list(NetworkInterface.getNetworkInterfaces())
} catch (_: Exception) {
emptyList<NetworkInterface>()
}
for (nif in interfaces) {
val name = nif.name ?: continue
val up = try {
nif.isUp && !nif.isLoopback
} catch (_: Exception) {
false
}
if (!up) continue
when {
name.startsWith("tun") || name.startsWith("ppp") ||
name.startsWith("ipsec") || name.startsWith("wg") ->
tunNames.add(name)
name.startsWith("wlan") || name.startsWith("rmnet") ||
name.startsWith("eth") ->
directNames.add(name)
}
}
return mapOf(
"hasTun" to tunNames.isNotEmpty(),
"hasVpn" to hasVpnTransport(),
"tunNames" to tunNames,
"directInterfaces" to directNames,
)
}
// VPN активен, даже если tun-интерфейс не виден приложению (Android 10+).
private fun hasVpnTransport(): Boolean {
val cm = connectivityManager()
for (network in cm.allNetworks) {
val caps = cm.getNetworkCapabilities(network) ?: continue
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return true
if (!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) {
return true
}
}
return false
}
private data class Candidate(
val network: Network,
val iface: String?,
val transport: String,
val score: Int,
)
// Привязка к не-VPN сети. Надёжный путь — попросить систему выдать
// подходящую сеть через NetworkCallback (валидный, привязываемый
// Network), и лишь при тайм-ауте — перебор getAllNetworks().
private fun bindToNonVpnNetwork(result: MethodChannel.Result) {
val cm = connectivityManager()
val main = Handler(Looper.getMainLooper())
val done = AtomicBoolean(false)
var callback: ConnectivityManager.NetworkCallback? = null
fun finish(map: Map<String, Any?>) {
if (!done.compareAndSet(false, true)) return
callback?.let {
try {
cm.unregisterNetworkCallback(it)
} catch (_: Exception) {
}
}
main.post { result.success(map) }
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
.build()
val cb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
val caps = cm.getNetworkCapabilities(network)
val iface = cm.getLinkProperties(network)?.interfaceName
val transport = when {
caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
== true -> "wifi"
caps?.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
== true -> "ethernet"
else -> "cellular"
}
val ok = cm.bindProcessToNetwork(network)
Log.i(LOG_TAG, "onAvailable iface=$iface t=$transport bound=$ok")
finish(
mapOf(
"bound" to ok,
"interface" to iface,
"transport" to transport,
"reason" to if (ok) {
null
} else {
"bind_rejected_maybe_lockdown"
},
),
)
}
}
callback = cb
try {
cm.registerNetworkCallback(request, cb)
} catch (e: Exception) {
Log.w(LOG_TAG, "registerNetworkCallback failed: ${e.message}")
finish(bindByEnumeration())
return
}
main.postDelayed({
if (done.get()) return@postDelayed
Log.w(LOG_TAG, "callback timeout — fallback to enumeration")
finish(bindByEnumeration())
}, 4000L)
}
// Запасной путь: перебор getAllNetworks(). Жёсткий фильтр — только
// исключение VPN-транспорта; INTERNET/NOT_VPN/VALIDATED лишь повышают
// приоритет (физическая сеть под VPN часто теряет эти capability).
private fun bindByEnumeration(): Map<String, Any?> {
val cm = connectivityManager()
val networks = cm.allNetworks
val candidates = ArrayList<Candidate>()
for (network in networks) {
val caps = cm.getNetworkCapabilities(network)
val iface = cm.getLinkProperties(network)?.interfaceName
Log.i(LOG_TAG, "net=$network iface=$iface caps=$caps")
if (caps == null) continue
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) continue
val baseScore = when {
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 3
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 2
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 1
else -> continue
}
val transport = when (baseScore) {
3 -> "wifi"
2 -> "ethernet"
else -> "cellular"
}
val internet =
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
val notVpn =
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
val validated =
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
val score = baseScore * 8 +
(if (internet) 4 else 0) +
(if (notVpn) 2 else 0) +
(if (validated) 1 else 0)
candidates.add(Candidate(network, iface, transport, score))
}
candidates.sortByDescending { it.score }
Log.i(LOG_TAG, "candidates=${candidates.map { "${it.iface}:${it.score}" }}")
if (candidates.isEmpty()) {
return mapOf(
"bound" to false,
"reason" to "no_non_vpn_network(scanned=${networks.size})",
)
}
for (c in candidates) {
if (cm.bindProcessToNetwork(c.network)) {
Log.i(LOG_TAG, "bound to ${c.iface} (${c.transport})")
return mapOf(
"bound" to true,
"interface" to c.iface,
"transport" to c.transport,
"reason" to null,
)
}
Log.w(LOG_TAG, "bindProcessToNetwork failed for ${c.iface}")
}
return mapOf("bound" to false, "reason" to "bind_blocked_maybe_lockdown")
}
private fun unbindNetwork(): Map<String, Any?> {
connectivityManager().bindProcessToNetwork(null)
return mapOf("bound" to false, "reason" to "unbound")
}
}
@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "659634599081",
"project_id": "max-messenger-app",
"storage_bucket": "max-messenger-app.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:659634599081:android:9605285443b661167225b8",
"android_client_info": {
"package_name": "ru.oneme.app"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyABuDYeeDXIOrKTXLkUj30Ii143ofPe63Q"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
+1
View File
@@ -21,6 +21,7 @@ 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
id("com.google.gms.google-services") version "4.4.3" apply false
}
include(":app")
+52 -7
View File
@@ -10,6 +10,7 @@ import '../core/transport/connection.dart';
import '../core/transport/dispatcher.dart';
import '../core/transport/receiver.dart';
import '../core/transport/sender.dart';
import '../core/transport/vpn_bypass.dart';
import '../core/utils/logger.dart';
import 'package:device_info_plus/device_info_plus.dart';
@@ -56,6 +57,9 @@ class Api {
int _reconnectAttempts = 0;
bool _autoReconnect = false;
/// Залипает на время сессии: VPN-путь не сработал — идём мимо туннеля.
bool _bypassActive = false;
// Публичное API
/// Подключается к серверу, шлёт хэндшейк, запускает пинг.
@@ -73,14 +77,28 @@ class Api {
}
});
final bypassArmed = await VpnBypassService.instance.shouldArm();
if (!bypassArmed) _bypassActive = false;
final useBypass = _bypassActive && bypassArmed;
// Попытку через VPN ограничиваем по времени, чтобы быстро понять,
// что туннель не пропускает, и переключиться на обход.
final attemptTimeout =
bypassArmed && !useBypass ? const Duration(seconds: 8) : null;
try {
final endpoint = await ServerConfig.loadEndpoint();
await _connection.connect(endpoint.host, endpoint.port);
await _connection.connect(
endpoint.host,
endpoint.port,
bypassVpn: useBypass,
timeout: attemptTimeout,
);
} catch (e) {
logger.e('Не удалось подключиться: $e');
if (_sessionState != SessionState.disconnected) {
_cleanup();
_setSessionState(SessionState.disconnected);
_armBypassIfPossible(bypassArmed, useBypass, 'подключение не удалось');
_scheduleReconnect();
}
return;
@@ -107,12 +125,29 @@ class Api {
}
} catch (e) {
logger.e('Ошибка хэндшейка: $e');
// Сокет подключился (через VPN), но сервер не ответил на хэндшейк —
// путь нерабочий: рвём соединение и пробуем мимо VPN.
if (_sessionState != SessionState.disconnected) {
_cleanup();
await _connection.disconnect();
_setSessionState(SessionState.disconnected);
_armBypassIfPossible(bypassArmed, useBypass, 'хэндшейк не прошёл');
_scheduleReconnect();
}
}
}
void _armBypassIfPossible(bool armed, bool alreadyBypassing, String why) {
if (armed && !alreadyBypassing && !_bypassActive) {
_bypassActive = true;
logger.w('VPN bypass: $why — следующая попытка мимо VPN');
}
}
/// Отключается без автореконнекта.
Future<void> disconnect() async {
_autoReconnect = false;
_bypassActive = false;
_reconnectTimer?.cancel();
_cleanup();
await _connection.disconnect();
@@ -122,11 +157,7 @@ class Api {
Future<Packet> sendHandshake() async {
final deviceInfo = DeviceInfoPlugin();
String deviceType = (Platform.isLinux || Platform.isWindows)
? 'DESKTOP'
: (Platform.isAndroid)
? 'ANDROID'
: 'IOS';
String deviceType = 'ANDROID';
String osVersion = '';
String deviceName = 'Unknown';
String architecture = 'arm64';
@@ -237,6 +268,11 @@ class Api {
_dispatcher.registerHandler(opcode, handler);
}
/// Снимает обработчик пушей с указанного опкода.
void unregisterPushHandler(int opcode) {
_dispatcher.unregisterHandler(opcode);
}
/// Стрим всех входящих пушей от сервера.
Stream<Packet> get pushStream => _dispatcher.pushStream;
@@ -248,6 +284,7 @@ class Api {
_connection.dispose();
_stateController.close();
_sessionExpiredController.close();
_handshakeSuccessController.close();
}
// Внутрянка
@@ -260,7 +297,15 @@ class Api {
}
Future<void> _onDataReceived(Uint8List data) async {
await for (final packet in _receiver.feed(data)) {
final rawPackets = _receiver.feed(data);
for (final raw in rawPackets) {
final Packet packet;
try {
packet = await unpackPacket(raw);
} catch (e) {
logger.e('PacketReceiver: ошибка распаковки: $e');
continue;
}
if (packet.isError &&
packet.payload is Map &&
(packet.payload['message'] == 'FAIL_LOGIN_TOKEN' ||
+1 -1
View File
@@ -25,7 +25,7 @@ class ChatFolder {
factory ChatFolder.fromJson(Map<String, dynamic> json) {
return ChatFolder(
id: json['id'].toString(),
id: json['id']?.toString() ?? '',
title: json['title']?.toString() ?? '',
emoji: json['emoji']?.toString(),
include: (json['include'] as List<dynamic>?)
+283 -42
View File
@@ -27,6 +27,7 @@ class PrivacyConfig {
final String chatsInvite;
final bool pushNewContacts;
final bool unsafeFiles;
final String phoneNumberPrivacy;
final String inactiveTtl;
final bool showReadMark;
final bool altKeyboard;
@@ -48,6 +49,7 @@ class PrivacyConfig {
required this.chatsInvite,
required this.pushNewContacts,
required this.unsafeFiles,
required this.phoneNumberPrivacy,
required this.inactiveTtl,
required this.showReadMark,
required this.altKeyboard,
@@ -71,6 +73,7 @@ class PrivacyConfig {
chatsInvite: map['CHATS_INVITE']?.toString() ?? 'CONTACTS',
pushNewContacts: map['PUSH_NEW_CONTACTS'] ?? false,
unsafeFiles: map['UNSAFE_FILES'] ?? true,
phoneNumberPrivacy: map['PHONE_NUMBER_PRIVACY']?.toString() ?? 'ALL',
inactiveTtl: map['INACTIVE_TTL']?.toString() ?? '6M',
showReadMark: map['SHOW_READ_MARK'] ?? true,
altKeyboard: map['ALT_KEYBOARD'] ?? false,
@@ -94,6 +97,7 @@ class PrivacyConfig {
'CHATS_INVITE': chatsInvite,
'PUSH_NEW_CONTACTS': pushNewContacts,
'UNSAFE_FILES': unsafeFiles,
'PHONE_NUMBER_PRIVACY': phoneNumberPrivacy,
'INACTIVE_TTL': inactiveTtl,
'SHOW_READ_MARK': showReadMark,
'ALT_KEYBOARD': altKeyboard,
@@ -125,6 +129,7 @@ class PrivacyConfig {
chatsInvite: 'CONTACTS',
pushNewContacts: false,
unsafeFiles: true,
phoneNumberPrivacy: 'ALL',
inactiveTtl: '6M',
showReadMark: true,
altKeyboard: false,
@@ -205,6 +210,12 @@ enum AuthRequestType {
enum LoginStatus { idle, loading, success, error }
class WrongDeviceTokenException implements Exception {
const WrongDeviceTokenException();
@override
String toString() => 'WrongDeviceTokenException';
}
class RequestCodeResult {
final String token;
@@ -289,7 +300,7 @@ class LoginSyncParams {
draftsSync: int.tryParse(values[SyncKey.draftsSync] ?? '') ?? 0,
bannersSync: int.tryParse(values[SyncKey.bannersSync] ?? '') ?? 0,
presenceSync: int.tryParse(values[SyncKey.presenceSync] ?? '') ?? -1,
lastLogin: int.parse(lastLogin),
lastLogin: int.tryParse(lastLogin) ?? 0,
configHash: values[SyncKey.configHash],
chatCacheFingerprint: values[SyncKey.chatCacheFingerprint],
);
@@ -370,9 +381,7 @@ class AccountModule {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId != null) {
final saved = await AppDatabase.getPrivacyConfig(accountId);
if (saved != null) {
return PrivacyConfig.fromJson(saved);
}
if (saved != null) return PrivacyConfig.fromJson(saved);
}
return PrivacyConfig.empty();
}
@@ -426,6 +435,109 @@ class AccountModule {
return config;
}
Future<void> registerPushToken(String pushToken) async {
_ensureOnline();
final packet = await _api.sendRequest(Opcode.config, <dynamic, dynamic>{
'pushToken': pushToken,
});
if (packet.isError) {
final msg = messageFromErrorPayload(packet.payload).toUpperCase();
if (msg.contains('WRONG_DEVICE_TOKEN') ||
msg.contains('WRONG.DEVICE.TOKEN')) {
throw const WrongDeviceTokenException();
}
throw PacketError(messageFromErrorPayload(packet.payload));
}
}
Future<void> unregisterPushToken(String pushToken) async {
if (_api.state != SessionState.online) return;
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) return;
final authToken = await TokenStorage.readToken(accountId);
if (authToken == null) return;
await _api.sendRequest(Opcode.logout, <dynamic, dynamic>{
'token': authToken,
'pushToken': pushToken,
});
}
Future<ProfileData> updateProfileName(String firstName, String? lastName) async {
_ensureOnline();
final payload = <dynamic, dynamic>{
'firstName': firstName,
};
if (lastName != null) payload['lastName'] = lastName;
final packet = await _api.sendRequest(Opcode.profile, payload);
if (packet.isError) {
throw Exception(packet.payload?.toString() ?? 'Server error');
}
final data = packet.payload as Map?;
if (data == null) throw Exception('Empty response');
final profile = data['profile'] as Map?;
if (profile == null) throw Exception('No profile in response');
final contact = profile['contact'] as Map?;
if (contact == null) throw Exception('No contact in response');
final newProfile = ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
await AppDatabase.saveProfile(newProfile, isActive: true);
return newProfile;
}
Future<ProfileData> updateProfileAvatar(String photoToken, String avatarType) async {
_ensureOnline();
final packet = await _api.sendRequest(Opcode.profile, {
'photoToken': photoToken,
'avatarType': avatarType,
});
if (packet.isError) {
throw Exception(packet.payload?.toString() ?? 'Server error');
}
final data = packet.payload as Map?;
if (data == null) throw Exception('Empty response');
final profile = data['profile'] as Map?;
if (profile == null) throw Exception('No profile in response');
final contact = profile['contact'] as Map?;
if (contact == null) throw Exception('No contact in response');
final newProfile = ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
await AppDatabase.saveProfile(newProfile, isActive: true);
return newProfile;
}
Future<String> getAvatarUploadUrl() async {
_ensureOnline();
final packet = await _api.sendRequest(Opcode.photoUpload, {
'count': 1,
'profile': true,
});
if (packet.isError) {
throw Exception(packet.payload?.toString() ?? 'Server error');
}
final data = packet.payload as Map?;
if (data == null) throw Exception('Empty response');
final url = data['url'] as String?;
if (url == null) throw Exception('No url in response');
return url;
}
Future<ProfileData> removeProfilePhoto(int photoId) async {
_ensureOnline();
final packet = await _api.sendRequest(Opcode.removeContactPhoto, {
'photoId': photoId,
});
if (packet.isError) {
throw Exception(packet.payload?.toString() ?? 'Server error');
}
final data = packet.payload as Map?;
if (data == null) throw Exception('Empty response');
final profile = data['profile'] as Map?;
if (profile == null) throw Exception('No profile in response');
final contact = profile['contact'] as Map?;
if (contact == null) throw Exception('No contact in response');
final newProfile = ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
await AppDatabase.saveProfile(newProfile, isActive: true);
return newProfile;
}
// 2FA Creation (when not set)
Future<String> create2faTrack() async {
_ensureOnline();
@@ -646,19 +758,25 @@ class AccountModule {
Future<ProfileData> _processProfileUpdate(Packet packet) async {
_api.registerPushHandler(Opcode.notifProfile, (p) {});
await for (final push in _api.pushStream.where(
(p) => p.opcode == Opcode.notifProfile,
)) {
final payload = push.payload;
if (payload is Map) {
final profile = payload['profile'];
if (profile is Map) {
final contact = profile['contact'];
if (contact is Map) {
return ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
try {
await for (final push in _api.pushStream
.where((p) => p.opcode == Opcode.notifProfile)
.timeout(const Duration(seconds: 15))) {
final payload = push.payload;
if (payload is Map) {
final profile = payload['profile'];
if (profile is Map) {
final contact = profile['contact'];
if (contact is Map) {
return ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
}
}
}
}
} on TimeoutException {
throw Exception('Таймаут ожидания обновления профиля');
} finally {
_api.unregisterPushHandler(Opcode.notifProfile);
}
throw Exception('Не удалось получить обновлённый профиль');
}
@@ -715,15 +833,18 @@ class AccountModule {
}) async {
_ensureOnline();
final resolvedAccountId =
int? resolvedAccountId =
accountId ?? await TokenStorage.getActiveAccountId();
if (resolvedAccountId == null) {
throw StateError('login: нет активного аккаунта');
}
final authToken = token ?? await TokenStorage.readToken(resolvedAccountId);
String? authToken = token;
if (authToken == null) {
throw StateError('login: нет токена для аккаунта $resolvedAccountId');
if (resolvedAccountId == null) {
throw StateError('login: нет активного аккаунта');
}
authToken = await TokenStorage.readToken(resolvedAccountId);
if (authToken == null) {
throw StateError('login: нет токена для аккаунта $resolvedAccountId');
}
}
final requestPayload = _buildLoginPayload(authToken, syncParams);
@@ -739,10 +860,24 @@ class AccountModule {
throw Exception('login: неожиданный тип payload: ${data.runtimeType}');
}
final result = await _processLoginResponse(
data.cast<dynamic, dynamic>(),
resolvedAccountId,
);
final dataMap = data.cast<dynamic, dynamic>();
if (resolvedAccountId == null) {
final profileMap = dataMap['profile'];
if (profileMap is Map) {
final contact = profileMap['contact'];
if (contact is Map) {
resolvedAccountId = contact['id'] as int?;
}
}
if (resolvedAccountId == null) {
throw Exception('login: не удалось определить accountId из ответа');
}
await TokenStorage.saveToken(authToken, resolvedAccountId);
await TokenStorage.setActiveAccount(resolvedAccountId);
}
final result = await _processLoginResponse(dataMap, resolvedAccountId);
_loginStatusController.add(LoginStatus.success);
return result;
} catch (e) {
@@ -850,23 +985,7 @@ class AccountModule {
throw Exception('checkPassword: отсутствует токен в ответе');
}
final profileData = data['profile'];
int? accountId;
if (profileData is Map) {
final contact = profileData['contact'];
if (contact is Map) {
accountId = contact['id'] as int?;
}
}
if (accountId != null) {
await TokenStorage.saveToken(loginToken, accountId);
await TokenStorage.setActiveAccount(accountId);
logger.i('2FA пройдена, токен аккаунта $accountId сохранён');
} else {
logger.w('2FA пройдена, но accountId не получен из ответа');
}
logger.i('2FA пройдена, получен login-токен');
return TwoFactorResult(loginToken: loginToken);
}
@@ -920,7 +1039,7 @@ class AccountModule {
throw Exception('login: отсутствует profile.contact в ответе');
}
final profile = ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
await AppDatabase.saveProfile(profile);
await AppDatabase.saveProfile(profile, isActive: true);
await AppDatabase.setActiveAccount(profile.id);
await _saveSyncState(data, serverTime, profile.id);
@@ -933,6 +1052,13 @@ class AccountModule {
profile.id,
config.cast<dynamic, dynamic>(),
);
final userConfig = config['user'];
if (userConfig is Map) {
await AppDatabase.savePrivacyConfig(
profile.id,
jsonEncode(userConfig),
);
}
}
try {
await FoldersModule.syncFromServer(_api, profile.id);
@@ -940,6 +1066,12 @@ class AccountModule {
logger.w('Папки чатов: $e');
}
try {
await _saveLoginInfo(data, profile.id);
} catch (e) {
logger.w('Info: $e');
}
return LoginResult(
profile: profile,
updatedToken: updatedToken,
@@ -974,6 +1106,115 @@ class AccountModule {
}
}
Future<void> _saveLoginInfo(
Map<dynamic, dynamic> data,
int accountId,
) async {
final contact = data['profile']?['contact'] as Map?;
final videoChatHistory = data['videoChatHistory'];
final chats = data['chats'] as List?;
final config = data['config'] as Map?;
final serverConfig = config?['server'] as Map?;
final userConfig = config?['user'] as Map?;
final yMap = serverConfig?['y-map'] as Map?;
final whiteListLinks = serverConfig?['white-list-links'] as List?;
final fileUploadUnsupported = serverConfig?['file-upload-unsupported-types'] as List?;
final time = data['time'] as int?;
final info = {
'registrationTime': contact?['registrationTime'],
'country': contact?['country'],
'videoChatHistory': videoChatHistory,
'updateTime': contact?['updateTime'],
'id': contact?['id'],
'chatMarker': chats != null && chats.isNotEmpty
? _extractChatMarker(chats.cast<Map>())
: null,
'time': time,
'server': serverConfig != null
? _extractServerInfo(serverConfig, yMap, whiteListLinks, fileUploadUnsupported)
: null,
'user': userConfig != null ? _extractUserConfig(userConfig) : null,
};
await AppDatabase.saveLoginInfo(accountId, jsonEncode(info));
}
Map<String, dynamic> _extractChatMarker(List<Map> chats) {
int? latestTime;
for (final chat in chats) {
final lastEventTime = chat['lastEventTime'] as int?;
if (lastEventTime != null && (latestTime == null || lastEventTime > latestTime)) {
latestTime = lastEventTime;
}
}
return {'chatMarker': latestTime};
}
Map<String, dynamic> _extractServerInfo(
Map serverConfig,
Map? yMap,
List? whiteListLinks,
List? fileUploadUnsupported,
) {
return {
'account-removal-enabled': serverConfig['account-removal-enabled'],
'image-size': serverConfig['image-size'],
'gce': serverConfig['gce'],
'gcce': serverConfig['gcce'],
'max-msg-length': serverConfig['max-msg-length'],
'quotes-enabled': serverConfig['quotes-enabled'],
'calls-endpoint': serverConfig['calls-endpoint'],
'send-location-enabled': serverConfig['send-location-enabled'],
'lgce': serverConfig['lgce'],
'wud': serverConfig['wud'],
'video-msg-enabled': serverConfig['video-msg-enabled'],
'grse': serverConfig['grse'],
'edit-timeout': serverConfig['edit-timeout'],
'image-quality': serverConfig['image-quality'],
'unsafe-files-alert': serverConfig['unsafe-files-alert'],
'account-nickname-enabled': serverConfig['account-nickname-enabled'],
'mentions_entity_names_limit': serverConfig['mentions_entity_names_limit'],
'reactions-enabled': serverConfig['reactions-enabled'],
'y-map': yMap != null ? {
'tile': yMap['tile'],
'geocoder': yMap['geocoder'],
'static': yMap['static'],
} : null,
'white-list-links': whiteListLinks,
'file-upload-unsupported-types': fileUploadUnsupported,
};
}
Map<String, dynamic> _extractUserConfig(Map userConfig) {
return {
'CHATS_PUSH_NOTIFICATION': userConfig['CHATS_PUSH_NOTIFICATION'],
'PUSH_DETAILS': userConfig['PUSH_DETAILS'],
'PUSH_SOUND': userConfig['PUSH_SOUND'],
'PHONE_NUMBER_PRIVACY': userConfig['PHONE_NUMBER_PRIVACY'],
'INACTIVE_TTL': userConfig['INACTIVE_TTL'],
'SHOW_READ_MARK': userConfig['SHOW_READ_MARK'],
'AUDIO_TRANSCRIPTION_ENABLED': userConfig['AUDIO_TRANSCRIPTION_ENABLED'],
'SEARCH_BY_PHONE': userConfig['SEARCH_BY_PHONE'],
'INCOMING_CALL': userConfig['INCOMING_CALL'],
'DOUBLE_TAP_REACTION_DISABLED': userConfig['DOUBLE_TAP_REACTION_DISABLED'],
'SAFE_MODE_NO_PIN': userConfig['SAFE_MODE_NO_PIN'],
'CHATS_PUSH_SOUND': userConfig['CHATS_PUSH_SOUND'],
'DOUBLE_TAP_REACTION_VALUE': userConfig['DOUBLE_TAP_REACTION_VALUE'],
'FAMILY_PROTECTION': userConfig['FAMILY_PROTECTION'],
'HIDDEN': userConfig['HIDDEN'],
'CHATS_INVITE': userConfig['CHATS_INVITE'],
'PUSH_NEW_CONTACTS': userConfig['PUSH_NEW_CONTACTS'],
'UNSAFE_FILES': userConfig['UNSAFE_FILES'],
'DONT_DISTURB_UNTIL': userConfig['DONT_DISTURB_UNTIL'],
'ALT_KEYBOARD': userConfig['ALT_KEYBOARD'],
'CONTENT_LEVEL_ACCESS': userConfig['CONTENT_LEVEL_ACCESS'],
'STICKERS_SUGGEST': userConfig['STICKERS_SUGGEST'],
'SAFE_MODE': userConfig['SAFE_MODE'],
'M_CALL_PUSH_NOTIFICATION': userConfig['M_CALL_PUSH_NOTIFICATION'],
};
}
Future<RequestCodeResult> _requestCodeInternal(
String phone,
AuthRequestType type,
+2 -2
View File
@@ -92,8 +92,8 @@ class CallsModule {
msg['id']?.toString() ??
DateTime.now().millisecondsSinceEpoch.toString();
final name = contact?.firstName != null
? '${contact!.firstName} ${contact.lastName ?? ''}'.trim()
final name = (contact != null && contact.firstName.isNotEmpty)
? '${contact.firstName} ${contact.lastName ?? ''}'.trim()
: 'Неизвестный';
extractedCalls.add(
+79 -14
View File
@@ -1,7 +1,24 @@
import 'dart:convert';
import '../../core/protocol/opcode_map.dart';
import '../../core/storage/app_database.dart';
import '../../core/utils/logger.dart';
import '../api.dart';
Map<int, int> _parseParticipants(dynamic raw) {
try {
final decoded = raw is String ? jsonDecode(raw) : raw;
if (decoded is Map) {
return decoded.map((k, v) => MapEntry(
k is int ? k : int.parse(k.toString()),
v is int ? v : int.tryParse(v.toString()) ?? 0,
));
}
} catch (e) {
logger.e('Failed to parse participants: $e');
}
return {};
}
class CachedChat {
final int id;
@@ -12,6 +29,7 @@ class CachedChat {
final int? lastMsgId;
final int? lastMsgTime;
final String? lastMsgText;
final String? lastMsgTextOneLine;
final int? lastMsgSenderId;
final int unreadCount;
final int lastEventTime;
@@ -21,8 +39,9 @@ class CachedChat {
final bool isOnline;
final int seenTime;
final Map<int, int> participants;
final Set<String> options;
const CachedChat({
CachedChat({
required this.id,
required this.accountId,
required this.type,
@@ -40,7 +59,12 @@ class CachedChat {
required this.isOnline,
required this.seenTime,
required this.participants,
});
this.options = const {},
}) : lastMsgTextOneLine = lastMsgText != null && lastMsgText.contains('\n')
? lastMsgText.replaceAll('\n', ' ')
: lastMsgText;
bool get isOfficial => options.contains('OFFICIAL');
factory CachedChat.fromDbRow(Map<String, dynamic> row) => CachedChat(
id: row['id'] as int,
@@ -59,10 +83,15 @@ class CachedChat {
dontDisturbUntil: row['dont_disturb_until'] as int,
isOnline: (row['is_online'] as int) == 1,
seenTime: row['seen_time'] as int,
// watafuc
participants: Map<String, int>.from(jsonDecode(row['participants'])).map((k, v) => MapEntry(int.parse(k), v))
participants: _parseParticipants(row['participants']),
options: _decodeOptions(row['options']),
);
static Set<String> _decodeOptions(dynamic raw) {
if (raw is! String || raw.isEmpty) return const {};
return raw.split(',').where((s) => s.isNotEmpty).toSet();
}
Map<String, dynamic> toDbRow() => {
'id': id,
'account_id': accountId,
@@ -80,7 +109,8 @@ class CachedChat {
'dont_disturb_until': dontDisturbUntil,
'is_online': isOnline ? 1 : 0,
'seen_time': seenTime,
'participants': jsonEncode(participants.map((k, v) => MapEntry(k.toString(), v)))
'participants': jsonEncode(participants.map((k, v) => MapEntry(k.toString(), v))),
'options': options.isEmpty ? null : options.join(','),
};
}
@@ -105,6 +135,7 @@ class ChatsModule {
final chatsConfig = configMap['chats'] is Map
? configMap['chats'] as Map
: {};
// Presence for online statuses
final presenceMap = data['presence'] is Map ? data['presence'] as Map : {};
final cachedAt = DateTime.now().millisecondsSinceEpoch;
@@ -144,7 +175,6 @@ class ChatsModule {
static Future<List<CachedChat>> getChats(int accountId) async {
try {
final rows = await AppDatabase.loadChats(accountId);
return rows.map(CachedChat.fromDbRow).toList();
} catch (e) {
logger.e("Ошибка при получении чатов: $e");
@@ -195,6 +225,7 @@ class ChatsModule {
String? title;
String? iconUrl;
Set<String> options = const {};
if (type == 'DIALOG') {
otherId = _otherParticipantId(chat['participants'], currentUserId);
@@ -203,13 +234,25 @@ class ChatsModule {
if (contact != null) {
title = _nameFromContact(contact);
iconUrl = contact['baseUrl'] as String?;
final contactOpts = contact['options'];
if (contactOpts is List) {
options = contactOpts.whereType<String>().toSet();
}
} else {
title = existing[id]?.title;
iconUrl = existing[id]?.iconUrl;
options = existing[id]?.options ?? const {};
}
} else {
title = chat['title'] as String?;
iconUrl = chat['baseIconUrl'] as String?;
final chatOpts = chat['options'];
if (chatOpts is Map) {
options = {
for (final entry in chatOpts.entries)
if (entry.value == true && entry.key is String) entry.key as String,
};
}
}
final lastMsg = chat['lastMessage'];
@@ -233,6 +276,7 @@ class ChatsModule {
dontDisturbUntil = (config['dontDisturbUntil'] as int?) ?? 0;
}
int seenTime = 0;
bool isOnline = false;
if (type == 'DIALOG' && otherId != null) {
@@ -242,7 +286,7 @@ class ChatsModule {
isOnline = (presence['status'] as int?) == 1;
}
}
Map<int, int> participants = Map<int, int>.from(chat['participants']);
Map<int, int> participants = _parseParticipants(chat['participants']);
return CachedChat(
id: id,
@@ -261,7 +305,8 @@ class ChatsModule {
dontDisturbUntil: dontDisturbUntil,
isOnline: isOnline,
seenTime: seenTime,
participants: participants
participants: participants,
options: options,
);
} catch (e) {
logger.e("Ошибка при парсинге чата: $e");
@@ -282,12 +327,32 @@ class ChatsModule {
static String? _nameFromContact(Map<dynamic, dynamic> contact) {
final names = contact['names'];
if (names is! List || names.isEmpty) return null;
final name =
names.firstWhere(
(n) => n is Map && n['type'] == 'ONEME',
orElse: () => names.first,
)
as Map;
final nameRaw = names.firstWhere(
(n) => n is Map && n['type'] == 'ONEME',
orElse: () => names.firstWhere((n) => n is Map, orElse: () => null),
);
if (nameRaw is! Map) return null;
final name = nameRaw;
return name['name'] as String?;
}
static Future<Map<String, dynamic>?> getChatInfo(Api api, int chatId) async {
final packet = await api.sendRequest(Opcode.chatInfo, {
'chatIds': [chatId],
});
if (packet.isError) return null;
final payload = packet.payload as Map?;
final chats = payload?['chats'] as List?;
if (chats == null || chats.isEmpty) return null;
return Map<String, dynamic>.from(chats.first as Map);
}
static Future<dynamic> searchById(Api api, int userId) async {
final packet = await api.sendRequest(Opcode.publicSearch, {
'query': userId.toString(),
'from': 0,
'count': 10,
});
return packet.payload;
}
}
+75 -11
View File
@@ -1,4 +1,5 @@
import '../../core/storage/app_database.dart';
import 'messages.dart';
class CachedContact {
final int id;
@@ -10,6 +11,7 @@ class CachedContact {
final String? baseUrl;
final String? baseRawUrl;
final int updateTime;
final Set<String> options;
const CachedContact({
required this.id,
@@ -21,8 +23,14 @@ class CachedContact {
this.baseUrl,
this.baseRawUrl,
required this.updateTime,
this.options = const {},
});
bool get isOfficial => options.contains('OFFICIAL');
bool get isBot => options.contains('BOT');
bool get isServiceAccount => options.contains('SERVICE_ACCOUNT');
bool get isVerified => isOfficial;
factory CachedContact.fromDbRow(Map<String, dynamic> row) => CachedContact(
id: row['id'] as int,
accountId: row['account_id'] as int,
@@ -33,7 +41,13 @@ class CachedContact {
baseUrl: row['base_url'] as String?,
baseRawUrl: row['base_raw_url'] as String?,
updateTime: row['update_time'] as int,
options: _decodeOptions(row['options']),
);
static Set<String> _decodeOptions(dynamic raw) {
if (raw is! String || raw.isEmpty) return const {};
return raw.split(',').where((s) => s.isNotEmpty).toSet();
}
}
class ContactsModule {
@@ -44,22 +58,65 @@ class ContactsModule {
final contacts = data['contacts'];
if (contacts is! List || contacts.isEmpty) return;
final rows = contacts
.whereType<Map>()
.map((c) => _parseContact(c.cast<dynamic, dynamic>(), accountId))
.whereType<Map<String, dynamic>>()
.toList();
final rows = <Map<String, dynamic>>[];
for (final raw in contacts.whereType<Map>()) {
final contact = raw.cast<dynamic, dynamic>();
final row = _parseContact(contact, accountId);
if (row != null) rows.add(row);
_primeContactCache(contact);
}
if (rows.isNotEmpty) {
await AppDatabase.saveContacts(rows);
}
}
static void _primeContactCache(Map<dynamic, dynamic> contact) {
final id = contact['id'];
if (id is! int) return;
final names = contact['names'];
if (names is List && names.isNotEmpty) {
final nameRaw = names.firstWhere(
(n) => n is Map && n['type'] == 'ONEME',
orElse: () => names.firstWhere((n) => n is Map, orElse: () => null),
);
if (nameRaw is Map) {
final firstName = (nameRaw['firstName'] as String?) ?? '';
final lastName = nameRaw['lastName'] as String?;
final fullName = (lastName != null && lastName.isNotEmpty)
? '$firstName $lastName'
: firstName;
if (fullName.isNotEmpty) ContactCache.put(id, fullName);
}
}
final baseUrl = contact['baseUrl'] as String?;
if (baseUrl != null && baseUrl.isNotEmpty) {
ContactCache.putAvatar(id, baseUrl);
}
}
static Future<List<CachedContact>> getContacts(int accountId) async {
final rows = await AppDatabase.loadContacts(accountId);
return rows.map(CachedContact.fromDbRow).toList();
}
/// Прогревает in-memory ContactCache из локальных контактов.
/// Нужно вызывать на cold start: иначе кэш пуст до следующего логина.
static Future<void> primeCacheFromDb(int accountId) async {
final contacts = await getContacts(accountId);
for (final c in contacts) {
final fullName = (c.lastName != null && c.lastName!.isNotEmpty)
? '${c.firstName} ${c.lastName}'
: c.firstName;
if (fullName.isNotEmpty) ContactCache.put(c.id, fullName);
if (c.baseUrl != null && c.baseUrl!.isNotEmpty) {
ContactCache.putAvatar(c.id, c.baseUrl);
}
}
}
static Map<String, dynamic>? _parseContact(
Map<dynamic, dynamic> contact,
int accountId,
@@ -72,16 +129,22 @@ class ContactsModule {
final names = contact['names'];
if (names is List && names.isNotEmpty) {
final name =
names.firstWhere(
(n) => n is Map && n['type'] == 'ONEME',
orElse: () => names.first,
)
as Map;
final nameRaw = names.firstWhere(
(n) => n is Map && n['type'] == 'ONEME',
orElse: () => names.firstWhere((n) => n is Map, orElse: () => null),
);
if (nameRaw is! Map) return null;
final name = nameRaw;
firstName = (name['firstName'] as String?) ?? '';
lastName = name['lastName'] as String?;
}
final optionsRaw = contact['options'];
String? optionsStr;
if (optionsRaw is List) {
optionsStr = optionsRaw.whereType<String>().join(',');
}
return {
'id': id,
'account_id': accountId,
@@ -92,6 +155,7 @@ class ContactsModule {
'base_url': contact['baseUrl'] as String?,
'base_raw_url': contact['baseRawUrl'] as String?,
'update_time': (contact['updateTime'] as int?) ?? 0,
'options': optionsStr,
};
}
}
+210 -29
View File
@@ -8,19 +8,86 @@ import '../../models/attachment.dart';
class ContactCache {
static final Map<int, String> _nameCache = {};
static final Map<int, String> _avatarCache = {};
static final Map<int, Set<String>> _optionsCache = {};
static void put(int id, String name) {
_nameCache[id] = name;
}
static void put(int id, String name) => _nameCache[id] = name;
static void putAvatar(int id, String? baseUrl) {
if (baseUrl != null) {
_avatarCache[id] = baseUrl;
}
if (baseUrl != null) _avatarCache[id] = baseUrl;
}
static void putOptions(int id, Set<String> opts) => _optionsCache[id] = opts;
static String? get(int id) => _nameCache[id];
static String? getAvatar(int id) => _avatarCache[id];
static bool isOfficial(int id) => _optionsCache[id]?.contains('OFFICIAL') ?? false;
}
class TranscriptionResult {
final int status;
final String? text;
final String? messageId;
final int? chatId;
final int? mediaId;
TranscriptionResult({
required this.status,
this.text,
this.messageId,
this.chatId,
this.mediaId,
});
}
class TranscriptionCache {
static final Map<String, TranscriptionResult> _cache = {};
static void put(String messageId, TranscriptionResult result) {
_cache[messageId] = result;
}
static TranscriptionResult? get(String messageId) => _cache[messageId];
static bool has(String messageId) => _cache.containsKey(messageId);
}
class FileHistoryEntry {
final int fileId;
final String? url;
final String? token;
final DateTime sentAt;
FileHistoryEntry({
required this.fileId,
this.url,
this.token,
required this.sentAt,
});
}
class FileHistoryCache {
static final List<FileHistoryEntry> _history = [];
static List<FileHistoryEntry> get history => List.unmodifiable(_history);
static void add(FileHistoryEntry entry) {
_history.insert(0, entry);
if (_history.length > 50) _history.removeLast();
}
static bool get isEmpty => _history.isEmpty;
}
class FileUploadInfo {
final String url;
final int fileId;
final String token;
FileUploadInfo({
required this.url,
required this.fileId,
required this.token,
});
}
class CachedMessage {
@@ -33,6 +100,7 @@ class CachedMessage {
final String? status;
final Map<String, dynamic>? payload;
final List<MessageAttachment>? attachments;
final bool isControl;
const CachedMessage({
required this.id,
@@ -44,6 +112,7 @@ class CachedMessage {
this.status,
this.payload,
this.attachments,
this.isControl = false,
});
factory CachedMessage.fromDbRow(Map<String, dynamic> row) {
@@ -75,15 +144,16 @@ class CachedMessage {
}
return CachedMessage(
id: row['id'] as String,
accountId: row['account_id'] as int,
chatId: row['chat_id'] as int,
senderId: row['sender_id'] as int,
text: row['text'] as String?,
time: row['time'] as int,
status: row['status'] as String?,
id: row['id']?.toString() ?? '',
accountId: row['account_id'] is int ? row['account_id'] as int : int.tryParse(row['account_id']?.toString() ?? '') ?? 0,
chatId: row['chat_id'] is int ? row['chat_id'] as int : int.tryParse(row['chat_id']?.toString() ?? '') ?? 0,
senderId: row['sender_id'] is int ? row['sender_id'] as int : int.tryParse(row['sender_id']?.toString() ?? '') ?? 0,
text: row['text']?.toString(),
time: row['time'] is int ? row['time'] as int : int.tryParse(row['time']?.toString() ?? '') ?? 0,
status: row['status']?.toString(),
payload: payload,
attachments: attachments,
isControl: attachments?.any((a) => a.type == AttachmentType.control) ?? false,
);
}
@@ -155,7 +225,9 @@ class MessagesModule {
}
if (rows.isNotEmpty) {
AppDatabase.saveMessages(rows).ignore();
AppDatabase.saveMessages(rows).catchError((e) {
debugPrint('saveMessages error: $e');
});
}
return results;
@@ -192,6 +264,7 @@ class MessagesModule {
}
List<MessageAttachment>? attachments;
bool isControl = false;
if (linkType == 'FORWARD') {
final fwdMap = Map<String, dynamic>.from(m.cast());
attachments = [ForwardedMessageAttachment.fromMap(fwdMap)];
@@ -202,6 +275,10 @@ class MessagesModule {
.whereType<Map>()
.map((a) => MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
.toList();
// Detect CONTROL
if (attachments.any((a) => a.type == AttachmentType.control)) {
isControl = true;
}
}
}
@@ -209,16 +286,24 @@ class MessagesModule {
id: id,
accountId: accountId,
chatId: chatId,
senderId: (m['sender'] as int?) ?? 0,
text: m['text'] as String?,
time: (m['time'] as int?) ?? 0,
status: m['status'] as String?,
senderId: _parseIntField(m['sender']),
text: m['text']?.toString(),
time: _parseIntField(m['time']),
status: m['status']?.toString(),
payload: Map<String, dynamic>.from(m.cast()),
attachments: attachments,
isControl: isControl,
);
}
Future<void> sendMessage(
int _parseIntField(dynamic value) {
if (value == null) return 0;
if (value is int) return value;
if (value is String) return int.tryParse(value) ?? 0;
return int.tryParse(value.toString()) ?? 0;
}
Future<String> sendMessage(
int accountId,
int chatId,
String text, {
@@ -235,7 +320,99 @@ class MessagesModule {
'notify': notify,
};
await _api.sendRequest(Opcode.msgSend, payload);
final response = await _api.sendRequest(Opcode.msgSend, payload);
if (!response.isOk) {
final msg = (response.payload is Map)
? (response.payload['localizedMessage'] ?? response.payload['message'] ?? 'Ошибка отправки')
: 'Ошибка отправки';
throw Exception(msg.toString());
}
final data = response.payload;
if (data is Map) {
final msgMap = data['message'];
if (msgMap is Map) {
final id = msgMap['id'];
if (id != null) return id.toString();
}
}
return '';
}
Future<TranscriptionResult> requestTranscription(
int chatId,
int messageId,
int mediaId,
) async {
final payload = {
'chatId': chatId,
'messageId': messageId,
'mediaId': mediaId,
};
final response = await _api.sendRequest(Opcode.audioTranscription, payload);
if (!response.isOk) return TranscriptionResult(status: -1);
final data = response.payload;
if (data is! Map) return TranscriptionResult(status: -1);
final transcriptionStatus = data['transcriptionStatus'] as int? ?? -1;
if (transcriptionStatus == 1) {
final text = data['transcription'] as String? ?? '';
if (text.isEmpty) {
return TranscriptionResult(status: 1, text: 'не удалось распознать текст');
}
return TranscriptionResult(status: 1, text: text);
}
return TranscriptionResult(status: transcriptionStatus);
}
Future<FileUploadInfo?> requestUploadUrl({int count = 1}) async {
final payload = {'count': count};
final response = await _api.sendRequest(Opcode.fileUpload, payload);
if (!response.isOk) return null;
final data = response.payload;
if (data is! Map) return null;
final infoList = data['info'] as List?;
if (infoList == null || infoList.isEmpty) return null;
final info = infoList.first;
if (info is! Map) return null;
return FileUploadInfo(
url: info['url'] as String? ?? '',
fileId: info['fileId'] as int? ?? 0,
token: info['token'] as String? ?? '',
);
}
Future<bool> sendFileMessage(
int chatId,
int fileId, {
String? token,
bool notify = true,
}) async {
final payload = {
'chatId': chatId,
'message': {
'isLive': false,
'detectShare': false,
'elements': <dynamic>[],
'cid': DateTime.now().millisecondsSinceEpoch,
'attaches': [
if (token != null)
{'_type': 'FILE', 'token': token}
else
{'_type': 'FILE', 'fileId': fileId}
],
},
'notify': notify,
};
final response = await _api.sendRequest(Opcode.msgSend, payload);
return response.isOk;
}
Future<Uint8List?> downloadPhoto(String baseUrl, String photoToken) async {
@@ -250,9 +427,8 @@ class MessagesModule {
if (data is! Map) return null;
final content = data['content'];
if (content is String) {
return Uri.parse(content).host.isNotEmpty ? null : null;
}
if (content is Uint8List) return content;
if (content is List<int>) return Uint8List.fromList(content);
return null;
} catch (e) {
return null;
@@ -288,9 +464,8 @@ class MessagesModule {
if (data is! Map) return null;
final content = data['content'];
if (content is String) {
return Uri.parse(content).host.isNotEmpty ? null : null;
}
if (content is Uint8List) return content;
if (content is List<int>) return Uint8List.fromList(content);
return null;
} catch (e) {
return null;
@@ -326,9 +501,8 @@ class MessagesModule {
if (data is! Map) return null;
final content = data['content'];
if (content is String) {
return Uri.parse(content).host.isNotEmpty ? null : null;
}
if (content is Uint8List) return content;
if (content is List<int>) return Uint8List.fromList(content);
return null;
} catch (e) {
return null;
@@ -356,6 +530,8 @@ class MessagesModule {
final cached = ContactCache.get(contactId);
if (cached != null) return cached;
if (_api.state != SessionState.online) return null;
try {
final response = await _api.sendRequest(Opcode.contactInfo, {
'contactIds': [contactId],
@@ -383,6 +559,11 @@ class MessagesModule {
final baseUrl = contact['baseUrl'] as String?;
ContactCache.putAvatar(contactId, baseUrl);
final rawOpts = contact['options'];
if (rawOpts is List) {
ContactCache.putOptions(contactId, rawOpts.whereType<String>().toSet());
}
return fullName;
}
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AppAccent {
static const prefKey = 'app_accent_seed';
static const List<({String label, Color? seed})> presets = [
(label: 'Системный', seed: null),
(label: 'Сиреневый', seed: Color(0xFFC1C4FF)),
(label: 'Синий', seed: Color(0xFF4F8EFF)),
(label: 'Бирюзовый', seed: Color(0xFF00BFA5)),
(label: 'Зелёный', seed: Color(0xFF43A047)),
(label: 'Янтарный', seed: Color(0xFFFFB300)),
(label: 'Розовый', seed: Color(0xFFE91E63)),
(label: 'Красный', seed: Color(0xFFE53935)),
(label: 'Фиолетовый', seed: Color(0xFF7E57C2)),
];
static Future<Color?> load() async {
final prefs = await SharedPreferences.getInstance();
final val = prefs.getInt(prefKey);
if (val == null) return null;
return Color(val);
}
static Future<void> save(Color? color) async {
final prefs = await SharedPreferences.getInstance();
if (color == null) {
await prefs.remove(prefKey);
} else {
await prefs.setInt(prefKey, color.toARGB32());
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
enum BubbleStyle { mobile, desktop }
class AppBubbleShape {
static const prefKey = 'app_bubble_shape';
static final ValueNotifier<BubbleStyle> current = ValueNotifier(
BubbleStyle.mobile,
);
static Future<BubbleStyle> load() async {
final prefs = await SharedPreferences.getInstance();
final val = prefs.getString(prefKey);
return _parse(val);
}
static Future<void> save(BubbleStyle style) async {
current.value = style;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(prefKey, style.name);
}
static BubbleStyle _parse(String? val) {
if (val == BubbleStyle.desktop.name) return BubbleStyle.desktop;
return BubbleStyle.mobile;
}
static String label(BubbleStyle style) {
switch (style) {
case BubbleStyle.mobile:
return 'TG Mobile';
case BubbleStyle.desktop:
return 'TG Desktop';
}
}
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AppCacheExtent {
static const prefKey = 'app_cache_extent';
static const double defaultValue = 5000;
static const double min = 1000;
static const double max = 10000;
static const double lowWarnThreshold = 2500;
static const double highWarnThreshold = 7000;
static final ValueNotifier<double> current = ValueNotifier(defaultValue);
static Future<double> load() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getDouble(prefKey);
if (raw == null) return defaultValue;
return clamp(raw);
}
static Future<void> save(double value) async {
final clamped = clamp(value);
current.value = clamped;
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(prefKey, clamped);
}
static double clamp(double v) {
if (v < min) return min;
if (v > max) return max;
return v;
}
}
+120
View File
@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AppFont {
final String id;
final String label;
final String? googleFamily;
const AppFont({
required this.id,
required this.label,
this.googleFamily,
});
bool get isSystem => googleFamily == null;
bool get isCustom => id.startsWith(AppFonts.customPrefix);
}
class AppFonts {
static const String prefKey = 'app_font';
static const String scalePrefKey = 'app_font_scale';
static const String customPrefKey = 'app_custom_fonts';
static const String customPrefix = 'g:';
static const double minScale = 0.85;
static const double maxScale = 1.35;
static const double defaultScale = 1.0;
static const List<AppFont> builtIn = [
AppFont(id: 'system', label: 'Системный'),
AppFont(id: 'inter', label: 'Inter', googleFamily: 'Inter'),
AppFont(id: 'unbounded', label: 'Unbounded', googleFamily: 'Unbounded'),
];
static AppFont get fallback => builtIn.first;
static String customId(String family) => '$customPrefix$family';
static AppFont resolve(String id) {
if (id.startsWith(customPrefix)) {
final family = id.substring(customPrefix.length);
return AppFont(id: id, label: family, googleFamily: family);
}
return builtIn.firstWhere((f) => f.id == id, orElse: () => fallback);
}
static TextTheme textTheme(String id, TextTheme base) {
final family = resolve(id).googleFamily;
if (family == null) return base;
try {
return GoogleFonts.getTextTheme(family, base);
} catch (_) {
return base;
}
}
static TextStyle sample(String id, {required double fontSize}) {
final family = resolve(id).googleFamily;
if (family == null) return TextStyle(fontSize: fontSize);
try {
return GoogleFonts.getFont(family, fontSize: fontSize);
} catch (_) {
return TextStyle(fontSize: fontSize);
}
}
static double clampScale(double scale) =>
scale.clamp(minScale, maxScale).toDouble();
static String? familyFromInput(String input) {
var value = input.trim();
if (value.isEmpty) return null;
final uri = Uri.tryParse(value);
if (uri != null && uri.host.contains('fonts.google.com')) {
final idx = uri.pathSegments.indexOf('specimen');
if (idx != -1 && idx + 1 < uri.pathSegments.length) {
value = uri.pathSegments[idx + 1];
}
}
try {
value = Uri.decodeComponent(value);
} catch (_) {}
value = value.replaceAll('+', ' ').trim();
return value.isEmpty ? null : value;
}
static String? matchGoogleFamily(String family) {
final map = GoogleFonts.asMap();
if (map.containsKey(family)) return family;
final lower = family.toLowerCase();
for (final key in map.keys) {
if (key.toLowerCase() == lower) return key;
}
return null;
}
static Future<List<String>> loadCustomFamilies() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getStringList(customPrefKey) ?? const <String>[];
}
static Future<void> addCustomFamily(String family) async {
final prefs = await SharedPreferences.getInstance();
final list = prefs.getStringList(customPrefKey) ?? <String>[];
if (!list.contains(family)) {
list.add(family);
await prefs.setStringList(customPrefKey, list);
}
}
static Future<void> removeCustomFamily(String family) async {
final prefs = await SharedPreferences.getInstance();
final list = prefs.getStringList(customPrefKey) ?? <String>[];
list.remove(family);
await prefs.setStringList(customPrefKey, list);
}
}
+6
View File
@@ -170,6 +170,10 @@ abstract class Opcode {
static const int notifBanners = 292; // Баннеры
static const int notifFolders = 277; // Обновление папок
// ── Transcription ───────────────────────────────────────────────────
static const int audioTranscription = 202; // Запрос транскрибации аудио
static const int transcriptionResult = 293; // Результат транскрибации (push)
// ── Misc ───────────────────────────────────────────────────────────
static const int okToken = 158; // OK-токен
static const int webAppInitData = 160; // Данные WebApp
@@ -332,6 +336,8 @@ abstract class Opcode {
notifProfile: 'NOTIF_PROFILE',
notifBanners: 'NOTIF_BANNERS',
notifFolders: 'NOTIF_FOLDERS',
audioTranscription: 'AUDIO_TRANSCRIPTION',
transcriptionResult: 'TRANSCRIPTION_RESULT',
okToken: 'OK_TOKEN',
webAppInitData: 'WEB_APP_INIT_DATA',
complain: 'COMPLAIN',
+40 -15
View File
@@ -1,6 +1,7 @@
import 'dart:typed_data';
import 'dart:isolate';
import 'package:dart_lz4/dart_lz4.dart';
import 'package:libcompress/libcompress.dart';
import 'package:msgpack_dart/msgpack_dart.dart' as msgpack;
/// ver(1) + cmd(1) + seq(2) + opcode(2) + packedLen(4) = 10
@@ -129,21 +130,7 @@ Future<Packet> unpackPacket(Uint8List packet) async {
if (payloadBytes.isNotEmpty) {
if (compFlag != 0) {
try {
payloadBytes = lz4Decompress(
payloadBytes,
decompressedSize: _maxDecompressedSize,
);
} catch (_) {
try {
payloadBytes = _lz4BlockDecompress(
payloadBytes,
_maxDecompressedSize,
);
} catch (e) {
throw Exception("LZ4 decompression error: $e");
}
}
payloadBytes = _decompressPayload(payloadBytes);
}
try {
@@ -165,6 +152,43 @@ Future<Packet> unpackPacket(Uint8List packet) async {
});
}
/// Определяет формат сжатия по magic-number и распаковывает payload.
/// Сервер может присылать LZ4 block ИЛИ Zstandard в зависимости от ответа.
Uint8List _decompressPayload(Uint8List src) {
// Zstandard: magic 28 B5 2F FD (little-endian)
if (src.length >= 4 &&
src[0] == 0x28 &&
src[1] == 0xB5 &&
src[2] == 0x2F &&
src[3] == 0xFD) {
try {
return ZstdCodec().decompress(src);
} catch (e) {
throw Exception('Zstd decompression error: $e');
}
}
// LZ4 frame: magic 04 22 4D 18
if (src.length >= 4 &&
src[0] == 0x04 &&
src[1] == 0x22 &&
src[2] == 0x4D &&
src[3] == 0x18) {
try {
return lz4Decompress(src, decompressedSize: _maxDecompressedSize);
} catch (e) {
throw Exception('LZ4 frame decompression error: $e');
}
}
// По умолчанию — LZ4 block (без magic)
try {
return _lz4BlockDecompress(src, _maxDecompressedSize);
} catch (e) {
throw Exception('LZ4 block decompression error: $e');
}
}
/// LZ4 block декомпрессия (без frame-заголовка).
/// Сервер шлёт именно block-формат, dart_lz4 его не поддерживает.
Uint8List _lz4BlockDecompress(Uint8List src, int maxSize) {
@@ -190,6 +214,7 @@ Uint8List _lz4BlockDecompress(Uint8List src, int maxSize) {
if (pos >= src.length) break;
if (pos + 1 >= src.length) throw StateError('LZ4: unexpected end of input');
final offset = src[pos] | (src[pos + 1] << 8);
pos += 2;
if (offset == 0) throw StateError('LZ4: offset = 0');
+188
View File
@@ -0,0 +1,188 @@
import 'dart:async';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../backend/api.dart';
import '../../backend/modules/account.dart';
import '../utils/logger.dart';
const _channelId = 'komet_messages';
const _channelName = 'Сообщения';
const _prefsTokenKey = 'fcm_push_token';
@pragma('vm:entry-point')
Future<void> _backgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
if (message.notification != null) return;
final plugin = FlutterLocalNotificationsPlugin();
await plugin.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
),
);
await _display(plugin, message);
}
Future<void> _display(
FlutterLocalNotificationsPlugin plugin,
RemoteMessage message,
) async {
final data = message.data;
final title = message.notification?.title ??
data['title']?.toString() ??
data['sender']?.toString() ??
'MAX';
final body = message.notification?.body ??
data['body']?.toString() ??
data['text']?.toString() ??
data['message']?.toString() ??
'Новое сообщение';
await plugin.show(
id: message.messageId?.hashCode ??
DateTime.now().millisecondsSinceEpoch ~/ 1000,
title: title,
body: body,
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
_channelName,
importance: Importance.high,
priority: Priority.high,
),
),
);
}
class PushService {
PushService._();
static final PushService instance = PushService._();
final FlutterLocalNotificationsPlugin _local =
FlutterLocalNotificationsPlugin();
Api? _api;
AccountModule? _account;
String? _token;
bool _initialized = false;
Future<void> init({required Api api, required AccountModule account}) async {
if (_initialized) return;
_api = api;
_account = account;
try {
await Firebase.initializeApp();
} catch (e) {
logger.w('Push: Firebase init не удался: $e');
return;
}
_initialized = true;
await _local.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
),
);
await _local
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(
const AndroidNotificationChannel(
_channelId,
_channelName,
importance: Importance.high,
),
);
final messaging = FirebaseMessaging.instance;
await messaging.requestPermission();
FirebaseMessaging.onBackgroundMessage(_backgroundHandler);
FirebaseMessaging.onMessage.listen((m) {
_display(_local, m);
});
messaging.onTokenRefresh.listen((t) async {
_token = t;
await _persistToken(t);
await _registerWithServer();
});
final prefs = await SharedPreferences.getInstance();
_token = prefs.getString(_prefsTokenKey);
try {
_token = await messaging.getToken() ?? _token;
if (_token != null) await _persistToken(_token!);
logger.i('Push: FCM-токен получен (${_token?.length ?? 0} симв.)');
} catch (e) {
logger.w('Push: getToken не удался: $e');
}
}
Future<void> onLoginSuccess() async {
if (!_initialized) return;
if (_token == null) {
try {
_token = await FirebaseMessaging.instance.getToken();
if (_token != null) await _persistToken(_token!);
} catch (_) {}
}
await _registerWithServer();
}
Future<void> unregister() async {
if (!_initialized || _token == null) return;
final account = _account;
if (account != null) {
try {
await account.unregisterPushToken(_token!);
} catch (e) {
logger.w('Push: unregister не удался: $e');
}
}
try {
await FirebaseMessaging.instance.deleteToken();
} catch (_) {}
_token = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_prefsTokenKey);
}
Future<void> _registerWithServer() async {
final account = _account;
final api = _api;
if (account == null || api == null) return;
if (api.state != SessionState.online) return;
final token = _token;
if (token == null || token.isEmpty) return;
try {
await account.registerPushToken(token);
logger.i('Push: токен зарегистрирован на сервере MAX');
} on WrongDeviceTokenException {
logger.w('Push: WRONG_DEVICE_TOKEN, переполучаю токен');
try {
await FirebaseMessaging.instance.deleteToken();
final fresh = await FirebaseMessaging.instance.getToken();
if (fresh != null && fresh.isNotEmpty) {
_token = fresh;
await _persistToken(fresh);
await account.registerPushToken(fresh);
logger.i('Push: токен перерегистрирован');
}
} catch (e) {
logger.w('Push: повторная регистрация не удалась: $e');
}
} catch (e) {
logger.w('Push: регистрация токена не удалась: $e');
}
}
Future<void> _persistToken(String token) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsTokenKey, token);
}
}
+53 -10
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:komet/core/utils/logger.dart';
@@ -72,10 +73,15 @@ class ProfileData {
final profileOptionsStr = row['profile_options'] as String?;
List<int>? profileOptions;
if (profileOptionsStr != null && profileOptionsStr.isNotEmpty) {
profileOptions = profileOptionsStr
.split(',')
.map((e) => int.parse(e.trim()))
.toList();
try {
profileOptions = profileOptionsStr
.split(',')
.where((e) => e.trim().isNotEmpty)
.map((e) => int.parse(e.trim()))
.toList();
} catch (_) {
profileOptions = null;
}
}
return ProfileData(
id: row['id'] as int,
@@ -92,7 +98,7 @@ class ProfileData {
);
}
Map<String, dynamic> toDbRow() => {
Map<String, dynamic> toDbRow({bool isActive = false}) => {
'id': id,
'first_name': firstName,
'last_name': lastName,
@@ -103,6 +109,7 @@ class ProfileData {
'country': country,
'account_status': accountStatus,
'update_time': updateTime,
'is_active': isActive ? 1 : 0,
'profile_options': profileOptions?.join(','),
};
}
@@ -118,6 +125,7 @@ abstract class SyncKey {
static const configHash = 'config_hash';
static const chatCacheFingerprint = 'chat_cache_fingerprint';
static const serverTime = 'server_time';
static const loginInfo = 'login_info';
}
class AppDatabase {
@@ -130,8 +138,20 @@ class AppDatabase {
}
}
static Completer<Database>? _initCompleter;
static Future<Database> get _instance async {
_db ??= await _open();
if (_db != null) return _db!;
if (_initCompleter != null) return _initCompleter!.future;
_initCompleter = Completer<Database>();
try {
_db = await _open();
_initCompleter!.complete(_db!);
} catch (e) {
_initCompleter!.completeError(e);
_initCompleter = null;
rethrow;
}
return _db!;
}
@@ -139,7 +159,7 @@ class AppDatabase {
final dbPath = await getDatabasesPath();
return openDatabase(
join(dbPath, 'komet.db'),
version: 8,
version: 9,
onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'),
onCreate: (db, _) => _createTables(db),
onUpgrade: (db, oldVersion, newVersion) async {
@@ -173,6 +193,14 @@ class AppDatabase {
'ALTER TABLE chats_cache ADD COLUMN participants TEXT',
);
}
if (oldVersion < 9) {
await db.execute(
'ALTER TABLE contacts ADD COLUMN options TEXT',
);
await db.execute(
'ALTER TABLE chats_cache ADD COLUMN options TEXT',
);
}
},
);
}
@@ -210,7 +238,8 @@ class AppDatabase {
photo_id INTEGER,
base_url TEXT,
base_raw_url TEXT,
update_time INTEGER NOT NULL DEFAULT 0
update_time INTEGER NOT NULL DEFAULT 0,
options TEXT
)
''';
@@ -242,6 +271,7 @@ class AppDatabase {
is_online INTEGER NOT NULL DEFAULT 0,
seen_time INTEGER NOT NULL DEFAULT 0,
participants TEXT NOT NULL DEFAULT "",
options TEXT,
PRIMARY KEY (id, account_id)
)
''';
@@ -261,11 +291,11 @@ class AppDatabase {
)
''';
static Future<void> saveProfile(ProfileData profile) async {
static Future<void> saveProfile(ProfileData profile, {bool isActive = true}) async {
final db = await _instance;
await db.insert(
'profile',
profile.toDbRow(),
profile.toDbRow(isActive: isActive),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
@@ -374,6 +404,19 @@ class AppDatabase {
return rows.first['value'] as String;
}
static Future<void> saveLoginInfo(int accountId, String jsonInfo) async {
final db = await _instance;
await db.insert('sync_state', {
'account_id': accountId,
'key': SyncKey.loginInfo,
'value': jsonInfo,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
static Future<String?> getLoginInfo(int accountId) async {
return getSyncValue(accountId, SyncKey.loginInfo);
}
static Future<void> close() async {
await _db?.close();
_db = null;
+1 -1
View File
@@ -1,7 +1,7 @@
import 'package:shared_preferences/shared_preferences.dart';
class SpoofingService {
static const String hardcodedAppVersion = '26.8.1';
static const String hardcodedAppVersion = '26.14.1';
static const int hardcodedBuildNumber = 6606;
static Future<Map<String, dynamic>?> getSpoofedSessionData() async {
+1 -1
View File
@@ -33,7 +33,7 @@ class TokenStorage {
static Future<String?> readActiveToken() async {
final id = await getActiveAccountId();
if (id == null) return null;
return readToken(id);
return await readToken(id);
}
static Future<void> deleteAccount(int accountId) async {
+44 -12
View File
@@ -5,6 +5,7 @@ import 'dart:typed_data';
import '../config/proxy_config.dart';
import '../utils/logger.dart';
import 'proxy_connector.dart';
import 'vpn_bypass.dart';
enum SocketState { disconnected, connecting, connected }
@@ -29,28 +30,34 @@ class Connection {
_stateController.add(newState);
}
Future<void> connect(String host, int port) async {
Future<void> connect(
String host,
int port, {
bool bypassVpn = false,
Duration? timeout,
}) async {
if (_state != SocketState.disconnected) return;
_setState(SocketState.connecting);
try {
final proxySettings = await ProxyConfig.load();
RawSocket rawSocket;
if (proxySettings.isEnabled) {
final connector = ProxyConnector(proxySettings);
rawSocket = await connector.connect(host, port);
logger.i('Подключено через прокси ${proxySettings.type.name}');
// Решение «обходить VPN или нет» принимает вызывающий (Api):
// первая попытка идёт через VPN, при её провале — мимо туннеля.
if (bypassVpn) {
await VpnBypassService.instance.bind();
} else {
rawSocket = await RawSocket.connect(host, port);
await VpnBypassService.instance.restoreDefault();
}
_socket = await RawSecureSocket.secure(
rawSocket,
host: host,
onBadCertificate: (_) => true,
final socket = await _openSecureSocket(
host,
port,
proxySettings,
timeout: timeout,
);
_socket = socket;
_setState(SocketState.connected);
logger.i('Подключено к $host:$port');
@@ -83,6 +90,29 @@ class Connection {
}
}
Future<RawSecureSocket> _openSecureSocket(
String host,
int port,
ProxySettings proxySettings, {
Duration? timeout,
}) async {
RawSocket rawSocket;
if (proxySettings.isEnabled) {
final connector = ProxyConnector(proxySettings);
rawSocket = await connector.connect(host, port);
logger.i('Подключено через прокси ${proxySettings.type.name}');
} else {
rawSocket = timeout == null
? await RawSocket.connect(host, port)
: await RawSocket.connect(host, port, timeout: timeout);
}
return RawSecureSocket.secure(
rawSocket,
host: host,
onBadCertificate: (_) => true,
);
}
void write(Uint8List data) {
if (_socket == null || !isConnected) {
throw StateError('Нельзя писать: сокет не подключён');
@@ -99,7 +129,9 @@ class Connection {
if (socket != null) {
try {
socket.close();
} catch (_) {}
} catch (e) {
logger.w('Ошибка при закрытии сокета: $e');
}
}
_setState(SocketState.disconnected);
+5 -1
View File
@@ -53,8 +53,12 @@ class PacketDispatcher {
if (packet.cmd == CmdType.ok ||
packet.cmd == CmdType.error ||
packet.cmd == CmdType.notFound) {
final payloadStr = packet.payload.toString();
final displayPayload = packet.opcode == Opcode.login && payloadStr.length > 50
? '${payloadStr.substring(0, 50)}...'
: payloadStr;
logger.i(
'<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${packet.payload}}',
'<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: $displayPayload}',
);
final completer = _pendingRequests.remove(packet.seq);
+17 -7
View File
@@ -60,8 +60,8 @@ class ProxyConnector {
if (!useAuth) {
throw SocketException('SOCKS5: прокси требует аутентификацию');
}
final usernameBytes = utf8.encode(settings.username!);
final passwordBytes = utf8.encode(settings.password!);
final usernameBytes = utf8.encode(settings.username ?? '');
final passwordBytes = utf8.encode(settings.password ?? '');
final authPacket = BytesBuilder()
..addByte(0x01)
..addByte(usernameBytes.length)
@@ -175,7 +175,10 @@ class ProxyConnector {
final responseStr = utf8.decode(headerBytes, allowMalformed: true);
final statusLine = responseStr.split('\r\n').first;
final parts = statusLine.split(' ');
final statusCode = parts.length >= 2 ? int.tryParse(parts[1]) ?? 0 : 0;
if (parts.length < 2) {
throw SocketException('HTTP CONNECT: некорректный ответ: $statusLine');
}
final statusCode = int.tryParse(parts[1]) ?? 0;
if (statusCode != 200) {
throw SocketException(
'HTTP CONNECT: прокси вернул статус $statusCode',
@@ -201,10 +204,17 @@ class ProxyConnector {
RawSocket proxySocket,
_RawSocketIO io,
) async {
final server = await RawServerSocket.bind(
InternetAddress.loopbackIPv4,
0,
);
RawServerSocket? server;
try {
server = await RawServerSocket.bind(
InternetAddress.loopbackIPv4,
0,
);
} catch (e) {
io.dispose();
proxySocket.close();
rethrow;
}
final clientSide = await RawSocket.connect(
InternetAddress.loopbackIPv4,
server.port,
+9 -12
View File
@@ -4,15 +4,16 @@ import '../protocol/packet.dart';
import '../utils/logger.dart';
/// Буфер входящих данных.
/// Копит сырые байты из сокета, собирает из них целые пакеты.
/// Копит сырые байты из сокета, нарезает их на байтовые срезы целых пакетов.
class PacketReceiver {
Uint8List _buffer = Uint8List(0);
static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта
/// Добавляет байты в буфер, возвращает поток собранных пакетов.
/// Неполные данные остаются в буфере до следующего вызова.
Stream<Packet> feed(Uint8List data) async* {
/// Добавляет байты в буфер и возвращает все собранные пакеты как сырые срезы.
/// Полностью синхронный — нарезка не блокируется на распаковке, поэтому
/// конкурентные вызовы из stream-листенера не могут пересечься на `_buffer`.
List<Uint8List> feed(Uint8List data) {
final newBuffer = Uint8List(_buffer.length + data.length);
newBuffer.setAll(0, _buffer);
newBuffer.setAll(_buffer.length, data);
@@ -23,9 +24,10 @@ class PacketReceiver {
'PacketReceiver: переполнение буфера (${_buffer.length} B), сброс',
);
reset();
return;
return const [];
}
final packets = <Uint8List>[];
while (_buffer.length >= headerSize) {
final bd = ByteData.view(
_buffer.buffer,
@@ -38,15 +40,10 @@ class PacketReceiver {
if (_buffer.length < totalLength) break;
final packetBytes = Uint8List.sublistView(_buffer, 0, totalLength);
packets.add(Uint8List.sublistView(_buffer, 0, totalLength));
_buffer = _buffer.sublist(totalLength);
try {
yield await unpackPacket(packetBytes);
} catch (e) {
logger.e('PacketReceiver: ошибка распаковки: $e');
}
}
return packets;
}
void reset() {
+1 -1
View File
@@ -8,7 +8,7 @@ class PacketSender {
int get currentSeq => _seq;
int _nextSeq() {
_seq = (_seq + 1) % 256;
_seq = (_seq + 1) % 65536;
return _seq;
}
+143
View File
@@ -0,0 +1,143 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../utils/logger.dart';
class VpnBypassResult {
final bool enabled;
final bool tunDetected;
final bool bound;
final String? boundInterface;
final String? transport;
final String? reason;
const VpnBypassResult({
required this.enabled,
this.tunDetected = false,
this.bound = false,
this.boundInterface,
this.transport,
this.reason,
});
@override
String toString() =>
'VpnBypassResult(enabled: $enabled, tun: $tunDetected, bound: $bound, '
'iface: $boundInterface, transport: $transport, reason: $reason)';
}
/// При активном VPN (tun-интерфейс) привязывает процесс к не-VPN сети
/// (wlan*/rmnet*). Только Android, по умолчанию выключено.
class VpnBypassService {
VpnBypassService._();
static final VpnBypassService instance = VpnBypassService._();
static const String prefKey = 'dev_vpn_bypass';
static const MethodChannel _channel =
MethodChannel('ru.komet.app/vpn_bypass');
bool _bound = false;
final _eventController = StreamController<VpnBypassResult>.broadcast();
/// Эмитит результат каждой попытки обхода (для уведомления в UI).
Stream<VpnBypassResult> get events => _eventController.stream;
bool get _supported => Platform.isAndroid;
Future<bool> isEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(prefKey) ?? false;
}
Future<void> setEnabled(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(prefKey, value);
}
/// true — обход включён, платформа поддерживается и активен VPN.
Future<bool> shouldArm() async {
if (!_supported) return false;
if (!await isEnabled()) return false;
return _isVpnActive();
}
/// Привязывает процесс к non-VPN сети (wlan*/rmnet*).
Future<VpnBypassResult> bind() async {
VpnBypassResult result;
try {
final res = await _channel
.invokeMapMethod<String, dynamic>('bindToNonVpnNetwork');
final bound = res?['bound'] == true;
_bound = bound;
result = VpnBypassResult(
enabled: true,
tunDetected: true,
bound: bound,
boundInterface: res?['interface'] as String?,
transport: res?['transport'] as String?,
reason: res?['reason'] as String?,
);
} on PlatformException catch (e) {
logger.e('VPN bypass: ошибка платформы: ${e.message}');
result = VpnBypassResult(
enabled: true,
tunDetected: true,
reason: e.code,
);
} on MissingPluginException {
result = const VpnBypassResult(
enabled: true,
tunDetected: true,
reason: 'no_plugin',
);
}
if (result.bound) {
logger.i('VPN bypass: привязано к ${result.boundInterface} '
'(${result.transport})');
} else {
logger.w('VPN bypass: обойти не удалось (${result.reason})');
}
_eventController.add(result);
return result;
}
Future<bool> _isVpnActive() async {
try {
final res = await _channel
.invokeMapMethod<String, dynamic>('detectInterfaces');
if (res != null) {
if (res['hasTun'] == true || res['hasVpn'] == true) return true;
if (res.containsKey('hasTun')) return false;
}
} catch (_) {}
try {
final ifaces = await NetworkInterface.list(
includeLoopback: false,
includeLinkLocal: true,
);
return ifaces.any((i) {
final n = i.name.toLowerCase();
return n.startsWith('tun') ||
n.startsWith('ppp') ||
n.startsWith('ipsec') ||
n.startsWith('wg');
});
} catch (_) {
return false;
}
}
/// Возвращает маршрутизацию процесса к системной (через VPN, если он есть).
Future<void> restoreDefault() async {
if (!_bound) return;
try {
await _channel.invokeMethod('unbindNetwork');
} catch (_) {}
_bound = false;
}
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Centralized tactile feedback for Komet.
///
/// Wraps Flutter's [HapticFeedback] so the whole app speaks one tactile
/// "language": the same gesture always feels the same. Composite patterns
/// chain impacts with short delays to produce richer, more memorable
/// sensations than a single buzz.
///
/// Every call is best-effort and silent on failure — a device without a
/// vibrator (or with system haptics disabled) must never crash the UI.
class Haptics {
Haptics._();
static const String _prefKey = 'haptics_enabled';
/// Master switch. Silences every haptic app-wide when `false`.
/// Controlled by the user via Settings; persisted across launches.
static bool enabled = true;
/// Restores the saved preference. Call once during app startup,
/// before the first frame. Defaults to enabled when never set.
static Future<void> load() async {
try {
final prefs = await SharedPreferences.getInstance();
enabled = prefs.getBool(_prefKey) ?? true;
} catch (_) {
enabled = true;
}
}
/// Updates the master switch and persists it.
static Future<void> setEnabled(bool value) async {
enabled = value;
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKey, value);
} catch (_) {
// Persistence is best-effort; the in-memory switch still applies.
}
}
static Future<void> _fire(Future<void> Function() effect) async {
if (!enabled) return;
try {
await effect();
} catch (_) {
// Intentionally swallowed: haptics are a nicety, never a hard dependency.
}
}
/// A crisp, light tick — taps, toggles, opening panels.
static Future<void> tap() => _fire(HapticFeedback.lightImpact);
/// A firmer press — confirmations, entering a mode.
static Future<void> medium() => _fire(HapticFeedback.mediumImpact);
/// A strong thud — destructive or weighty actions.
static Future<void> heavy() => _fire(HapticFeedback.heavyImpact);
/// The subtle detent of moving between discrete options — tabs, selection.
static Future<void> selection() => _fire(HapticFeedback.selectionClick);
/// Message sent: a quick, instant tick (the "whoosh").
static Future<void> send() => tap();
/// A two-beat rising pulse — success, completion, "it landed".
static Future<void> success() async {
if (!enabled) return;
await _fire(HapticFeedback.lightImpact);
await Future.delayed(const Duration(milliseconds: 90));
await _fire(HapticFeedback.mediumImpact);
}
/// A double thud — errors, rejected or failed actions.
static Future<void> error() async {
if (!enabled) return;
await _fire(HapticFeedback.heavyImpact);
await Future.delayed(const Duration(milliseconds: 120));
await _fire(HapticFeedback.heavyImpact);
}
}
View File
@@ -34,13 +34,14 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
late AnimationController _shakeController;
late Animation<double> _shakeAnimation;
bool _keyboardScheduled = false;
Animation<double>? _routeAnimation;
AnimationStatusListener? _routeAnimationListener;
@override
void initState() {
super.initState();
_startTimer();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
_shakeController = AnimationController(
vsync: this,
@@ -56,8 +57,17 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
]).animate(CurvedAnimation(parent: _shakeController, curve: Curves.linear));
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_scheduleKeyboardOpen();
}
@override
void dispose() {
if (_routeAnimationListener != null) {
_routeAnimation?.removeStatusListener(_routeAnimationListener!);
}
_timer?.cancel();
_errorTimer?.cancel();
_shakeController.dispose();
@@ -66,6 +76,36 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
super.dispose();
}
void _scheduleKeyboardOpen() {
if (_keyboardScheduled) return;
_keyboardScheduled = true;
final animation = ModalRoute.of(context)?.animation;
if (animation == null || animation.status == AnimationStatus.completed) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _openKeyboard();
});
return;
}
_routeAnimation = animation;
_routeAnimationListener = (status) {
if (status == AnimationStatus.completed) {
animation.removeStatusListener(_routeAnimationListener!);
_routeAnimationListener = null;
if (mounted) _openKeyboard();
}
};
animation.addStatusListener(_routeAnimationListener!);
}
void _openKeyboard() {
if (!_focusNode.hasFocus) {
_focusNode.requestFocus();
}
SystemChannels.textInput.invokeMethod<void>('TextInput.show');
}
void _startTimer() {
_timer?.cancel();
_timerSeconds = 30;
@@ -215,7 +255,7 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
),
),
GestureDetector(
onTap: () => _focusNode.requestFocus(),
onTap: _openKeyboard,
child: FittedBox(
child: Row(
children: List.generate(6, (index) {
@@ -33,14 +33,14 @@ class _Password2FAScreenState extends State<Password2FAScreen> {
});
try {
await accountModule.checkPassword(
final result = await accountModule.checkPassword(
password: _passwordController.text,
trackId: widget.trackId,
);
if (!mounted) return;
await accountModule.login();
await accountModule.login(token: result.loginToken);
if (!mounted) return;
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart';
@@ -53,7 +55,7 @@ class _ServerSettingsSheetState extends State<ServerSettingsSheet> {
await prefs.setString(ServerConfig.prefHostKey, host);
await prefs.setInt(ServerConfig.prefPortKey, port);
await api.disconnect();
api.connect();
unawaited(api.connect());
final online = await api.stateStream
.firstWhere((s) =>
s == SessionState.online || s == SessionState.disconnected)
+9 -1
View File
@@ -31,7 +31,13 @@ class _CallsTabState extends State<CallsTab> {
}
final callsModule = CallsModule(api);
final calls = await callsModule.fetchHistory(p.id, p.id);
List<CallLogEntry> calls;
try {
calls = await callsModule.fetchHistory(p.id, p.id);
} catch (e) {
if (mounted) setState(() => _isLoading = false);
return;
}
final List<CallLogEntry> grouped = [];
for (final call in calls) {
@@ -164,6 +170,8 @@ class _CallsTabState extends State<CallsTab> {
? CachedNetworkImage(
imageUrl: call.avatarUrl!,
fit: BoxFit.cover,
memCacheWidth: 144,
memCacheHeight: 144,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (context, url, error) =>
_buildPlaceholderAvatar(cs, call.name),
File diff suppressed because it is too large Load Diff
+326 -200
View File
@@ -12,6 +12,7 @@ import '../calls/calls_tab.dart';
import '../contacts/contacts_tab.dart';
import '../profile/settings_tab.dart';
import '../../../backend/api.dart';
import '../../../core/utils/haptics.dart';
import '../../../backend/models/chat_folder.dart';
import '../../../backend/modules/account.dart';
import '../../../backend/modules/chats.dart';
@@ -73,16 +74,24 @@ class _ChatListScreenState extends State<ChatListScreen>
double _navDragBaseLeft = 0;
double _revealAnimBegin = 0.0;
double _closeAnimBegin = 0.0;
double _pullRatio = 0.0;
static const double _kStoriesPullTriggerPx = 16.0;
final _StoriesUi _storiesUi = _StoriesUi();
double get _pullRatio => _storiesUi.pullRatio;
set _pullRatio(double v) => _storiesUi.pullRatio = v;
bool get _storiesDockedOpen => _storiesUi.dockedOpen;
set _storiesDockedOpen(bool v) => _storiesUi.dockedOpen = v;
bool get _storiesOverscrollRevealArmed => _storiesUi.overscrollRevealArmed;
set _storiesOverscrollRevealArmed(bool v) =>
_storiesUi.overscrollRevealArmed = v;
bool get _shouldCollapseSearch => _storiesUi.shouldCollapseSearch;
set _shouldCollapseSearch(bool v) => _storiesUi.shouldCollapseSearch = v;
bool _navDragging = false;
bool _isFabOpen = false;
bool _showCacheWarning = false;
bool _storiesAnimClosing = false;
bool _storiesDockedOpen = false;
bool _storiesOverscrollRevealArmed = true;
bool _shouldCollapseSearch = false;
Timer? _contactRebuildTimer;
bool get _isSelectionMode => _selectedChats.isNotEmpty;
bool? _foldersListKnown;
@@ -107,7 +116,39 @@ class _ChatListScreenState extends State<ChatListScreen>
StreamSubscription? _stateSub;
StreamSubscription<LoginStatus>? _loginSub;
Widget? _cachedChatsBody;
Object? _chatsBodyCacheKey;
/// Возвращает дерево вкладки «Чаты», кэшируя его между ребилдами
/// родителя. Тап/драг навбара и FAB не трогают эти state-vars,
/// поэтому ключ остаётся прежним и subtree не пересобирается.
Widget _getChatsBody() {
final key = Object.hashAll([
identityHashCode(_chats),
identityHashCode(_folders),
_selectedFolderId,
_isInitialLoading,
_foldersListKnown,
_showCacheWarning,
_isSelectionMode,
_shouldCollapseSearch,
_selectedChats.length,
_pullRatio,
_storiesDockedOpen,
_storiesAnimClosing,
_storiesOverscrollRevealArmed,
_sessionState,
identityHashCode(_profile),
]);
if (_cachedChatsBody == null || _chatsBodyCacheKey != key) {
_chatsBodyCacheKey = key;
_cachedChatsBody = _buildChatsTabBody();
}
return _cachedChatsBody!;
}
void _toggleSelection(String chatId) {
Haptics.selection();
setState(() {
if (_selectedChats.contains(chatId)) {
_selectedChats.remove(chatId);
@@ -337,19 +378,35 @@ class _ChatListScreenState extends State<ChatListScreen>
for (final id in ids) {
messagesModule.searchContactById(id).whenComplete(() {
_inflightContactIds.remove(id);
if (mounted) setState(() {});
_scheduleContactRebuild();
});
}
}
void _scheduleContactRebuild() {
if (!mounted) return;
_contactRebuildTimer?.cancel();
_contactRebuildTimer = Timer(const Duration(milliseconds: 120), () {
if (mounted) setState(() {});
});
}
List<CachedChat> _chatsForPageIndex(int pageIndex) {
if (_folders.isEmpty) return _chats;
if (pageIndex < 0 || pageIndex >= _folders.length) return _chats;
final folder = _folders[pageIndex];
if (FoldersModule.isAllChatsFolder(folder)) return _chats;
return _chats
.where((c) => FoldersModule.chatMatchesFolder(c, folder))
.toList();
List<CachedChat> base;
if (_folders.isEmpty) {
base = _chats;
} else if (pageIndex < 0 || pageIndex >= _folders.length) {
base = _chats;
} else {
final folder = _folders[pageIndex];
base = FoldersModule.isAllChatsFolder(folder)
? _chats
: _chats.where((c) => FoldersModule.chatMatchesFolder(c, folder)).toList();
}
final pinned = base.where((c) => (c.favIndex ?? 0) > 0).toList()
..sort((a, b) => a.favIndex!.compareTo(b.favIndex!));
final regular = base.where((c) => (c.favIndex ?? 0) <= 0).toList();
return [...pinned, ...regular];
}
void _syncFolderChatScrollControllers() {
@@ -395,9 +452,8 @@ class _ChatListScreenState extends State<ChatListScreen>
if (!c.hasClients) return;
final double offset = c.offset;
if (_isSelectionMode && !_shouldCollapseSearch && offset < 132) {
setState(() {
_shouldCollapseSearch = true;
});
_shouldCollapseSearch = true;
_storiesUi.notify();
}
if (offset < 0) {
@@ -412,9 +468,8 @@ class _ChatListScreenState extends State<ChatListScreen>
_startStoriesAutoReveal(dragRatio);
} else if (!_storiesDockedOpen) {
if (dragRatio != _pullRatio) {
setState(() {
_pullRatio = dragRatio;
});
_pullRatio = dragRatio;
_storiesUi.notify();
}
}
} else {
@@ -429,14 +484,9 @@ class _ChatListScreenState extends State<ChatListScreen>
final disarm = offset > 3 && _storiesOverscrollRevealArmed;
final clearPull = _pullRatio > 0;
if (disarm || clearPull) {
setState(() {
if (disarm) {
_storiesOverscrollRevealArmed = false;
}
if (clearPull) {
_pullRatio = 0.0;
}
});
if (disarm) _storiesOverscrollRevealArmed = false;
if (clearPull) _pullRatio = 0.0;
_storiesUi.notify();
}
}
}
@@ -477,85 +527,81 @@ class _ChatListScreenState extends State<ChatListScreen>
animation: _shimmerController,
builder: (context, child) {
final opacity = 0.3 + 0.3 * sin(_shimmerController.value * pi * 2);
return Opacity(
opacity: opacity,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Expanded(
child: SizedBox(
height: 48,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 120,
height: 14,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(7),
),
),
Container(
width: double.infinity,
height: 12,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
),
],
),
),
),
],
),
),
);
return Opacity(opacity: opacity, child: child);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Expanded(
child: SizedBox(
height: 48,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 120,
height: 14,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(7),
),
),
Container(
width: double.infinity,
height: 12,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
),
],
),
),
),
],
),
),
);
}
void _onStoriesRevealTick() {
if (!mounted) return;
final t = Curves.easeOutCubic.transform(_storiesRevealController.value);
setState(() {
if (_storiesAnimClosing) {
_pullRatio = _closeAnimBegin * (1.0 - t);
} else {
_pullRatio = _revealAnimBegin + (1.0 - _revealAnimBegin) * t;
}
});
if (_storiesAnimClosing) {
_pullRatio = _closeAnimBegin * (1.0 - t);
} else {
_pullRatio = _revealAnimBegin + (1.0 - _revealAnimBegin) * t;
}
_storiesUi.notify();
}
void _onStoriesRevealStatus(AnimationStatus status) {
if (!mounted) return;
if (status == AnimationStatus.completed) {
setState(() {
if (_storiesAnimClosing) {
_pullRatio = 0.0;
_storiesDockedOpen = false;
_storiesAnimClosing = false;
_storiesOverscrollRevealArmed = true;
} else {
_pullRatio = 1.0;
_storiesDockedOpen = true;
_storiesRevealLayoutSettleUntil = DateTime.now().add(
const Duration(milliseconds: 520),
);
}
});
if (_storiesAnimClosing) {
_pullRatio = 0.0;
_storiesDockedOpen = false;
_storiesAnimClosing = false;
_storiesOverscrollRevealArmed = true;
} else {
_pullRatio = 1.0;
_storiesDockedOpen = true;
_storiesRevealLayoutSettleUntil = DateTime.now().add(
const Duration(milliseconds: 520),
);
}
_storiesUi.notify();
}
}
@@ -566,10 +612,9 @@ class _ChatListScreenState extends State<ChatListScreen>
_storiesAnimClosing = false;
final from = max(_pullRatio, suggestedFrom.clamp(0.0, 1.0));
if (from >= 1.0) {
setState(() {
_pullRatio = 1.0;
_storiesDockedOpen = true;
});
_pullRatio = 1.0;
_storiesDockedOpen = true;
_storiesUi.notify();
_storiesRevealLayoutSettleUntil = DateTime.now().add(
const Duration(milliseconds: 520),
);
@@ -597,12 +642,11 @@ class _ChatListScreenState extends State<ChatListScreen>
_storiesAnimClosing = true;
final from = _pullRatio.clamp(0.0, 1.0);
if (from <= 0) {
setState(() {
_pullRatio = 0.0;
_storiesDockedOpen = false;
_storiesAnimClosing = false;
_storiesOverscrollRevealArmed = true;
});
_pullRatio = 0.0;
_storiesDockedOpen = false;
_storiesAnimClosing = false;
_storiesOverscrollRevealArmed = true;
_storiesUi.notify();
return;
}
_closeAnimBegin = from;
@@ -618,9 +662,8 @@ class _ChatListScreenState extends State<ChatListScreen>
if (n is ScrollEndNotification) {
if (n.metrics.pixels <= 0.5) {
setState(() {
_storiesOverscrollRevealArmed = true;
});
_storiesOverscrollRevealArmed = true;
_storiesUi.notify();
}
return false;
}
@@ -665,6 +708,7 @@ class _ChatListScreenState extends State<ChatListScreen>
..removeListener(_onStoriesRevealTick)
..removeStatusListener(_onStoriesRevealStatus)
..dispose();
_shimmerController.dispose();
_folderPageController.dispose();
while (_folderChatScrollControllers.isNotEmpty) {
final c = _folderChatScrollControllers.removeLast();
@@ -672,6 +716,8 @@ class _ChatListScreenState extends State<ChatListScreen>
c.removeListener(fn);
c.dispose();
}
_contactRebuildTimer?.cancel();
_storiesUi.dispose();
super.dispose();
}
@@ -697,6 +743,8 @@ class _ChatListScreenState extends State<ChatListScreen>
if (index == _currentNavIndex && !_navPageAnimController.isAnimating) {
return;
}
// Detent "click" when crossing into a different tab.
Haptics.selection();
double fromT;
if (_navPageAnimController.isAnimating) {
final t = Curves.easeOutCubic.transform(_navPageAnimController.value);
@@ -711,6 +759,7 @@ class _ChatListScreenState extends State<ChatListScreen>
}
void _toggleFab() {
Haptics.tap();
setState(() {
_isFabOpen = !_isFabOpen;
if (_isFabOpen) {
@@ -729,20 +778,22 @@ class _ChatListScreenState extends State<ChatListScreen>
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClipRect(
clipBehavior: Clip.hardEdge,
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: _shouldCollapseSearch
? const SizedBox(width: double.infinity, height: 52)
: Column(
ListenableBuilder(
listenable: _storiesUi,
builder: (context, _) => ClipRect(
clipBehavior: Clip.hardEdge,
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: _shouldCollapseSearch
? const SizedBox(width: double.infinity, height: 52)
: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 2),
padding: const EdgeInsets.fromLTRB(20, 6, 20, 3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -888,7 +939,7 @@ class _ChatListScreenState extends State<ChatListScreen>
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 2, 20, 8),
padding: const EdgeInsets.fromLTRB(20, 3, 20, 4),
child: Container(
height: 44,
decoration: BoxDecoration(
@@ -929,13 +980,14 @@ class _ChatListScreenState extends State<ChatListScreen>
),
],
),
),
),
),
if (_folders.length > 1)
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
height: 48,
height: 34,
color: cs.surface,
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
@@ -962,7 +1014,7 @@ class _ChatListScreenState extends State<ChatListScreen>
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 4,
vertical: 2,
),
physics: const BouncingScrollPhysics(),
children: [
@@ -979,7 +1031,7 @@ class _ChatListScreenState extends State<ChatListScreen>
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 4,
vertical: 2,
),
child: Row(
children: [
@@ -1009,6 +1061,13 @@ class _ChatListScreenState extends State<ChatListScreen>
final chats = _chatsForPageIndex(pageIndex);
final sc = _folderChatScrollControllers[pageIndex];
final cs = Theme.of(context).colorScheme;
final pinnedCount = _isInitialLoading
? 0
: chats.where((c) => (c.favIndex ?? 0) > 0).length;
final hasSeparator = pinnedCount > 0 && pinnedCount < chats.length;
final totalItems = _isInitialLoading
? 10
: chats.length + (hasSeparator ? 1 : 0);
return NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification n) {
if (_currentNavIndex != 0) return false;
@@ -1033,13 +1092,14 @@ class _ChatListScreenState extends State<ChatListScreen>
parent: const AlwaysScrollableScrollPhysics(),
),
slivers: [
const SliverToBoxAdapter(child: SizedBox(height: 14)),
if (chats.isEmpty && !_isInitialLoading)
SliverFillRemaining(
child: Center(
child: Text(
'Кажется, тут пусто...',
style: TextStyle(
color: cs.onSurface.withOpacity(0.6),
color: cs.onSurface.withValues(alpha: 0.6),
fontSize: 16,
),
),
@@ -1051,7 +1111,21 @@ class _ChatListScreenState extends State<ChatListScreen>
if (_isInitialLoading) {
return _buildChatShimmer();
}
final chat = chats[index];
if (hasSeparator && index == pinnedCount) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Divider(
height: 1,
thickness: 0.5,
color: cs.outlineVariant.withValues(alpha: 0.5),
),
);
}
final chatIndex = hasSeparator && index > pinnedCount ? index - 1 : index;
final chat = chats[chatIndex];
final isPinned = (chat.favIndex ?? 0) > 0;
if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) {
final secondId = chat.participants.entries
@@ -1060,32 +1134,34 @@ class _ChatListScreenState extends State<ChatListScreen>
.key;
final name = ContactCache.get(secondId);
final avatar = ContactCache.getAvatar(secondId);
// ContactCache.isOfficial covers contacts loaded via opcode 32;
// chat.isOfficial covers contacts from the login payload.
final isVerified = ContactCache.isOfficial(secondId) || chat.isOfficial;
return _buildChatItem(
chat.id.toString(),
name ?? "Пользователь",
chat.lastMsgText?.replaceAll('\n', ' ') ?? '',
chat.lastMsgTextOneLine ?? '',
_formatTime(chat.lastMsgTime),
avatar ?? "",
isOnline: chat.isOnline,
unreadCount: chat.unreadCount,
isMuted: chat.dontDisturbUntil > 0,
isVerified: isVerified,
isPinned: isPinned,
chatType: "DIALOG",
);
} else {
final name = chat.lastMsgSenderId != null
? ContactCache.get(chat.lastMsgSenderId!)
: null;
final avatar = chat.lastMsgSenderId != null
? ContactCache.getAvatar(chat.lastMsgSenderId!)
: null;
String fullMsg = "";
if (name?.isNotEmpty == true && chat.id != 0) {
fullMsg += "$name: ";
}
if (chat.lastMsgText?.isNotEmpty == true) {
fullMsg += chat.lastMsgText ?? "";
}
@@ -1101,9 +1177,12 @@ class _ChatListScreenState extends State<ChatListScreen>
isOnline: chat.isOnline,
unreadCount: chat.unreadCount,
isMuted: chat.dontDisturbUntil > 0,
isVerified: chat.isOfficial,
isPinned: isPinned,
chatType: chat.type,
);
}
}, childCount: _isInitialLoading ? 10 : chats.length),
}, childCount: totalItems),
),
SliverPadding(
padding: EdgeInsets.only(
@@ -1282,6 +1361,7 @@ class _ChatListScreenState extends State<ChatListScreen>
});
},
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
AnimatedPositioned(
duration: _navDragging
@@ -1299,7 +1379,9 @@ class _ChatListScreenState extends State<ChatListScreen>
),
),
),
Row(
SizedBox(
width: navInnerW,
child: Row(
children: List.generate(4, (index) {
IconData icon;
String label;
@@ -1347,6 +1429,7 @@ class _ChatListScreenState extends State<ChatListScreen>
),
);
}),
),
),
],
),
@@ -1402,7 +1485,7 @@ class _ChatListScreenState extends State<ChatListScreen>
child: SizedBox(
width: pageW,
height: pageH,
child: _buildChatsTabBody(),
child: _getChatsBody(),
),
),
RepaintBoundary(
@@ -1600,7 +1683,7 @@ class _ChatListScreenState extends State<ChatListScreen>
),
child: CircleAvatar(
radius: 26,
backgroundImage: CachedNetworkImageProvider(imageUrl),
backgroundImage: CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144),
),
),
const SizedBox(height: 6),
@@ -1669,33 +1752,36 @@ class _ChatListScreenState extends State<ChatListScreen>
final isSelected = _selectedFolderId == folderId;
return GestureDetector(
onTap: () {
final i = _folders.indexWhere((f) => f.id == folderId);
if (i < 0) return;
final target = _folders.indexWhere((f) => f.id == folderId);
if (target < 0) return;
setState(() => _selectedFolderId = folderId);
if (_folderPageController.hasClients) {
final cur = _folderPageController.page?.round();
if (cur != i) {
_folderPageController.animateToPage(
i,
duration: const Duration(milliseconds: 320),
curve: Curves.easeOutCubic,
);
final cur = _folderPageController.page?.round() ?? 0;
if (cur == target) return;
if ((target - cur).abs() > 1) {
final neighbor = target > cur ? target - 1 : target + 1;
_folderPageController.jumpToPage(neighbor);
}
_folderPageController.animateToPage(
target,
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
);
}
},
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(8),
),
child: Text(
title,
textAlign: TextAlign.center,
style: TextStyle(
color: isSelected ? cs.onPrimaryContainer : cs.primary,
fontSize: 14,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
@@ -1714,6 +1800,9 @@ class _ChatListScreenState extends State<ChatListScreen>
bool isRead = false,
int unreadCount = 0,
bool isMuted = false,
bool isVerified = false,
bool isPinned = false,
String chatType = "CHAT",
}) {
final cs = Theme.of(context).colorScheme;
final isSelected = _selectedChats.contains(id);
@@ -1723,16 +1812,17 @@ class _ChatListScreenState extends State<ChatListScreen>
if (_isSelectionMode) {
_toggleSelection(id);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChatScreen(
chatId: int.parse(id),
name: name,
imageUrl: imageUrl,
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChatScreen(
chatId: int.parse(id),
name: name,
imageUrl: imageUrl,
chatType: chatType,
),
),
),
);
);
}
},
onLongPress: () => _toggleSelection(id),
@@ -1742,7 +1832,7 @@ class _ChatListScreenState extends State<ChatListScreen>
? cs.primary.withValues(alpha: 0.08)
: Colors.transparent,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@@ -1752,7 +1842,7 @@ class _ChatListScreenState extends State<ChatListScreen>
radius: 24,
backgroundColor: cs.surfaceContainerHighest,
backgroundImage: imageUrl.isNotEmpty
? CachedNetworkImageProvider(imageUrl)
? CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144)
: null,
child: imageUrl.isEmpty
? Text(
@@ -1812,16 +1902,33 @@ class _ChatListScreenState extends State<ChatListScreen>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
name,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.1,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
name,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.1,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (isVerified) ...[
const SizedBox(width: 4),
Icon(
Symbols.verified,
color: cs.primary,
size: 16,
weight: 600,
fill: 1,
),
],
],
),
),
if (isMuted) ...[
@@ -1833,6 +1940,15 @@ class _ChatListScreenState extends State<ChatListScreen>
weight: 400,
),
],
if (isPinned) ...[
const SizedBox(width: 4),
Icon(
Symbols.keep,
color: cs.outlineVariant,
size: 14,
weight: 400,
),
],
const SizedBox(width: 8),
Text(
time,
@@ -1966,13 +2082,26 @@ class _ChatListScreenState extends State<ChatListScreen>
}
Widget _buildFabMenu() {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_buildFabMenuItem(Symbols.group_add, 'Создать группу'),
const SizedBox(height: 4),
_buildFabMenuItem(Symbols.campaign, 'Создать канал'),
const SizedBox(height: 4),
_buildFabMenuItem(Symbols.person_add, 'Создать контакт'),
],
);
}
Widget _buildFabMenuItem(IconData icon, String title) {
final cs = Theme.of(context).colorScheme;
return Container(
width: 220,
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(100),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
@@ -1981,39 +2110,27 @@ class _ChatListScreenState extends State<ChatListScreen>
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildFabMenuItem(Symbols.search, 'Найти по номеру'),
_buildFabMenuItem(Symbols.group_add, 'Добавить группу'),
_buildFabMenuItem(Symbols.campaign, 'Создать канал'),
],
),
);
}
Widget _buildFabMenuItem(IconData icon, String title) {
final cs = Theme.of(context).colorScheme;
return InkWell(
onTap: () {
// Action logic here
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(icon, color: cs.onSurface, size: 22),
const SizedBox(width: 12),
Text(
title,
style: TextStyle(
color: cs.onSurface,
fontSize: 14,
fontWeight: FontWeight.w500,
child: InkWell(
onTap: () {
// Action logic here
},
borderRadius: BorderRadius.circular(100),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(icon, color: cs.onSurface, size: 22),
const SizedBox(width: 12),
Text(
title,
style: TextStyle(
color: cs.onSurface,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
],
),
),
),
);
@@ -2054,9 +2171,18 @@ class _ChatListScreenState extends State<ChatListScreen>
),
child: CircleAvatar(
radius: 12,
backgroundImage: CachedNetworkImageProvider(imageUrl),
backgroundImage: CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144),
),
),
);
}
}
class _StoriesUi extends ChangeNotifier {
double pullRatio = 0.0;
bool dockedOpen = false;
bool overscrollRevealArmed = true;
bool shouldCollapseSearch = false;
void notify() => notifyListeners();
}
+639 -169
View File
@@ -1,26 +1,44 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:komet/backend/modules/chats.dart';
import 'package:komet/frontend/screens/chats/chat_info_screen.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../main.dart';
import '../../../backend/api.dart';
import '../../../backend/modules/messages.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/utils/haptics.dart';
import '../../../core/config/app_cache_extent.dart';
import '../../../models/attachment.dart';
import '../../../backend/modules/messages.dart' show ContactCache;
import '../../widgets/message_bubble.dart';
import '../../widgets/attachment_panel.dart';
class _DateSeparatorItem {
final DateTime date;
final GlobalKey key;
_DateSeparatorItem(this.date, this.key);
}
class _MessageItem {
final CachedMessage message;
final int index;
const _MessageItem(this.message, this.index);
}
class ChatScreen extends StatefulWidget {
final int chatId;
final String name;
final String imageUrl;
final String chatType;
const ChatScreen({
super.key,
required this.chatId,
required this.name,
required this.imageUrl,
required this.chatType,
});
@override
@@ -28,25 +46,45 @@ class ChatScreen extends StatefulWidget {
}
class _ChatScreenState extends State<ChatScreen>
with SingleTickerProviderStateMixin {
with TickerProviderStateMixin {
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
bool _hasText = false;
final GlobalKey _listKey = GlobalKey();
final ValueNotifier<bool> _hasText = ValueNotifier(false);
bool _isLoading = true;
bool _isSending = false;
final ValueNotifier<bool> _showAttachmentPanel = ValueNotifier(false);
late AnimationController _shimmerController;
List<CachedMessage> _messages = [];
int _myId = 0;
CachedChat? chat;
final ValueNotifier<DateTime?> _floatingDate = ValueNotifier(null);
Timer? _floatingDateTimer;
late final AnimationController _floatingDateAnimController;
late final CurvedAnimation _floatingDateCurved;
final Map<int, GlobalKey> _separatorKeys = {};
double _lastScrollOffset = 0;
String? _lastSentId;
@override
void initState() {
super.initState();
_messageController.addListener(_onTextChanged);
_scrollController.addListener(_onScrollForDate);
_shimmerController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat();
_floatingDateAnimController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
reverseDuration: const Duration(milliseconds: 380),
);
_floatingDateCurved = CurvedAnimation(
parent: _floatingDateAnimController,
curve: Curves.easeOut,
reverseCurve: Curves.easeIn,
);
_loadHistory();
}
@@ -55,10 +93,10 @@ class _ChatScreenState extends State<ChatScreen>
final activeProfile = await AppDatabase.loadActiveProfile();
_myId = activeProfile?.id ?? 0;
ChatsModule.getChat(_myId, widget.chatId).then((value) {
chat = value[0];
}).catchError((error) {
});
if (mounted && value.isNotEmpty) {
setState(() { chat = value.first; });
}
}).catchError((_) {});
final cachedRows = await AppDatabase.loadMessages(
_myId,
@@ -105,6 +143,13 @@ class _ChatScreenState extends State<ChatScreen>
@override
void dispose() {
_messageController.removeListener(_onTextChanged);
_scrollController.removeListener(_onScrollForDate);
_floatingDateTimer?.cancel();
_floatingDateCurved.dispose();
_floatingDateAnimController.dispose();
_floatingDate.dispose();
_hasText.dispose();
_showAttachmentPanel.dispose();
_messageController.dispose();
_scrollController.dispose();
_shimmerController.dispose();
@@ -112,25 +157,35 @@ class _ChatScreenState extends State<ChatScreen>
}
void _onTextChanged() {
final bool newHasText = _messageController.text.trim().isNotEmpty;
if (newHasText != _hasText) {
setState(() {
_hasText = newHasText;
});
final newHasText = _messageController.text.trim().isNotEmpty;
if (newHasText != _hasText.value) {
_hasText.value = newHasText;
}
}
String? _effectiveStatus(CachedMessage msg) {
if (msg.senderId != _myId) return null;
if (msg.status == 'sending' || msg.status == 'error') return msg.status;
final c = chat;
if (c == null) return 'sent';
int otherReadTime = 0;
for (final entry in c.participants.entries) {
if (entry.key != _myId && entry.value > otherReadTime) {
otherReadTime = entry.value;
}
}
if (otherReadTime > 0 && otherReadTime >= msg.time) return 'read';
return 'sent';
}
Future<void> _sendMessage() async {
final text = _messageController.text.trim();
if (text.isEmpty || _myId == 0) return;
setState(() {
_isSending = true;
});
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
final now = DateTime.now().millisecondsSinceEpoch;
try {
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
final now = DateTime.now().millisecondsSinceEpoch;
final tempMessage = CachedMessage(
id: tempId,
@@ -142,21 +197,26 @@ class _ChatScreenState extends State<ChatScreen>
status: 'sending',
);
_hasText.value = false;
setState(() {
_lastSentId = tempId;
_messages.add(tempMessage);
_messageController.clear();
_hasText = false;
});
// Instant tactile "whoosh" the moment the message leaves the composer,
// not after the network round-trip — feedback must feel immediate.
Haptics.send();
_scrollToBottom();
await messagesModule.sendMessage(_myId, widget.chatId, text);
final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text);
final index = _messages.indexWhere((m) => m.id == tempId);
if (index != -1) {
if (index != -1 && mounted) {
setState(() {
_messages[index] = CachedMessage(
id: tempId,
id: actualId.isNotEmpty ? actualId : tempId,
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
@@ -167,11 +227,21 @@ class _ChatScreenState extends State<ChatScreen>
});
}
} catch (e) {
debugPrint('Error sending message: $e');
} finally {
setState(() {
_isSending = false;
});
Haptics.error();
final index = _messages.indexWhere((m) => m.id == tempId);
if (index != -1 && mounted) {
setState(() {
_messages[index] = CachedMessage(
id: tempId,
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
text: text,
time: now,
status: 'error',
);
});
}
}
}
@@ -246,88 +316,272 @@ class _ChatScreenState extends State<ChatScreen>
});
}
List<Object> _buildCombinedItems() {
final List<Object> items = [];
final Set<int> usedDates = {};
for (int i = 0; i < _messages.length; i++) {
final msg = _messages[i];
final msgDate = DateTime.fromMillisecondsSinceEpoch(msg.time);
final dayMillis = DateTime(msgDate.year, msgDate.month, msgDate.day)
.millisecondsSinceEpoch;
bool needSeparator = i == 0;
if (!needSeparator) {
final prevDate =
DateTime.fromMillisecondsSinceEpoch(_messages[i - 1].time);
final prevDayMillis =
DateTime(prevDate.year, prevDate.month, prevDate.day)
.millisecondsSinceEpoch;
needSeparator = dayMillis != prevDayMillis;
}
if (needSeparator) {
_separatorKeys.putIfAbsent(dayMillis, () => GlobalKey());
usedDates.add(dayMillis);
items.add(_DateSeparatorItem(
DateTime.fromMillisecondsSinceEpoch(dayMillis),
_separatorKeys[dayMillis]!,
));
}
items.add(_MessageItem(msg, i));
}
_separatorKeys.removeWhere((k, _) => !usedDates.contains(k));
return items;
}
void _onScrollForDate() {
if (!_scrollController.hasClients) return;
final currentOffset = _scrollController.position.pixels;
final scrollingUp = currentOffset > _lastScrollOffset;
_lastScrollOffset = currentOffset;
_floatingDateTimer?.cancel();
if (!scrollingUp) {
_floatingDateAnimController.reverse();
return;
}
_floatingDateTimer = Timer(const Duration(seconds: 2), () {
if (mounted) _floatingDateAnimController.reverse();
});
WidgetsBinding.instance.addPostFrameCallback((_) => _updateFloatingDate());
}
void _updateFloatingDate() {
if (!mounted) return;
DateTime? result;
final listRenderBox = _listKey.currentContext?.findRenderObject();
if (listRenderBox is! RenderBox) return;
_separatorKeys.forEach((dayMillis, gkey) {
final ctx = gkey.currentContext;
if (ctx == null) return;
final box = ctx.findRenderObject();
if (box is! RenderBox) return;
final pos = box.localToGlobal(Offset.zero, ancestor: listRenderBox);
if (pos.dy + box.size.height < 4) {
final date = DateTime.fromMillisecondsSinceEpoch(dayMillis);
if (result == null || date.isAfter(result!)) {
result = date;
}
}
});
if (result == null) return;
final bool dateChanged = result != _floatingDate.value;
_floatingDate.value = result;
if (dateChanged) {
_floatingDateAnimController.forward(from: 0);
} else {
_floatingDateAnimController.forward();
}
}
String _formatDateLabel(DateTime date) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final yesterday = today.subtract(const Duration(days: 1));
final d = DateTime(date.year, date.month, date.day);
if (d == today) return 'Сегодня';
if (d == yesterday) return 'Вчера';
const months = [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря',
];
if (date.year == now.year) {
return '${date.day} ${months[date.month - 1]}';
}
return '${date.day} ${months[date.month - 1]} ${date.year}';
}
Widget _buildDateSeparatorWidget(BuildContext context, DateTime date,
{Key? key, bool floating = false}) {
final cs = Theme.of(context).colorScheme;
return Padding(
key: key,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Text(
_formatDateLabel(date),
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
fontStyle: floating ? FontStyle.normal : FontStyle.italic,
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
// TODO: Локализация
// TODO: Cклонения
String? status = chat?.type == "CHAT" ? "${chat?.participants.length.toString()} участников" : "last seen recently";
final String status = chat?.type == "CHAT" ? "${chat?.participants.length ?? 0} участников" : "last seen recently";
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBar(
backgroundColor: cs.surfaceContainerHigh,
foregroundColor: cs.onSurface,
elevation: 0,
surfaceTintColor: Colors.transparent,
iconTheme: IconThemeData(color: cs.onSurface),
leading: IconButton(
icon: const Icon(Symbols.arrow_back, weight: 400),
onPressed: () => Navigator.pop(context),
),
titleSpacing: 0,
title: Row(
children: [
if (widget.imageUrl.isNotEmpty)
CircleAvatar(
radius: 18,
backgroundImage: CachedNetworkImageProvider(widget.imageUrl),
)
else
CircleAvatar(
radius: 18,
backgroundColor: cs.primaryContainer,
child: Text(
widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?',
style: TextStyle(color: cs.onPrimaryContainer, fontSize: 12),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.name,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
),
),
Text(
status ?? "",
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
],
),
appBar: PreferredSize(
preferredSize: Size.fromHeight(kToolbarHeight),
child: InkWell(
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => ChatInfoScreen(
chatId: widget.chatId,
name: widget.name,
imageUrl: widget.imageUrl,
chatType: widget.chatType)
)
),
child: AppBar(
backgroundColor: cs.surfaceContainerHigh,
foregroundColor: cs.onSurface,
elevation: 0,
surfaceTintColor: Colors.transparent,
iconTheme: IconThemeData(color: cs.onSurface),
leading: IconButton(
icon: const Icon(Symbols.arrow_back, weight: 400),
onPressed: () => Navigator.pop(context),
),
],
),
actions: [
IconButton(
icon: const Icon(Symbols.call, weight: 400),
onPressed: () {},
titleSpacing: 0,
title: Row(
children: [
if (widget.imageUrl.isNotEmpty)
CircleAvatar(
radius: 18,
backgroundImage: CachedNetworkImageProvider(widget.imageUrl, maxWidth: 144, maxHeight: 144),
)
else
CircleAvatar(
radius: 18,
backgroundColor: cs.primaryContainer,
child: Text(
widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?',
style: TextStyle(color: cs.onPrimaryContainer, fontSize: 12),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
widget.name,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (chat?.isOfficial ?? false) ...[
const SizedBox(width: 4),
Icon(
Symbols.verified,
color: cs.primary,
size: 16,
weight: 600,
fill: 1,
),
],
],
),
Text(
status,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
],
),
),
],
),
actions: [
IconButton(
icon: const Icon(Symbols.call, weight: 400),
onPressed: () {},
),
IconButton(
icon: const Icon(Symbols.more_vert, weight: 400),
onPressed: () {},
),
],
),
IconButton(
icon: const Icon(Symbols.more_vert, weight: 400),
onPressed: () {},
),
],
),
body: Column(
)),
body: Stack(
children: [
Expanded(
child: _isLoading && _messages.isEmpty
? _buildShimmerLoading()
: _buildMessagesList(),
Column(
children: [
Expanded(
child: _isLoading && _messages.isEmpty
? _buildShimmerLoading()
: _buildMessagesList(),
),
_buildInputArea(context),
],
),
ValueListenableBuilder<bool>(
valueListenable: _showAttachmentPanel,
builder: (context, open, _) {
if (!open) return const SizedBox.shrink();
return Positioned(
left: 0,
right: 0,
bottom: 0,
child: AttachmentPanel(
chatId: widget.chatId,
onClose: () => _showAttachmentPanel.value = false,
),
);
},
),
_buildInputArea(context),
],
),
);
@@ -345,30 +599,88 @@ class _ChatScreenState extends State<ChatScreen>
);
}
return ListView.builder(
controller: _scrollController,
reverse: true,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _messages.length,
itemBuilder: (context, index) {
final message = _messages[_messages.length - 1 - index];
final isMe = message.senderId == _myId;
final prevMessage = index < _messages.length - 1
? _messages[_messages.length - 2 - index]
: null;
final nextMessage = index > 0
? _messages[_messages.length - index]
: null;
final items = _buildCombinedItems();
return MessageBubble(
message: message,
isMe: isMe,
myId: _myId,
prevMessage: prevMessage,
nextMessage: nextMessage,
chatType: chat!.type,
);
},
return Stack(
key: _listKey,
children: [
ValueListenableBuilder<double>(
valueListenable: AppCacheExtent.current,
builder: (context, cacheExtent, _) => ListView.builder(
controller: _scrollController,
reverse: true,
padding: const EdgeInsets.symmetric(vertical: 8),
cacheExtent: cacheExtent,
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[items.length - 1 - index];
if (item is _DateSeparatorItem) {
return _buildDateSeparatorWidget(context, item.date,
key: item.key);
}
final msgItem = item as _MessageItem;
final message = msgItem.message;
final msgIndex = msgItem.index;
final isMe = message.senderId == _myId;
final prevMessage =
msgIndex > 0 ? _messages[msgIndex - 1] : null;
final nextMessage = msgIndex < _messages.length - 1
? _messages[msgIndex + 1]
: null;
final bubble = MessageBubble(
message: message,
isMe: isMe,
myId: _myId,
prevMessage: prevMessage,
nextMessage: nextMessage,
chatType: chat?.type ?? 'CHAT',
overrideStatus: _effectiveStatus(message),
);
if (isMe && message.id == _lastSentId) {
return _SentMessageAnimation(
key: ValueKey('anim_${message.id}'),
onComplete: () {
if (mounted) setState(() => _lastSentId = null);
},
child: bubble,
);
}
return bubble;
},
),
),
Positioned(
top: 8,
left: 0,
right: 0,
child: IgnorePointer(
child: ValueListenableBuilder<DateTime?>(
valueListenable: _floatingDate,
builder: (context, date, _) {
if (date == null) return const SizedBox.shrink();
return AnimatedBuilder(
animation: _floatingDateCurved,
builder: (context, child) {
final t = _floatingDateCurved.value;
return Opacity(
opacity: t,
child: Transform.scale(
scale: 0.82 + 0.18 * t,
child: child,
),
);
},
child: _buildDateSeparatorWidget(context, date, floating: true),
);
},
),
),
),
],
);
}
@@ -470,6 +782,43 @@ class _ChatScreenState extends State<ChatScreen>
Widget _buildInputArea(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final mutedIcon = cs.onSurfaceVariant.withValues(alpha: 0.85);
if (widget.chatType == "CHANNEL") {
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
child: GestureDetector(
onTap: () {},
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: Color.alphaBlend(
cs.surfaceContainerHighest.withValues(alpha: 0.92),
cs.surface,
),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: cs.outlineVariant.withValues(alpha: 0.5),
width: 0.5,
),
),
child: Center(
child: Text(
'Отключить уведомления',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
),
),
),
);
}
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
@@ -501,65 +850,66 @@ class _ChatScreenState extends State<ChatScreen>
Icon(Symbols.face, color: mutedIcon, size: 24, weight: 400),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _messageController,
style: TextStyle(color: cs.onSurface, fontSize: 16),
maxLines: null,
keyboardType: TextInputType.multiline,
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
hintText: 'Message',
hintStyle: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 16,
),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
vertical: 14,
child: Focus(
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.enter &&
!HardwareKeyboard.instance.isShiftPressed) {
if (_hasText.value) _sendMessage();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: TextField(
controller: _messageController,
style: TextStyle(color: cs.onSurface, fontSize: 16),
maxLines: null,
keyboardType: TextInputType.multiline,
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
hintText: 'Message',
hintStyle: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 16,
),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
vertical: 14,
),
),
),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: _hasText ? 0 : 36,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _hasText ? 0 : 1,
child: _hasText
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.only(left: 12),
child: Icon(
Symbols.attachment,
color: mutedIcon,
size: 24,
weight: 400,
),
),
),
_AttachButton(
hasText: _hasText,
panelOpen: _showAttachmentPanel,
mutedIcon: mutedIcon,
cs: cs,
),
],
),
),
),
const SizedBox(width: 8),
Container(
width: 54,
height: 54,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _hasText ? cs.primary : cs.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: GestureDetector(
onTap: _hasText ? _sendMessage : null,
child: Icon(
_hasText ? Symbols.send : Symbols.mic,
color: _hasText ? cs.onPrimary : cs.onSurface,
size: 24,
weight: 400,
ValueListenableBuilder<bool>(
valueListenable: _hasText,
builder: (context, hasText, _) => Container(
width: 54,
height: 54,
alignment: Alignment.center,
decoration: BoxDecoration(
color: hasText ? cs.primary : cs.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: GestureDetector(
onTap: hasText ? _sendMessage : null,
child: Icon(
hasText ? Symbols.send : Symbols.mic,
color: hasText ? cs.onPrimary : cs.onSurface,
size: 24,
weight: 400,
),
),
),
),
@@ -569,3 +919,123 @@ class _ChatScreenState extends State<ChatScreen>
);
}
}
class _AttachButton extends StatelessWidget {
final ValueNotifier<bool> hasText;
final ValueNotifier<bool> panelOpen;
final Color mutedIcon;
final ColorScheme cs;
const _AttachButton({
required this.hasText,
required this.panelOpen,
required this.mutedIcon,
required this.cs,
});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: hasText,
builder: (context, isText, _) {
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: isText ? 0 : 36,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: isText ? 0 : 1,
child: isText
? const SizedBox.shrink()
: ValueListenableBuilder<bool>(
valueListenable: panelOpen,
builder: (context, open, _) => GestureDetector(
onTap: open ? null : () => panelOpen.value = true,
child: Padding(
padding: const EdgeInsets.only(left: 12),
child: Stack(
alignment: Alignment.center,
children: [
if (open)
SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.primary,
),
),
Icon(
Symbols.attachment,
color: open
? cs.onSurfaceVariant.withValues(alpha: 0.3)
: mutedIcon,
size: 24,
weight: 400,
),
],
),
),
),
),
),
);
},
);
}
}
class _SentMessageAnimation extends StatefulWidget {
final Widget child;
final VoidCallback onComplete;
const _SentMessageAnimation({
super.key,
required this.child,
required this.onComplete,
});
@override
State<_SentMessageAnimation> createState() => _SentMessageAnimationState();
}
class _SentMessageAnimationState extends State<_SentMessageAnimation>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<double> _opacity;
late final Animation<double> _slide;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
);
_opacity = CurvedAnimation(parent: _ctrl, curve: Curves.easeOut);
_slide = Tween<double>(begin: 16, end: 0).animate(
CurvedAnimation(parent: _ctrl, curve: Curves.easeOut),
);
_ctrl.forward().whenComplete(widget.onComplete);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _ctrl,
builder: (context, child) => Opacity(
opacity: _opacity.value,
child: Transform.translate(
offset: Offset(0, _slide.value),
child: child,
),
),
child: widget.child,
);
}
}
@@ -88,6 +88,8 @@ class _ContactsTabState extends State<ContactsTab> {
? CachedNetworkImage(
imageUrl: contact.baseUrl!,
fit: BoxFit.cover,
memCacheWidth: 144,
memCacheHeight: 144,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (context, url, error) =>
_buildPlaceholderAvatar(cs, nameToDisplay),
@@ -100,15 +102,32 @@ class _ContactsTabState extends State<ContactsTab> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nameToDisplay,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
nameToDisplay,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (contact.isVerified) ...[
const SizedBox(width: 4),
Icon(
Symbols.verified,
color: cs.primary,
size: 16,
weight: 600,
fill: 1,
),
],
],
),
const SizedBox(height: 4),
Text(
@@ -0,0 +1,526 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/config/app_bubble_shape.dart';
import '../../../core/utils/haptics.dart';
import '../../../main.dart';
class AppearanceScreen extends StatefulWidget {
const AppearanceScreen({super.key});
@override
State<AppearanceScreen> createState() => _AppearanceScreenState();
}
class _AppearanceScreenState extends State<AppearanceScreen> {
static const _fallback = Color(0xFFC1C4FF);
final ValueNotifier<Color> _color = ValueNotifier(_fallback);
final ValueNotifier<bool> _isSystem = ValueNotifier(false);
bool _initialized = false;
bool _accentExpanded = false;
Timer? _debounce;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_initialized) {
_initialized = true;
final seed = KometApp.stateOf(context)?.accentSeed.value;
_isSystem.value = seed == null;
_color.value = seed ?? _fallback;
}
}
@override
void dispose() {
_debounce?.cancel();
_color.dispose();
_isSystem.dispose();
super.dispose();
}
void _onColorChanged(Color color) {
_color.value = color;
_isSystem.value = false;
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 350), () {
if (mounted) KometApp.stateOf(context)?.applyAccentColor(color);
});
}
void _resetToSystem() {
Haptics.selection();
_debounce?.cancel();
_isSystem.value = true;
_color.value = _fallback;
KometApp.stateOf(context)?.applyAccentColor(null);
}
void _toggleAccentExpanded() {
Haptics.tap();
setState(() => _accentExpanded = !_accentExpanded);
}
void _onStyleChanged(BubbleStyle style) {
Haptics.selection();
AppBubbleShape.save(style);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBarM3E(
titleText: 'Внешний вид',
backgroundColor: cs.surface,
),
body: SafeArea(
top: false,
child: ListView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 120),
children: [
_PreviewSection(color: _color, isSystem: _isSystem),
const SizedBox(height: 16),
_ColorPickerCard(
color: _color,
isSystem: _isSystem,
expanded: _accentExpanded,
onToggle: _toggleAccentExpanded,
onColorChanged: _onColorChanged,
onReset: _resetToSystem,
),
const SizedBox(height: 12),
_BubbleShapeCard(onChanged: _onStyleChanged),
],
),
),
);
}
}
class _PreviewSection extends StatefulWidget {
final ValueNotifier<Color> color;
final ValueNotifier<bool> isSystem;
const _PreviewSection({required this.color, required this.isSystem});
@override
State<_PreviewSection> createState() => _PreviewSectionState();
}
class _PreviewSectionState extends State<_PreviewSection> {
ColorScheme? _cachedScheme;
Color? _cachedColor;
Brightness? _cachedBrightness;
ColorScheme _schemeFor(Color color, Brightness brightness) {
if (_cachedScheme != null &&
_cachedColor == color &&
_cachedBrightness == brightness) {
return _cachedScheme!;
}
_cachedColor = color;
_cachedBrightness = brightness;
_cachedScheme = ColorScheme.fromSeed(
seedColor: color,
brightness: brightness,
);
return _cachedScheme!;
}
@override
Widget build(BuildContext context) {
final outerCs = Theme.of(context).colorScheme;
final brightness = Theme.of(context).brightness;
return ValueListenableBuilder<bool>(
valueListenable: widget.isSystem,
builder: (context, isSystem, _) {
if (isSystem) {
return Theme(
data: Theme.of(context).copyWith(colorScheme: outerCs),
child: const _ChatPreview(),
);
}
return ValueListenableBuilder<Color>(
valueListenable: widget.color,
builder: (context, color, _) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: _schemeFor(color, brightness),
),
child: const _ChatPreview(),
);
},
);
},
);
}
}
class _ChatPreview extends StatelessWidget {
const _ChatPreview();
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return ValueListenableBuilder<BubbleStyle>(
valueListenable: AppBubbleShape.current,
builder: (context, style, _) => Container(
decoration: BoxDecoration(
color: cs.surfaceContainerLow,
borderRadius: BorderRadius.circular(28),
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PreviewBubble(text: 'Как тебе?', isMe: true, style: style),
const SizedBox(height: 6),
_PreviewBubble(text: 'отлично выглядит!', isMe: false, style: style),
],
),
),
);
}
}
class _PreviewBubble extends StatelessWidget {
final String text;
final bool isMe;
final BubbleStyle style;
const _PreviewBubble({
required this.text,
required this.isMe,
required this.style,
});
BorderRadius get _radius {
const big = Radius.circular(20);
const small = Radius.circular(4);
final outside = style == BubbleStyle.mobile ? big : small;
return BorderRadius.only(
topLeft: isMe ? outside : big,
topRight: isMe ? big : outside,
bottomLeft: isMe ? outside : big,
bottomRight: isMe ? big : outside,
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final bg = isMe ? cs.primaryContainer : cs.surfaceContainerHighest;
final fg = Theme.of(context).brightness == Brightness.dark
? Colors.white
: Colors.black;
return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
constraints: const BoxConstraints(maxWidth: 220),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(color: bg, borderRadius: _radius),
child: Text(
text,
style: TextStyle(color: fg, fontSize: 15, height: 1.3),
),
),
);
}
}
class _ColorPickerCard extends StatelessWidget {
final ValueNotifier<Color> color;
final ValueNotifier<bool> isSystem;
final bool expanded;
final VoidCallback onToggle;
final ValueChanged<Color> onColorChanged;
final VoidCallback onReset;
const _ColorPickerCard({
required this.color,
required this.isSystem,
required this.expanded,
required this.onToggle,
required this.onColorChanged,
required this.onReset,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return ValueListenableBuilder<bool>(
valueListenable: isSystem,
builder: (context, sys, _) {
return ValueListenableBuilder<Color>(
valueListenable: color,
builder: (context, col, _) => _buildBody(cs, col, sys),
);
},
);
}
Widget _buildBody(ColorScheme cs, Color col, bool sys) {
final swatchColor = sys ? cs.primary : col;
return Material(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(28),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
InkWell(
onTap: onToggle,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 16, 18),
child: Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: swatchColor,
shape: BoxShape.circle,
border: Border.all(
color: cs.outlineVariant.withValues(alpha: 0.5),
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Акцентный цвет',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
sys
? 'Системный'
: 'Основной цвет интерфейса и пузырей',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
],
),
),
AnimatedRotation(
duration: const Duration(milliseconds: 200),
turns: expanded ? 0.5 : 0,
child: Icon(
Symbols.expand_more,
color: cs.onSurfaceVariant,
size: 24,
),
),
],
),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: expanded
? Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_HueStripPicker(
color: col,
onChanged: onColorChanged,
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: FilledButton.tonal(
onPressed: sys ? null : onReset,
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.auto_awesome, size: 18, weight: 500),
const SizedBox(width: 8),
Text(sys
? 'Системный цвет активен'
: 'Сбросить на системный'),
],
),
),
),
],
),
)
: const SizedBox(width: double.infinity),
),
],
),
);
}
}
class _BubbleShapeCard extends StatelessWidget {
final ValueChanged<BubbleStyle> onChanged;
const _BubbleShapeCard({required this.onChanged});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Форма сообщения',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'Скругление углов пузырей',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
const SizedBox(height: 16),
ValueListenableBuilder<BubbleStyle>(
valueListenable: AppBubbleShape.current,
builder: (context, current, _) {
return SegmentedButton<BubbleStyle>(
segments: const [
ButtonSegment(
value: BubbleStyle.mobile,
label: Text('TG Mobile'),
icon: Icon(Symbols.smartphone),
),
ButtonSegment(
value: BubbleStyle.desktop,
label: Text('TG Desktop'),
icon: Icon(Symbols.desktop_windows),
),
],
selected: {current},
onSelectionChanged: (set) {
if (set.isNotEmpty) onChanged(set.first);
},
);
},
),
],
),
),
);
}
}
class _HueStripPicker extends StatelessWidget {
final Color color;
final ValueChanged<Color> onChanged;
const _HueStripPicker({required this.color, required this.onChanged});
static const _gradient = LinearGradient(
colors: [
Color(0xFFFF0000),
Color(0xFFFFFF00),
Color(0xFF00FF00),
Color(0xFF00FFFF),
Color(0xFF0000FF),
Color(0xFFFF00FF),
Color(0xFFFF0000),
],
);
@override
Widget build(BuildContext context) {
final hue = HSVColor.fromColor(color).hue;
const trackHeight = 26.0;
const thumbDiameter = 30.0;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
void emit(double dx) {
final clamped = dx.clamp(0.0, width);
final newHue = (clamped / width) * 360;
onChanged(HSVColor.fromAHSV(1, newHue, 1, 1).toColor());
}
final thumbLeft = (hue / 360) * width - thumbDiameter / 2;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onPanDown: (d) => emit(d.localPosition.dx),
onPanUpdate: (d) => emit(d.localPosition.dx),
child: SizedBox(
height: thumbDiameter + 4,
child: Stack(
children: [
Center(
child: Container(
height: trackHeight,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(trackHeight / 2),
gradient: _gradient,
),
),
),
Positioned(
left: thumbLeft.clamp(0, width - thumbDiameter),
top: (thumbDiameter + 4 - thumbDiameter) / 2,
child: Container(
width: thumbDiameter,
height: thumbDiameter,
decoration: BoxDecoration(
color: HSVColor.fromAHSV(1, hue, 1, 1).toColor(),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
),
),
),
],
),
),
);
},
);
}
}
@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/utils/haptics.dart';
import 'appearance_screen.dart';
import 'font_settings_screen.dart';
class _CustomizationCategory {
final IconData icon;
final String title;
final String subtitle;
final WidgetBuilder builder;
const _CustomizationCategory({
required this.icon,
required this.title,
required this.subtitle,
required this.builder,
});
}
class CustomizationScreen extends StatelessWidget {
const CustomizationScreen({super.key});
static const List<_CustomizationCategory> _categories = [
_CustomizationCategory(
icon: Symbols.palette,
title: 'Внешний вид',
subtitle: 'Акцентный цвет интерфейса',
builder: _buildAppearance,
),
_CustomizationCategory(
icon: Symbols.text_fields,
title: 'Шрифты',
subtitle: 'Шрифт приложения, свои шрифты, размер текста',
builder: _buildFontSettings,
),
];
static Widget _buildAppearance(BuildContext context) =>
const AppearanceScreen();
static Widget _buildFontSettings(BuildContext context) =>
const FontSettingsScreen();
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBarM3E(
titleText: 'Кастомизация',
backgroundColor: cs.surface,
),
body: SafeArea(
top: false,
child: ListView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 120),
children: [
for (final category in _categories) ...[
_CategoryCard(
category: category,
onTap: () {
Haptics.tap();
Navigator.push(
context,
MaterialPageRoute(builder: category.builder),
);
},
),
const SizedBox(height: 12),
],
],
),
),
);
}
}
class _CategoryCard extends StatelessWidget {
final _CustomizationCategory category;
final VoidCallback onTap;
const _CategoryCard({required this.category, required this.onTap});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(28),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 18),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: cs.primaryContainer,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Icon(
category.icon,
color: cs.onPrimaryContainer,
size: 24,
weight: 500,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
category.title,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 3),
Text(
category.subtitle,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
height: 1.3,
),
),
],
),
),
const SizedBox(width: 12),
Icon(Symbols.chevron_right, color: cs.outline, size: 22),
],
),
),
),
);
}
}
@@ -1,11 +1,55 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/chats.dart';
import '../../../core/utils/logger.dart';
import '../../../main.dart';
class DebugMenuScreen extends StatelessWidget {
class DebugMenuScreen extends StatefulWidget {
const DebugMenuScreen({super.key});
@override
State<DebugMenuScreen> createState() => _DebugMenuScreenState();
}
class _DebugMenuScreenState extends State<DebugMenuScreen> {
final _idController = TextEditingController();
String? _searchResult;
bool _isSearching = false;
@override
void dispose() {
_idController.dispose();
super.dispose();
}
Future<void> _search() async {
final id = int.tryParse(_idController.text);
if (id == null) return;
setState(() {
_isSearching = true;
_searchResult = null;
});
try {
final result = await ChatsModule.searchById(api, id);
logger.i('searchById result: $result');
if (!mounted) return;
if (result is Map && result.containsKey('error')) {
final errorMsg = result['localizedMessage'] ?? result['message'] ?? result['error'] ?? 'Error';
setState(() => _searchResult = 'Error: $errorMsg');
} else if (result is Map) {
setState(() => _searchResult = result.toString());
} else {
setState(() => _searchResult = result?.toString() ?? 'null');
}
} catch (e) {
if (mounted) {
setState(() => _searchResult = 'Exception: $e');
}
} finally {
if (mounted) setState(() => _isSearching = false);
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
@@ -115,10 +159,159 @@ class DebugMenuScreen extends StatelessWidget {
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: appState == null
? const SizedBox.shrink()
: ValueListenableBuilder<bool>(
valueListenable: appState.vpnBypassEnabled,
builder: (context, bypassOn, _) {
return Container(
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 17,
),
child: Row(
children: [
Icon(
Symbols.vpn_key_off,
color: cs.onSurfaceVariant,
size: 22,
weight: 400,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'Обход VPN',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
'Если обнаружен VPN (tun-интерфейс), '
'подключаться напрямую через Wi-Fi или '
'моб. сеть в обход туннеля. Только Android',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
],
),
),
Switch(
value: bypassOn,
onChanged: (v) {
appState.setVpnBypassEnabled(v);
},
),
],
),
),
);
},
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Container(
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Поиск по ID (opcode 60)',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _idController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
hintText: 'Введите user ID',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onSubmitted: (_) => _search(),
),
),
const SizedBox(width: 12),
FilledButton(
onPressed: _isSearching ? null : _search,
child: _isSearching
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Symbols.search, size: 20),
),
],
),
if (_searchResult != null) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
constraints: const BoxConstraints(maxHeight: 400),
child: SingleChildScrollView(
child: Text(
_searchResult!,
style: TextStyle(
color: cs.onSurface,
fontSize: 12,
fontFamily: 'monospace',
),
),
),
),
],
],
),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 120)),
],
),
),
);
}
}
}
@@ -271,8 +271,9 @@ class _DevicesScreenState extends State<DevicesScreen>
setState(() => _loadingIps.add(id));
}
HttpClient? client;
try {
final client = HttpClient();
client = HttpClient();
client.connectionTimeout = const Duration(seconds: 5);
final request = await client.getUrl(
Uri.parse(
@@ -296,6 +297,8 @@ class _DevicesScreenState extends State<DevicesScreen>
setState(() => _loadingIps.remove(id));
showCustomNotification(context, 'Ошибка IP: $e');
}
} finally {
client?.close();
}
}
@@ -0,0 +1,253 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/storage/app_database.dart';
import '../../../l10n/app_localizations.dart';
import '../../../main.dart' show accountModule, KometApp;
import '../../widgets/custom_notification.dart';
class EditProfileScreen extends StatefulWidget {
const EditProfileScreen({super.key});
@override
State<EditProfileScreen> createState() => _EditProfileScreenState();
}
class _EditProfileScreenState extends State<EditProfileScreen> {
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
bool _isLoading = true;
bool _isSaving = false;
String? _avatarUrl;
int? _photoId;
@override
void initState() {
super.initState();
_loadProfile();
}
@override
void dispose() {
_firstNameController.dispose();
_lastNameController.dispose();
super.dispose();
}
Future<void> _loadProfile() async {
final profile = await AppDatabase.loadActiveProfile();
if (!mounted) return;
if (profile != null) {
_firstNameController.text = profile.firstName;
_lastNameController.text = profile.lastName ?? '';
_avatarUrl = profile.baseUrl;
_photoId = profile.photoId;
setState(() => _isLoading = false);
} else {
setState(() => _isLoading = false);
}
}
Future<void> _saveName() async {
if (_isSaving) return;
final firstName = _firstNameController.text.trim();
if (firstName.isEmpty) {
if (mounted) showCustomNotification(context, 'Имя не может быть пустым');
return;
}
setState(() => _isSaving = true);
try {
final newProfile = await accountModule.updateProfileName(
firstName,
_lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(),
);
_avatarUrl = newProfile.baseUrl;
_photoId = newProfile.photoId;
if (!mounted) return;
KometApp.stateOf(context)?.notifyProfileUpdate();
if (mounted) {
showCustomNotification(context, 'Имя сохранено');
setState(() => _isSaving = false);
}
} catch (e) {
if (!mounted) return;
showCustomNotification(context, 'Ошибка: $e');
setState(() => _isSaving = false);
}
}
Future<void> _changeAvatar() async {
if (_isSaving) return;
try {
final uploadUrl = await accountModule.getAvatarUploadUrl();
if (!mounted) return;
showCustomNotification(context, 'Загрузка аватарки: $uploadUrl (пока нет)');
} catch (e) {
if (mounted) showCustomNotification(context, 'Ошибка: $e');
}
}
Future<void> _removeAvatar() async {
if (_isSaving || _photoId == null) return;
setState(() => _isSaving = true);
try {
final newProfile = await accountModule.removeProfilePhoto(_photoId!);
_avatarUrl = newProfile.baseUrl;
_photoId = newProfile.photoId;
if (!mounted) return;
KometApp.stateOf(context)?.notifyProfileUpdate();
if (mounted) {
showCustomNotification(context, 'Фото удалено');
setState(() => _isSaving = false);
}
} catch (e) {
if (!mounted) return;
showCustomNotification(context, 'Ошибка: $e');
setState(() => _isSaving = false);
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context);
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBar(
backgroundColor: cs.surface,
elevation: 0,
leading: IconButton(
icon: Icon(Symbols.arrow_back, color: cs.onSurface),
onPressed: () => Navigator.pop(context),
),
title: Text(
l10n?.editProfileTitle ?? 'Edit Profile',
style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600),
),
actions: [
TextButton(
onPressed: _isLoading || _isSaving ? null : _saveName,
child: _isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(
l10n?.editProfileSave ?? 'Save',
style: TextStyle(color: cs.primary, fontWeight: FontWeight.w600),
),
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(16),
children: [
Center(
child: Stack(
children: [
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: cs.primary.withValues(alpha: 0.5),
width: 2.5,
),
),
child: ClipOval(
child: _avatarUrl != null && _avatarUrl!.isNotEmpty
? Image.network(_avatarUrl!, fit: BoxFit.cover)
: Container(
color: cs.primaryContainer,
alignment: Alignment.center,
child: Text(
_firstNameController.text.isNotEmpty
? _firstNameController.text[0].toUpperCase()
: '?',
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
),
),
),
Positioned(
bottom: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
color: cs.primary,
shape: BoxShape.circle,
),
child: IconButton(
icon: Icon(Symbols.camera_alt, color: cs.onPrimary, size: 20),
onPressed: _changeAvatar,
),
),
),
],
),
),
if (_photoId != null) ...[
const SizedBox(height: 8),
Center(
child: TextButton(
onPressed: _removeAvatar,
child: Text(
l10n?.editProfileRemovePhoto ?? 'Remove photo',
style: TextStyle(color: cs.error),
),
),
),
],
const SizedBox(height: 24),
_buildTextField(
l10n?.editProfileFirstName ?? 'First name',
_firstNameController,
cs,
enabled: !_isSaving,
),
const SizedBox(height: 12),
_buildTextField(
l10n?.editProfileLastName ?? 'Last name',
_lastNameController,
cs,
enabled: !_isSaving,
),
const SizedBox(height: 120),
],
),
);
}
Widget _buildTextField(String label, TextEditingController controller, ColorScheme cs, {bool enabled = true}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 6),
child: Text(label, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
),
TextField(
controller: controller,
enabled: enabled,
decoration: InputDecoration(
filled: true,
fillColor: cs.surfaceContainerHigh,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
),
],
);
}
}
@@ -0,0 +1,418 @@
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/config/app_fonts.dart';
import '../../../core/utils/haptics.dart';
import '../../../main.dart';
import '../../widgets/custom_notification.dart';
class FontSettingsScreen extends StatefulWidget {
const FontSettingsScreen({super.key});
@override
State<FontSettingsScreen> createState() => _FontSettingsScreenState();
}
class _FontSettingsScreenState extends State<FontSettingsScreen> {
List<String> _custom = const [];
@override
void initState() {
super.initState();
_reloadCustom();
}
Future<void> _reloadCustom() async {
final list = await AppFonts.loadCustomFamilies();
if (mounted) setState(() => _custom = list);
}
void _selectFont(String id) {
final app = KometApp.stateOf(context);
if (app == null || app.fontId == id) return;
Haptics.selection();
app.applyAppFont(id);
}
Future<void> _addFont(String raw) async {
final parsed = AppFonts.familyFromInput(raw);
if (parsed == null) {
if (mounted) {
showCustomNotification(context, 'Введите ссылку или название шрифта');
}
return;
}
final canonical = AppFonts.matchGoogleFamily(parsed);
if (canonical == null) {
if (mounted) {
showCustomNotification(
context,
'Шрифт «$parsed» не найден в Google Fonts',
);
}
return;
}
await AppFonts.addCustomFamily(canonical);
await _reloadCustom();
if (!mounted) return;
KometApp.stateOf(context)?.applyAppFont(AppFonts.customId(canonical));
Haptics.success();
showCustomNotification(context, 'Шрифт «$canonical» добавлен');
}
Future<void> _removeFont(String family) async {
await AppFonts.removeCustomFamily(family);
await _reloadCustom();
if (!mounted) return;
final app = KometApp.stateOf(context);
if (app != null && app.fontId == AppFonts.customId(family)) {
app.applyAppFont(AppFonts.fallback.id);
}
showCustomNotification(context, 'Шрифт «$family» удалён');
}
Future<void> _showAddFontDialog() async {
final cs = Theme.of(context).colorScheme;
final controller = TextEditingController();
final result = await showDialog<String>(
context: context,
builder: (ctx) {
return AlertDialog(
backgroundColor: cs.surfaceContainerHigh,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
title: const Text('Добавить шрифт'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Вставьте ссылку Google Fonts или название шрифта',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
height: 1.35,
),
),
const SizedBox(height: 18),
TextField(
controller: controller,
autofocus: true,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
hintText: 'fonts.google.com/specimen/Roboto',
filled: true,
fillColor: cs.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
),
onSubmitted: (v) => Navigator.pop(ctx, v),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, controller.text),
child: const Text('Добавить'),
),
],
);
},
);
if (result != null) await _addFont(result);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final app = KometApp.stateOf(context);
final currentId = app?.fontId ?? AppFonts.fallback.id;
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBarM3E(
titleText: 'Шрифты',
backgroundColor: cs.surface,
),
body: SafeArea(
top: false,
child: ListView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 120),
children: [
_PreviewCard(fontId: currentId),
const SizedBox(height: 28),
const _SectionLabel(icon: Symbols.text_fields, text: 'Шрифт'),
const SizedBox(height: 14),
for (final font in AppFonts.builtIn) ...[
_FontOption(
font: font,
selected: font.id == currentId,
onTap: () => _selectFont(font.id),
),
const SizedBox(height: 8),
],
for (final family in _custom) ...[
_FontOption(
font: AppFonts.resolve(AppFonts.customId(family)),
selected: AppFonts.customId(family) == currentId,
onTap: () => _selectFont(AppFonts.customId(family)),
onDelete: () => _removeFont(family),
),
const SizedBox(height: 8),
],
const SizedBox(height: 4),
SizedBox(
width: double.infinity,
child: ButtonM3E(
onPressed: _showAddFontDialog,
style: ButtonM3EStyle.outlined,
size: ButtonM3ESize.md,
icon: const Icon(Symbols.add),
label: const Text('Добавить шрифт'),
),
),
const SizedBox(height: 30),
const _SectionLabel(
icon: Symbols.format_size,
text: 'Размер шрифта',
),
const SizedBox(height: 6),
if (app != null)
ValueListenableBuilder<double>(
valueListenable: app.fontScale,
builder: (context, scale, _) => _FontSizeControl(
scale: scale,
onChanged: (v) => app.applyFontScale(v, persist: false),
onChangeEnd: (v) {
Haptics.selection();
app.applyFontScale(v);
},
onReset: () {
Haptics.selection();
app.applyFontScale(AppFonts.defaultScale);
},
),
),
],
),
),
);
}
}
class _PreviewCard extends StatelessWidget {
final String fontId;
const _PreviewCard({required this.fontId});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(28),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'ПРЕДПРОСМОТР',
style: TextStyle(
color: cs.primary,
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
),
),
const SizedBox(height: 16),
Text(
'Съешь ещё этих мягких булок',
style: AppFonts.sample(fontId, fontSize: 22).copyWith(
color: cs.onSurface,
fontWeight: FontWeight.w600,
height: 1.25,
),
),
const SizedBox(height: 10),
Text(
'The quick brown fox 0123',
style: AppFonts.sample(fontId, fontSize: 15).copyWith(
color: cs.onSurfaceVariant,
),
),
],
),
);
}
}
class _SectionLabel extends StatelessWidget {
final IconData icon;
final String text;
const _SectionLabel({required this.icon, required this.text});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(left: 6),
child: Row(
children: [
Icon(icon, size: 20, color: cs.onSurfaceVariant, weight: 500),
const SizedBox(width: 8),
Text(
text,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
),
),
],
),
);
}
}
class _FontOption extends StatelessWidget {
final AppFont font;
final bool selected;
final VoidCallback onTap;
final VoidCallback? onDelete;
const _FontOption({
required this.font,
required this.selected,
required this.onTap,
this.onDelete,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final button = ButtonM3E(
onPressed: onTap,
style: selected ? ButtonM3EStyle.filled : ButtonM3EStyle.tonal,
size: ButtonM3ESize.md,
shape: ButtonM3EShape.round,
selected: selected,
icon: Icon(
selected
? Symbols.check_circle
: (font.isSystem ? Symbols.smartphone : Symbols.font_download),
fill: selected ? 1 : 0,
),
label: Text(
font.label,
style: AppFonts.sample(font.id, fontSize: 16),
),
);
if (onDelete == null) {
return SizedBox(width: double.infinity, child: button);
}
return Row(
children: [
Expanded(child: button),
const SizedBox(width: 8),
IconButton(
onPressed: onDelete,
tooltip: 'Удалить',
icon: Icon(Symbols.delete, color: cs.onSurfaceVariant, weight: 500),
),
],
);
}
}
class _FontSizeControl extends StatelessWidget {
final double scale;
final ValueChanged<double> onChanged;
final ValueChanged<double> onChangeEnd;
final VoidCallback onReset;
const _FontSizeControl({
required this.scale,
required this.onChanged,
required this.onChangeEnd,
required this.onReset,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final isDefault = (scale - AppFonts.defaultScale).abs() < 0.001;
return Container(
padding: const EdgeInsets.fromLTRB(20, 16, 12, 16),
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(28),
),
child: Column(
children: [
Row(
children: [
Text(
'А',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: SliderM3E(
value: AppFonts.clampScale(scale),
min: AppFonts.minScale,
max: AppFonts.maxScale,
divisions: 10,
onChanged: onChanged,
onChangeEnd: onChangeEnd,
),
),
),
Text(
'А',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 24),
),
],
),
const SizedBox(height: 4),
Row(
children: [
Text(
'${(scale * 100).round()}%',
style: TextStyle(
color: cs.onSurface,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
const Spacer(),
ButtonM3E(
onPressed: isDefault ? null : onReset,
enabled: !isDefault,
style: ButtonM3EStyle.text,
size: ButtonM3ESize.sm,
label: const Text('Сбросить'),
),
],
),
],
),
);
}
}
@@ -0,0 +1,282 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/storage/token_storage.dart';
import '../../../l10n/app_localizations.dart';
import '../../widgets/custom_notification.dart';
class InfoScreen extends StatefulWidget {
const InfoScreen({super.key});
@override
State<InfoScreen> createState() => _InfoScreenState();
}
class _InfoScreenState extends State<InfoScreen> {
bool _isLoading = true;
Map<String, dynamic>? _info;
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
try {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) {
if (mounted) setState(() => _isLoading = false);
return;
}
final jsonStr = await AppDatabase.getLoginInfo(accountId);
if (jsonStr != null) {
setState(() => _info = jsonDecode(jsonStr) as Map<String, dynamic>);
}
if (mounted) setState(() => _isLoading = false);
} catch (e) {
if (mounted) {
showCustomNotification(context, 'Error: $e');
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context);
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBar(
backgroundColor: cs.surface,
elevation: 0,
leading: IconButton(
icon: Icon(Symbols.arrow_back, color: cs.onSurface),
onPressed: () => Navigator.pop(context),
),
title: Text(
l10n?.infoTitle ?? 'Info',
style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600),
),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _info == null
? Center(
child: Text(
'No data',
style: TextStyle(color: cs.onSurfaceVariant),
),
)
: _buildContent(cs, l10n!),
);
}
Widget _buildContent(ColorScheme cs, AppLocalizations l10n) {
final info = _info!;
final server = info['server'] as Map<String, dynamic>?;
final user = info['user'] as Map<String, dynamic>?;
final yMap = server?['y-map'] as Map<String, dynamic>?;
final accountKeys = <String, String>{
'registrationTime': l10n.infoRegistrationTime,
'country': l10n.infoCountry,
'videoChatHistory': l10n.infoVideoChatHistory,
'updateTime': l10n.infoUpdateTime,
'id': l10n.infoId,
'chatMarker': l10n.infoChatMarker,
};
final serverKeys = <String, String>{
'account-removal-enabled': l10n.infoAccountRemovalEnabled,
'image-size': l10n.infoImageSize,
'gce': l10n.infoGce,
'gcce': l10n.infoGcce,
'max-msg-length': l10n.infoMaxMsgLength,
'quotes-enabled': l10n.infoQuotesEnabled,
'calls-endpoint': l10n.infoCallsEndpoint,
'send-location-enabled': l10n.infoSendLocationEnabled,
'lgce': l10n.infoLgce,
'wud': l10n.infoWud,
'video-msg-enabled': l10n.infoVideoMsgEnabled,
'grse': l10n.infoGrse,
'edit-timeout': l10n.infoEditTimeout,
'image-quality': l10n.infoImageQuality,
'unsafe-files-alert': l10n.infoUnsafeFilesAlert,
'account-nickname-enabled': l10n.infoAccountNicknameEnabled,
'mentions_entity_names_limit': l10n.infoMentionsEntityNamesLimit,
'reactions-enabled': l10n.infoReactionsEnabled,
};
return ListView(
padding: const EdgeInsets.all(16),
children: [
_buildSectionTitle(l10n.infoAccountSection, cs),
...accountKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(info[e.key], e.key), cs)),
const SizedBox(height: 16),
_buildSectionTitle(l10n.infoServerSection, cs),
...serverKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(server?[e.key], e.key), cs)),
const SizedBox(height: 8),
_buildSectionTitle(l10n.infoYMapSection, cs),
_buildRow('tile', l10n.infoTile, yMap?['tile']?.toString() ?? '-', cs),
_buildRow('geocoder', l10n.infoGeocoder, yMap?['geocoder']?.toString() ?? '-', cs),
_buildRow('static', l10n.infoStatic, yMap?['static']?.toString() ?? '-', cs),
const SizedBox(height: 8),
_buildSectionTitle(l10n.infoFileUploadTypes, cs),
_buildListRow(server?['file-upload-unsupported-types'] as List?, cs),
const SizedBox(height: 8),
_buildSectionTitle(l10n.infoWhiteListLinks, cs),
_buildListRow(server?['white-list-links'] as List?, cs),
const SizedBox(height: 8),
_buildSectionTitle(l10n.infoUserSection, cs),
if (user != null)
...user.entries
.where((e) => e.value != null)
.map((e) => _buildRow(e.key, e.key, e.value.toString(), cs)),
const SizedBox(height: 120),
],
);
}
Widget _buildSectionTitle(String title, ColorScheme cs) {
return Padding(
padding: const EdgeInsets.only(top: 16, bottom: 8, left: 4, right: 4),
child: Text(
title,
style: TextStyle(
color: cs.primary,
fontSize: 13,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
);
}
Widget _buildRow(String key, String label, String value, ColorScheme cs) {
return Container(
margin: const EdgeInsets.only(bottom: 1),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
flex: 2,
child: Text(
label,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
),
const SizedBox(width: 12),
Expanded(
flex: 3,
child: Text(
value,
style: TextStyle(
color: cs.onSurface,
fontSize: 14,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.end,
),
),
],
),
);
}
Widget _buildListRow(List? items, ColorScheme cs) {
if (items == null || items.isEmpty) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Text('-', style: TextStyle(color: cs.onSurfaceVariant)),
);
}
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Wrap(
spacing: 8,
runSpacing: 4,
children: items
.map(
(item) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(
item.toString(),
style: TextStyle(fontSize: 13, color: cs.onSurface),
),
),
)
.toList(),
),
);
}
String _formatValue(dynamic value, String key) {
if (value == null) return '-';
if (value is Map && value.containsKey('chatMarker')) {
final ts = value['chatMarker'] as int?;
return ts != null ? _formatTs(ts) : '-';
}
if (value is int && value > 1000000000000) return _formatTs(value);
if (key == 'edit-timeout' && value is int && value > 0) {
final weeks = value ~/ 604800;
final days = (value % 604800) ~/ 86400;
if (weeks > 0) return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}'.trim();
final h = value ~/ 3600;
final m = (value % 3600) ~/ 60;
if (h > 0) return '${h}h ${m}m';
return '${m}m';
}
return value.toString();
}
String _formatTs(int ts) {
if (ts < 1000000000000) return ts.toString();
final dt = DateTime.fromMillisecondsSinceEpoch(ts);
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
}
String _w(int n) {
final m = n % 10;
if (m == 1 && n != 11) return 'нед';
if ((m == 2 || m == 3 || m == 4) && (n < 10 || n > 20)) return 'нед';
return 'нед';
}
String _d(int n) {
final m = n % 10;
if (m == 1 && n != 11) return 'дн';
if ((m == 2 || m == 3 || m == 4) && (n < 10 || n > 20)) return 'дн';
return 'дн';
}
}
@@ -15,8 +15,6 @@ class PasswordEntryScreen extends StatefulWidget {
class _PasswordEntryScreenState extends State<PasswordEntryScreen> {
bool _isLoading = true;
bool _is2faEnabled = false;
String? _email;
String? _hint;
@override
void initState() {
@@ -0,0 +1,182 @@
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
import '../../../core/config/app_cache_extent.dart';
import '../../../core/utils/haptics.dart';
class PerformanceScreen extends StatefulWidget {
const PerformanceScreen({super.key});
@override
State<PerformanceScreen> createState() => _PerformanceScreenState();
}
class _PerformanceScreenState extends State<PerformanceScreen> {
late double _value;
late double _preZoneValue;
bool _lowWarnDismissed = false;
bool _highWarnDismissed = false;
@override
void initState() {
super.initState();
_value = AppCacheExtent.current.value;
_preZoneValue = _value;
}
bool _isInSafeZone(double v) =>
v >= AppCacheExtent.lowWarnThreshold && v < AppCacheExtent.highWarnThreshold;
void _onChanged(double v) {
setState(() {
_value = v;
if (_isInSafeZone(v)) _preZoneValue = v;
});
}
Future<void> _onChangeEnd(double v) async {
Haptics.selection();
final inLow = v < AppCacheExtent.lowWarnThreshold;
final inHigh = v >= AppCacheExtent.highWarnThreshold;
if (inLow && !_lowWarnDismissed) {
final ok = await _showWarning(
text:
'Производительность приложения может снизиться, вы уверены?',
);
if (ok) {
_lowWarnDismissed = true;
await AppCacheExtent.save(v);
} else {
setState(() => _value = _preZoneValue);
await AppCacheExtent.save(_preZoneValue);
}
return;
}
if (inHigh && !_highWarnDismissed) {
final ok = await _showWarning(
text:
'Это врядли даст хотя-бы немного заметный прирост к FPS, '
'но может потреблять больше памяти. Вы уверены?',
);
if (ok) {
_highWarnDismissed = true;
await AppCacheExtent.save(v);
} else {
setState(() => _value = _preZoneValue);
await AppCacheExtent.save(_preZoneValue);
}
return;
}
await AppCacheExtent.save(v);
}
Future<bool> _showWarning({required String text}) async {
final cs = Theme.of(context).colorScheme;
final res = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
backgroundColor: cs.surfaceContainerHigh,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
content: Text(
text,
style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.35),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(
'Нет',
style: TextStyle(color: cs.onSurfaceVariant),
),
),
FilledButton.tonal(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Да'),
),
],
);
},
);
return res ?? false;
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final hint = cs.onSurfaceVariant;
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBarM3E(
titleText: 'Производительность',
backgroundColor: cs.surface,
),
body: SafeArea(
top: false,
child: ListView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 120),
children: [
Material(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Кеш сообщений',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'Сколько пикселей сообщений держать построенными за пределами видимой области.',
style: TextStyle(color: hint, fontSize: 13, height: 1.3),
),
const SizedBox(height: 18),
Text(
'Текущий cacheExtent: ${_value.round()}',
style: TextStyle(color: hint, fontSize: 12),
),
const SizedBox(height: 4),
Slider(
value: _value,
min: AppCacheExtent.min,
max: AppCacheExtent.max,
onChanged: _onChanged,
onChangeEnd: _onChangeEnd,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Меньше потребление',
style: TextStyle(color: hint, fontSize: 11),
),
Text(
'Больше FPS',
style: TextStyle(color: hint, fontSize: 11),
),
],
),
],
),
),
),
],
),
),
);
}
}
@@ -222,6 +222,7 @@ class _SecurityScreenState extends State<SecurityScreen>
case 'CONTACTS':
return 'Мои контакты';
case 'NONE':
case 'NOBODY':
return 'Никто';
default:
return value;
@@ -481,9 +482,31 @@ class _SecurityScreenState extends State<SecurityScreen>
icon: Icons.visibility_off_outlined,
label: 'Видеть статус «в сети»',
value: _privacyConfig?.hidden == true ? 'Никто' : 'Мои контакты',
isLast: true,
isLast: false,
onTap: () => _showHiddenStatusSheet(context, cs),
),
_buildOptionRow(
cs,
icon: Symbols.contact_page,
label: 'Видеть мой номер',
value: _getPrivacyLabel(
_privacyConfig?.phoneNumberPrivacy ?? 'ALL',
),
isLast: true,
onTap: () => _showOptionSheet(
context,
cs,
title: 'Видеть мой номер',
currentValue: _privacyConfig?.phoneNumberPrivacy ?? 'ALL',
options: const [
('ALL', 'Все'),
('CONTACTS', 'Мои контакты'),
('NOBODY', 'Никто'),
],
onSelect: (value) =>
_updateSetting('PHONE_NUMBER_PRIVACY', value),
),
),
],
],
),
@@ -920,7 +943,7 @@ class _SecurityScreenState extends State<SecurityScreen>
fontSize: 14,
),
),
if (trailing != null) trailing,
?trailing,
const SizedBox(width: 4),
Icon(
Symbols.chevron_right,
+118 -13
View File
@@ -5,10 +5,16 @@ import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/utils/haptics.dart';
import '../../../l10n/app_localizations.dart';
import '../../../main.dart';
import '../auth/proxy_settings_sheet.dart';
import 'customization_screen.dart';
import 'performance_screen.dart';
import 'debug_menu_screen.dart';
import 'devices_screen.dart';
import 'edit_profile_screen.dart';
import 'info_screen.dart';
import 'security_screen.dart';
import 'spoof_screen.dart';
@@ -26,17 +32,26 @@ class _SettingsTabState extends State<SettingsTab> {
bool _debugMenuVisible = false;
int _versionSecretTapCount = 0;
Timer? _versionSecretTapResetTimer;
StreamSubscription? _profileUpdateSub;
bool _hapticsEnabled = Haptics.enabled;
@override
void initState() {
super.initState();
_loadProfile();
_loadAppVersion();
final appState = KometApp.stateOf(context);
if (appState != null) {
_profileUpdateSub = appState.profileUpdateStream.listen((_) {
if (mounted) _loadProfile();
});
}
}
@override
void dispose() {
_versionSecretTapResetTimer?.cancel();
_profileUpdateSub?.cancel();
super.dispose();
}
@@ -72,6 +87,13 @@ class _SettingsTabState extends State<SettingsTab> {
});
}
Future<void> _setHaptics(bool value) async {
await Haptics.setEnabled(value);
// Let the user *feel* the confirmation the instant they switch it on.
if (value) Haptics.success();
if (mounted) setState(() => _hapticsEnabled = value);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
@@ -97,14 +119,61 @@ class _SettingsTabState extends State<SettingsTab> {
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: _buildSection(
context,
cs,
items: [
const _SettingsItem(icon: Symbols.badge, label: 'Цифровой ID'),
const _SettingsItem(
icon: Symbols.language,
label: 'Войти в Сферум',
),
_SettingsItem(
icon: Symbols.info,
label: 'Info',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const InfoScreen(),
),
);
},
),
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: _buildSection(
context,
cs,
items: const [
_SettingsItem(icon: Symbols.badge, label: 'Цифровой ID'),
items: [
_SettingsItem(
icon: Symbols.language,
label: 'Войти в Сферум',
icon: Symbols.palette,
label: 'Кастомизация',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const CustomizationScreen(),
),
);
},
),
_SettingsItem(
icon: Symbols.speed,
label: 'Производительность',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const PerformanceScreen(),
),
);
},
),
],
),
@@ -121,6 +190,12 @@ class _SettingsTabState extends State<SettingsTab> {
icon: Symbols.notifications_active,
label: 'Уведомления и звук',
),
_SettingsItem(
icon: Symbols.vibration,
label: 'Тактильная отдача',
toggleValue: _hapticsEnabled,
onToggle: _setHaptics,
),
_SettingsItem(
icon: Symbols.vpn_lock,
label: 'Прокси',
@@ -304,7 +379,14 @@ class _SettingsTabState extends State<SettingsTab> {
size: 22,
weight: 400,
),
onPressed: () {},
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const EditProfileScreen(),
),
);
},
),
],
),
@@ -324,6 +406,8 @@ class _SettingsTabState extends State<SettingsTab> {
? CachedNetworkImage(
imageUrl: _profile!.baseUrl!,
fit: BoxFit.cover,
memCacheWidth: 240,
memCacheHeight: 240,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (context, url, error) =>
_buildPlaceholderAvatar(cs, name),
@@ -420,7 +504,9 @@ class _SettingsTabState extends State<SettingsTab> {
Material(
color: Colors.transparent,
child: InkWell(
onTap: item.onTap ?? () {},
onTap: item.isToggle
? () => item.onToggle!(!(item.toggleValue ?? false))
: (item.onTap ?? () {}),
borderRadius: isLast
? const BorderRadius.vertical(bottom: Radius.circular(20))
: null,
@@ -445,12 +531,18 @@ class _SettingsTabState extends State<SettingsTab> {
),
),
),
Icon(
Symbols.chevron_right,
color: cs.outline,
size: 20,
weight: 400,
),
if (item.isToggle)
Switch.adaptive(
value: item.toggleValue ?? false,
onChanged: item.onToggle,
)
else
Icon(
Symbols.chevron_right,
color: cs.outline,
size: 20,
weight: 400,
),
],
),
),
@@ -475,7 +567,20 @@ class _SettingsItem {
final String label;
final VoidCallback? onTap;
const _SettingsItem({required this.icon, required this.label, this.onTap});
/// When [onToggle] is set the row renders a trailing switch instead of a
/// chevron, and [toggleValue] reflects its current state.
final bool? toggleValue;
final ValueChanged<bool>? onToggle;
const _SettingsItem({
required this.icon,
required this.label,
this.onTap,
this.toggleValue,
this.onToggle,
});
bool get isToggle => onToggle != null;
}
class _PhoneSpoiler extends StatefulWidget {
+441
View File
@@ -0,0 +1,441 @@
import 'dart:async';
import 'dart:convert' show utf8;
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart';
import 'package:komet/backend/modules/messages.dart' show FileHistoryCache, FileHistoryEntry;
import 'package:komet/core/config/proxy_config.dart';
import 'package:komet/core/protocol/opcode_map.dart';
import 'package:komet/core/protocol/packet.dart';
import 'package:komet/core/transport/proxy_connector.dart';
import 'package:komet/frontend/widgets/custom_notification.dart';
import 'package:komet/main.dart' show api, messagesModule;
import 'package:material_symbols_icons/symbols.dart';
class AttachmentPanel extends StatefulWidget {
final int chatId;
final VoidCallback onClose;
const AttachmentPanel({
super.key,
required this.chatId,
required this.onClose,
});
@override
State<AttachmentPanel> createState() => _AttachmentPanelState();
}
class _AttachmentPanelState extends State<AttachmentPanel> {
final TextEditingController _fileIdController = TextEditingController();
bool _isUploading = false;
Future<void> _pickAndUploadFile() async {
final result = await FilePicker.platform.pickFiles();
if (result == null || result.files.isEmpty) return;
final file = result.files.first;
if (file.path == null) return;
setState(() => _isUploading = true);
try {
final uploadInfo = await messagesModule.requestUploadUrl();
if (uploadInfo == null) {
if (mounted) showCustomNotification(context, 'Не удалось получить ссылку');
return;
}
await api.sendRequest(Opcode.msgTyping, {
'chatId': widget.chatId,
'type': 'FILE',
});
final uri = Uri.parse(uploadInfo.url);
final fileBytes = await File(file.path!).readAsBytes();
final proxySettings = await ProxyConfig.load();
int statusCode;
if (proxySettings.isEnabled) {
final connector = ProxyConnector(proxySettings);
final proxySocket = await connector.connect(uri.host, uri.port);
final socket = uri.scheme == 'https'
? await RawSecureSocket.secure(
proxySocket,
host: uri.host,
onBadCertificate: (_) => true,
)
: proxySocket;
statusCode = await _rawPost(socket, uri, fileBytes, file.name);
} else {
final socket = await RawSocket.connect(uri.host, uri.port);
final secureSocket = uri.scheme == 'https'
? await RawSecureSocket.secure(
socket,
host: uri.host,
onBadCertificate: (_) => true,
)
: socket;
statusCode = await _rawPost(secureSocket, uri, fileBytes, file.name);
}
if (statusCode != 200) {
if (mounted) showCustomNotification(context, 'Ошибка загрузки: $statusCode');
return;
}
// Wait for notifAttach push
final pushCompleter = Completer<void>();
void Function(Packet)? pushHandler;
pushHandler = (Packet packet) {
final payload = packet.payload;
if (payload is Map && payload['fileId'] == uploadInfo.fileId) {
api.unregisterPushHandler(Opcode.notifAttach);
pushCompleter.complete();
}
};
api.registerPushHandler(Opcode.notifAttach, (Packet p) => pushHandler!(p));
await pushCompleter.future.timeout(
const Duration(seconds: 30),
onTimeout: () {
api.unregisterPushHandler(Opcode.notifAttach);
throw TimeoutException('Тайм-аут подтверждения загрузки');
},
);
// Retry loop: server may say "attachment in progress" (cmd=3)
for (var attempt = 0; attempt < 5; attempt++) {
final sent = await messagesModule.sendFileMessage(
widget.chatId,
uploadInfo.fileId,
token: uploadInfo.token,
);
// Listen for push again (another notifAttach may come)
final msgCompleter = Completer<bool>();
void Function(Packet)? msgHandler;
msgHandler = (Packet packet) {
final payload = packet.payload;
if (payload is Map && payload['fileId'] == uploadInfo.fileId) {
api.unregisterPushHandler(Opcode.notifAttach);
msgCompleter.complete(true);
}
};
api.registerPushHandler(Opcode.notifAttach, (Packet p) => msgHandler!(p));
final pushFuture = msgCompleter.future.timeout(
const Duration(seconds: 5),
onTimeout: () {
api.unregisterPushHandler(Opcode.notifAttach);
return false;
},
);
final pushReceived = await pushFuture;
if (pushReceived && sent) {
FileHistoryCache.add(FileHistoryEntry(
fileId: uploadInfo.fileId,
url: uploadInfo.url,
token: uploadInfo.token,
sentAt: DateTime.now(),
));
if (mounted) {
showCustomNotification(context, 'Файл отправлен');
widget.onClose();
}
return;
}
// If push was received, check if message was sent
if (pushReceived) {
FileHistoryCache.add(FileHistoryEntry(
fileId: uploadInfo.fileId,
url: uploadInfo.url,
token: uploadInfo.token,
sentAt: DateTime.now(),
));
if (mounted) {
showCustomNotification(context, 'Файл отправлен');
widget.onClose();
}
return;
}
if (!sent) {
// msgSend failed, maybe server still processing — wait and retry
await Future.delayed(Duration(seconds: 1 + attempt));
continue;
}
// Sent ok, no push received (already processed earlier)
FileHistoryCache.add(FileHistoryEntry(
fileId: uploadInfo.fileId,
url: uploadInfo.url,
token: uploadInfo.token,
sentAt: DateTime.now(),
));
if (mounted) {
showCustomNotification(context, 'Файл отправлен');
widget.onClose();
}
return;
}
if (mounted) showCustomNotification(context, 'Не удалось отправить сообщение');
} catch (e) {
if (mounted) showCustomNotification(context, 'Ошибка: $e');
} finally {
if (mounted) setState(() => _isUploading = false);
}
}
Future<int> _rawPost(RawSocket socket, Uri uri, List<int> body, String filename) async {
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
final host = uri.host;
final total = body.length;
final request = StringBuffer()
..write('POST $path HTTP/1.1\r\n')
..write('Host: $host\r\n')
..write('Content-Type: application/x-binary; charset=x-user-defined\r\n')
..write('Content-Disposition: attachment; filename=$filename\r\n')
..write('Connection: keep-alive\r\n')
..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n')
..write('Content-Range: bytes 0-${total - 1}/$total\r\n')
..write('Content-Length: $total\r\n')
..write('\r\n');
final requestBytes = utf8.encode(request.toString());
final allBytes = <int>[...requestBytes, ...(body is Uint8List ? body : Uint8List.fromList(body))];
socket.write(Uint8List.fromList(allBytes));
final responseBytes = <int>[];
final completer = Completer<int>();
Timer? timer;
socket.listen((event) {
if (event == RawSocketEvent.read) {
final data = socket.read();
if (data != null) responseBytes.addAll(data);
} else if (event == RawSocketEvent.readClosed || event == RawSocketEvent.closed) {
timer?.cancel();
if (responseBytes.isEmpty) {
completer.completeError(const SocketException('Пустой ответ сервера'));
return;
}
final headerEnd = _findHeaderEnd(responseBytes);
if (headerEnd == -1) {
completer.completeError(const SocketException('Не удалось прочитать заголовок ответа'));
return;
}
final headerStr = utf8.decode(responseBytes.sublist(0, headerEnd), allowMalformed: true);
final statusLine = headerStr.split('\r\n').first;
debugPrint('HTTP Response: $statusLine');
final parts = statusLine.split(' ');
completer.complete(parts.length >= 2 ? int.tryParse(parts[1]) ?? 0 : 0);
}
}, onError: (e) {
timer?.cancel();
completer.completeError(e);
});
timer = Timer(const Duration(minutes: 5), () {
socket.close();
completer.completeError(TimeoutException('Тайм-аут загрузки'));
});
return completer.future;
}
int _findHeaderEnd(List<int> bytes) {
for (var i = 0; i < bytes.length - 3; i++) {
if (bytes[i] == 0x0D && bytes[i + 1] == 0x0A &&
bytes[i + 2] == 0x0D && bytes[i + 3] == 0x0A) {
return i + 4;
}
}
return -1;
}
Future<void> _uploadByFileId() async {
final fileIdStr = _fileIdController.text.trim();
if (fileIdStr.isEmpty) return;
final fileId = int.tryParse(fileIdStr);
if (fileId == null) {
if (mounted) showCustomNotification(context, 'Неверный fileId');
return;
}
setState(() => _isUploading = true);
try {
final sent = await messagesModule.sendFileMessage(widget.chatId, fileId);
if (sent) {
if (mounted) {
showCustomNotification(context, 'Файл отправлен');
widget.onClose();
}
} else {
if (mounted) showCustomNotification(context, 'Ошибка отправки');
}
} catch (e) {
if (mounted) showCustomNotification(context, 'Ошибка: $e');
} finally {
if (mounted) setState(() => _isUploading = false);
}
}
@override
void dispose() {
_fileIdController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return GestureDetector(
onVerticalDragEnd: (details) {
if (details.velocity.pixelsPerSecond.dy > 300) widget.onClose();
},
child: Container(
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)),
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(top: 8),
decoration: BoxDecoration(
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(children: [
Expanded(child: _buildButton(
label: 'Выбрать из файла',
icon: Symbols.folder_open,
filled: true,
onTap: _isUploading ? null : _pickAndUploadFile,
cs: cs,
)),
const SizedBox(width: 8),
Expanded(child: _buildButton(
label: 'Отправить по id',
icon: null,
filled: false,
onTap: _isUploading ? null : _uploadByFileId,
cs: cs,
)),
]),
),
if (_isUploading)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: LinearProgressIndicator(),
)
else
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
controller: _fileIdController,
style: TextStyle(color: cs.onSurface, fontSize: 14),
keyboardType: TextInputType.number,
decoration: InputDecoration(
hintText: 'fileId...',
hintStyle: TextStyle(color: cs.onSurfaceVariant),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 8),
),
),
),
const Divider(height: 16),
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text('История', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12, fontWeight: FontWeight.w500)),
),
),
if (FileHistoryCache.isEmpty)
Padding(
padding: const EdgeInsets.all(24),
child: Text('история пуста...', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14)),
)
else
SizedBox(
height: 100,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: FileHistoryCache.history.length,
itemBuilder: (ctx, idx) {
final e = FileHistoryCache.history[idx];
return Container(
width: 72,
margin: const EdgeInsets.only(right: 8, bottom: 8),
decoration: BoxDecoration(
color: cs.surfaceContainerLow,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)),
),
child: Center(child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.description, color: cs.onSurfaceVariant, size: 28),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text('${e.fileId}', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 9), overflow: TextOverflow.ellipsis, textAlign: TextAlign.center),
),
],
)),
);
},
),
),
const SizedBox(height: 8),
]),
),
);
}
Widget _buildButton({
required String label,
required IconData? icon,
required bool filled,
required VoidCallback? onTap,
required ColorScheme cs,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: filled ? cs.primaryContainer : cs.surfaceContainerLow,
borderRadius: BorderRadius.circular(10),
border: filled ? null : Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: 18, color: filled ? cs.onPrimaryContainer : cs.onSurface),
const SizedBox(width: 6),
],
Text(label, style: TextStyle(
color: filled ? cs.onPrimaryContainer : cs.onSurface,
fontWeight: FontWeight.w500,
fontSize: 13,
)),
],
),
),
);
}
}
View File
View File
@@ -10,7 +10,7 @@ void showCustomNotificationOnOverlay(OverlayState overlay, String message) {
builder: (context) => CustomNotification(message: message),
);
overlay.insert(entry);
Future.delayed(const Duration(milliseconds: 1900), () {
Future.delayed(const Duration(milliseconds: 2600), () {
entry.remove();
});
}
@@ -38,7 +38,7 @@ class _CustomNotificationState extends State<CustomNotification>
);
_opacity = Tween<double>(begin: 0.0, end: 1.0).animate(_controller);
_controller.forward();
Future.delayed(const Duration(milliseconds: 1600), () {
Future.delayed(const Duration(milliseconds: 2300), () {
if (mounted) _controller.reverse();
});
}
File diff suppressed because it is too large Load Diff
+61 -1
View File
@@ -104,5 +104,65 @@
}
}
},
"profileMenuSpoof": "Spoofing"
"profileMenuSpoof": "Spoofing",
"infoTitle": "Info",
"infoAccountSection": "Account",
"infoServerSection": "Server",
"infoUserSection": "User",
"infoYMapSection": "Y-Map",
"infoFileUploadTypes": "file-upload-unsupported-types",
"infoWhiteListLinks": "white-list-links",
"infoRegistrationTime": "registrationTime",
"infoCountry": "country",
"infoVideoChatHistory": "videoChatHistory",
"infoUpdateTime": "updateTime",
"infoId": "id",
"infoChatMarker": "chatMarker",
"infoAccountRemovalEnabled": "account-removal-enabled",
"infoImageSize": "image-size",
"infoGce": "gce",
"infoGcce": "gcce",
"infoMaxMsgLength": "max-msg-length",
"infoQuotesEnabled": "quotes-enabled",
"infoCallsEndpoint": "calls-endpoint",
"infoSendLocationEnabled": "send-location-enabled",
"infoLgce": "lgce",
"infoWud": "wud",
"infoVideoMsgEnabled": "video-msg-enabled",
"infoGrse": "grse",
"infoEditTimeout": "edit-timeout",
"infoImageQuality": "image-quality",
"infoUnsafeFilesAlert": "unsafe-files-alert",
"infoAccountNicknameEnabled": "account-nickname-enabled",
"infoMentionsEntityNamesLimit": "mentions_entity_names_limit",
"infoReactionsEnabled": "reactions-enabled",
"infoTile": "tile",
"infoGeocoder": "geocoder",
"infoStatic": "static",
"chatInfoSubscribers": "subscribers:",
"chatInfoInvitedBy": "invited by:",
"chatInfoLink": "link:",
"chatInfoOfficial": "official:",
"chatInfoComments": "comments:",
"chatInfoAplus": "approved by Roskomnadzor:",
"chatInfoSignAdmin": "admin signature:",
"chatInfoLastChanged": "last changed:",
"chatInfoJoinTime": "joined:",
"chatInfoCreated": "created:",
"chatInfoTitle": "Info",
"chatInfoMembers": "members:",
"chatInfoLastSeen": "last seen recently",
"chatInfoHasBots": "has bots:",
"chatInfoBlockedCount": "blocked in group:",
"chatInfoOfficialStatus": "official status:",
"chatInfoLastChanged": "last changed:",
"chatInfoJoined": "joined:",
"chatInfoGroupCreated": "group created:",
"chatInfoGroupOwner": "group owner:",
"chatInfoDialogStarted": "dialog started:",
"editProfileTitle": "Edit Profile",
"editProfileSave": "Save",
"editProfileFirstName": "First name",
"editProfileLastName": "Last name",
"editProfileRemovePhoto": "Remove photo"
}
+354
View File
@@ -631,6 +631,360 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Spoofing'**
String get profileMenuSpoof;
/// No description provided for @infoTitle.
///
/// In en, this message translates to:
/// **'Info'**
String get infoTitle;
/// No description provided for @infoAccountSection.
///
/// In en, this message translates to:
/// **'Account'**
String get infoAccountSection;
/// No description provided for @infoServerSection.
///
/// In en, this message translates to:
/// **'Server'**
String get infoServerSection;
/// No description provided for @infoUserSection.
///
/// In en, this message translates to:
/// **'User'**
String get infoUserSection;
/// No description provided for @infoYMapSection.
///
/// In en, this message translates to:
/// **'Y-Map'**
String get infoYMapSection;
/// No description provided for @infoFileUploadTypes.
///
/// In en, this message translates to:
/// **'file-upload-unsupported-types'**
String get infoFileUploadTypes;
/// No description provided for @infoWhiteListLinks.
///
/// In en, this message translates to:
/// **'white-list-links'**
String get infoWhiteListLinks;
/// No description provided for @infoRegistrationTime.
///
/// In en, this message translates to:
/// **'registrationTime'**
String get infoRegistrationTime;
/// No description provided for @infoCountry.
///
/// In en, this message translates to:
/// **'country'**
String get infoCountry;
/// No description provided for @infoVideoChatHistory.
///
/// In en, this message translates to:
/// **'videoChatHistory'**
String get infoVideoChatHistory;
/// No description provided for @infoUpdateTime.
///
/// In en, this message translates to:
/// **'updateTime'**
String get infoUpdateTime;
/// No description provided for @infoId.
///
/// In en, this message translates to:
/// **'id'**
String get infoId;
/// No description provided for @infoChatMarker.
///
/// In en, this message translates to:
/// **'chatMarker'**
String get infoChatMarker;
/// No description provided for @infoAccountRemovalEnabled.
///
/// In en, this message translates to:
/// **'account-removal-enabled'**
String get infoAccountRemovalEnabled;
/// No description provided for @infoImageSize.
///
/// In en, this message translates to:
/// **'image-size'**
String get infoImageSize;
/// No description provided for @infoGce.
///
/// In en, this message translates to:
/// **'gce'**
String get infoGce;
/// No description provided for @infoGcce.
///
/// In en, this message translates to:
/// **'gcce'**
String get infoGcce;
/// No description provided for @infoMaxMsgLength.
///
/// In en, this message translates to:
/// **'max-msg-length'**
String get infoMaxMsgLength;
/// No description provided for @infoQuotesEnabled.
///
/// In en, this message translates to:
/// **'quotes-enabled'**
String get infoQuotesEnabled;
/// No description provided for @infoCallsEndpoint.
///
/// In en, this message translates to:
/// **'calls-endpoint'**
String get infoCallsEndpoint;
/// No description provided for @infoSendLocationEnabled.
///
/// In en, this message translates to:
/// **'send-location-enabled'**
String get infoSendLocationEnabled;
/// No description provided for @infoLgce.
///
/// In en, this message translates to:
/// **'lgce'**
String get infoLgce;
/// No description provided for @infoWud.
///
/// In en, this message translates to:
/// **'wud'**
String get infoWud;
/// No description provided for @infoVideoMsgEnabled.
///
/// In en, this message translates to:
/// **'video-msg-enabled'**
String get infoVideoMsgEnabled;
/// No description provided for @infoGrse.
///
/// In en, this message translates to:
/// **'grse'**
String get infoGrse;
/// No description provided for @infoEditTimeout.
///
/// In en, this message translates to:
/// **'edit-timeout'**
String get infoEditTimeout;
/// No description provided for @infoImageQuality.
///
/// In en, this message translates to:
/// **'image-quality'**
String get infoImageQuality;
/// No description provided for @infoUnsafeFilesAlert.
///
/// In en, this message translates to:
/// **'unsafe-files-alert'**
String get infoUnsafeFilesAlert;
/// No description provided for @infoAccountNicknameEnabled.
///
/// In en, this message translates to:
/// **'account-nickname-enabled'**
String get infoAccountNicknameEnabled;
/// No description provided for @infoMentionsEntityNamesLimit.
///
/// In en, this message translates to:
/// **'mentions_entity_names_limit'**
String get infoMentionsEntityNamesLimit;
/// No description provided for @infoReactionsEnabled.
///
/// In en, this message translates to:
/// **'reactions-enabled'**
String get infoReactionsEnabled;
/// No description provided for @infoTile.
///
/// In en, this message translates to:
/// **'tile'**
String get infoTile;
/// No description provided for @infoGeocoder.
///
/// In en, this message translates to:
/// **'geocoder'**
String get infoGeocoder;
/// No description provided for @infoStatic.
///
/// In en, this message translates to:
/// **'static'**
String get infoStatic;
/// No description provided for @chatInfoSubscribers.
///
/// In en, this message translates to:
/// **'subscribers:'**
String get chatInfoSubscribers;
/// No description provided for @chatInfoInvitedBy.
///
/// In en, this message translates to:
/// **'invited by:'**
String get chatInfoInvitedBy;
/// No description provided for @chatInfoLink.
///
/// In en, this message translates to:
/// **'link:'**
String get chatInfoLink;
/// No description provided for @chatInfoOfficial.
///
/// In en, this message translates to:
/// **'official:'**
String get chatInfoOfficial;
/// No description provided for @chatInfoComments.
///
/// In en, this message translates to:
/// **'comments:'**
String get chatInfoComments;
/// No description provided for @chatInfoAplus.
///
/// In en, this message translates to:
/// **'approved by Roskomnadzor:'**
String get chatInfoAplus;
/// No description provided for @chatInfoSignAdmin.
///
/// In en, this message translates to:
/// **'admin signature:'**
String get chatInfoSignAdmin;
/// No description provided for @chatInfoLastChanged.
///
/// In en, this message translates to:
/// **'last changed:'**
String get chatInfoLastChanged;
/// No description provided for @chatInfoJoinTime.
///
/// In en, this message translates to:
/// **'joined:'**
String get chatInfoJoinTime;
/// No description provided for @chatInfoCreated.
///
/// In en, this message translates to:
/// **'created:'**
String get chatInfoCreated;
/// No description provided for @chatInfoTitle.
///
/// In en, this message translates to:
/// **'Info'**
String get chatInfoTitle;
/// No description provided for @chatInfoMembers.
///
/// In en, this message translates to:
/// **'members:'**
String get chatInfoMembers;
/// No description provided for @chatInfoLastSeen.
///
/// In en, this message translates to:
/// **'last seen recently'**
String get chatInfoLastSeen;
/// No description provided for @chatInfoHasBots.
///
/// In en, this message translates to:
/// **'has bots:'**
String get chatInfoHasBots;
/// No description provided for @chatInfoBlockedCount.
///
/// In en, this message translates to:
/// **'blocked in group:'**
String get chatInfoBlockedCount;
/// No description provided for @chatInfoOfficialStatus.
///
/// In en, this message translates to:
/// **'official status:'**
String get chatInfoOfficialStatus;
/// No description provided for @chatInfoJoined.
///
/// In en, this message translates to:
/// **'joined:'**
String get chatInfoJoined;
/// No description provided for @chatInfoGroupCreated.
///
/// In en, this message translates to:
/// **'group created:'**
String get chatInfoGroupCreated;
/// No description provided for @chatInfoGroupOwner.
///
/// In en, this message translates to:
/// **'group owner:'**
String get chatInfoGroupOwner;
/// No description provided for @chatInfoDialogStarted.
///
/// In en, this message translates to:
/// **'dialog started:'**
String get chatInfoDialogStarted;
/// No description provided for @editProfileTitle.
///
/// In en, this message translates to:
/// **'Edit Profile'**
String get editProfileTitle;
/// No description provided for @editProfileSave.
///
/// In en, this message translates to:
/// **'Save'**
String get editProfileSave;
/// No description provided for @editProfileFirstName.
///
/// In en, this message translates to:
/// **'First name'**
String get editProfileFirstName;
/// No description provided for @editProfileLastName.
///
/// In en, this message translates to:
/// **'Last name'**
String get editProfileLastName;
/// No description provided for @editProfileRemovePhoto.
///
/// In en, this message translates to:
/// **'Remove photo'**
String get editProfileRemovePhoto;
}
class _AppLocalizationsDelegate
+177
View File
@@ -289,4 +289,181 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get profileMenuSpoof => 'Spoofing';
@override
String get infoTitle => 'Info';
@override
String get infoAccountSection => 'Account';
@override
String get infoServerSection => 'Server';
@override
String get infoUserSection => 'User';
@override
String get infoYMapSection => 'Y-Map';
@override
String get infoFileUploadTypes => 'file-upload-unsupported-types';
@override
String get infoWhiteListLinks => 'white-list-links';
@override
String get infoRegistrationTime => 'registrationTime';
@override
String get infoCountry => 'country';
@override
String get infoVideoChatHistory => 'videoChatHistory';
@override
String get infoUpdateTime => 'updateTime';
@override
String get infoId => 'id';
@override
String get infoChatMarker => 'chatMarker';
@override
String get infoAccountRemovalEnabled => 'account-removal-enabled';
@override
String get infoImageSize => 'image-size';
@override
String get infoGce => 'gce';
@override
String get infoGcce => 'gcce';
@override
String get infoMaxMsgLength => 'max-msg-length';
@override
String get infoQuotesEnabled => 'quotes-enabled';
@override
String get infoCallsEndpoint => 'calls-endpoint';
@override
String get infoSendLocationEnabled => 'send-location-enabled';
@override
String get infoLgce => 'lgce';
@override
String get infoWud => 'wud';
@override
String get infoVideoMsgEnabled => 'video-msg-enabled';
@override
String get infoGrse => 'grse';
@override
String get infoEditTimeout => 'edit-timeout';
@override
String get infoImageQuality => 'image-quality';
@override
String get infoUnsafeFilesAlert => 'unsafe-files-alert';
@override
String get infoAccountNicknameEnabled => 'account-nickname-enabled';
@override
String get infoMentionsEntityNamesLimit => 'mentions_entity_names_limit';
@override
String get infoReactionsEnabled => 'reactions-enabled';
@override
String get infoTile => 'tile';
@override
String get infoGeocoder => 'geocoder';
@override
String get infoStatic => 'static';
@override
String get chatInfoSubscribers => 'subscribers:';
@override
String get chatInfoInvitedBy => 'invited by:';
@override
String get chatInfoLink => 'link:';
@override
String get chatInfoOfficial => 'official:';
@override
String get chatInfoComments => 'comments:';
@override
String get chatInfoAplus => 'approved by Roskomnadzor:';
@override
String get chatInfoSignAdmin => 'admin signature:';
@override
String get chatInfoLastChanged => 'last changed:';
@override
String get chatInfoJoinTime => 'joined:';
@override
String get chatInfoCreated => 'created:';
@override
String get chatInfoTitle => 'Info';
@override
String get chatInfoMembers => 'members:';
@override
String get chatInfoLastSeen => 'last seen recently';
@override
String get chatInfoHasBots => 'has bots:';
@override
String get chatInfoBlockedCount => 'blocked in group:';
@override
String get chatInfoOfficialStatus => 'official status:';
@override
String get chatInfoJoined => 'joined:';
@override
String get chatInfoGroupCreated => 'group created:';
@override
String get chatInfoGroupOwner => 'group owner:';
@override
String get chatInfoDialogStarted => 'dialog started:';
@override
String get editProfileTitle => 'Edit Profile';
@override
String get editProfileSave => 'Save';
@override
String get editProfileFirstName => 'First name';
@override
String get editProfileLastName => 'Last name';
@override
String get editProfileRemovePhoto => 'Remove photo';
}
+177
View File
@@ -291,4 +291,181 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get profileMenuSpoof => 'Подмена данных';
@override
String get infoTitle => 'Info';
@override
String get infoAccountSection => 'Аккаунт';
@override
String get infoServerSection => 'Сервер';
@override
String get infoUserSection => 'Пользователь';
@override
String get infoYMapSection => 'Y-Map';
@override
String get infoFileUploadTypes => 'запрещённые типы файлов';
@override
String get infoWhiteListLinks => 'безопасные ссылки';
@override
String get infoRegistrationTime => 'Дата регистрации:';
@override
String get infoCountry => 'Регион аккаунта:';
@override
String get infoVideoChatHistory => 'videoChatHistory';
@override
String get infoUpdateTime => 'Последнее обновление аватарки:';
@override
String get infoId => 'id аккаунта:';
@override
String get infoChatMarker => 'chatMarker';
@override
String get infoAccountRemovalEnabled => 'Мгновенное удаление аккаунта:';
@override
String get infoImageSize => 'image-size';
@override
String get infoGce => 'gce';
@override
String get infoGcce => 'gcce';
@override
String get infoMaxMsgLength => 'макс. длина сообщения:';
@override
String get infoQuotesEnabled => 'quotes-enabled';
@override
String get infoCallsEndpoint => 'calls-endpoint';
@override
String get infoSendLocationEnabled => 'отправка гео.:';
@override
String get infoLgce => 'lgce';
@override
String get infoWud => 'wud';
@override
String get infoVideoMsgEnabled => 'Кружки:';
@override
String get infoGrse => 'grse';
@override
String get infoEditTimeout => 'Можно редактировать сообщение в течении:';
@override
String get infoImageQuality => 'image-quality';
@override
String get infoUnsafeFilesAlert => 'unsafe-files-alert';
@override
String get infoAccountNicknameEnabled => 'account-nickname-enabled';
@override
String get infoMentionsEntityNamesLimit => 'макс. кол-во упоминаний:';
@override
String get infoReactionsEnabled => 'reactions-enabled';
@override
String get infoTile => 'tile';
@override
String get infoGeocoder => 'geocoder';
@override
String get infoStatic => 'static';
@override
String get chatInfoSubscribers => 'подписчиков:';
@override
String get chatInfoInvitedBy => 'Приглашён от:';
@override
String get chatInfoLink => 'ссылка:';
@override
String get chatInfoOfficial => 'оффициальный:';
@override
String get chatInfoComments => 'комментарии:';
@override
String get chatInfoAplus => 'подтверждён Роскомнадзором:';
@override
String get chatInfoSignAdmin => 'Подпись админов:';
@override
String get chatInfoLastChanged => 'последнее изменение:';
@override
String get chatInfoJoinTime => 'заход в канал:';
@override
String get chatInfoCreated => 'канал создан:';
@override
String get chatInfoTitle => 'Информация';
@override
String get chatInfoMembers => 'участников:';
@override
String get chatInfoLastSeen => 'был(а) недавно';
@override
String get chatInfoHasBots => 'Есть боты:';
@override
String get chatInfoBlockedCount => 'в ЧС группы:';
@override
String get chatInfoOfficialStatus => 'Официальный статус:';
@override
String get chatInfoJoined => 'Зашли в:';
@override
String get chatInfoGroupCreated => 'Группа создана в:';
@override
String get chatInfoGroupOwner => 'Создатель группы:';
@override
String get chatInfoDialogStarted => 'ЛС начат в:';
@override
String get editProfileTitle => 'Редактирование профиля';
@override
String get editProfileSave => 'Сохранить';
@override
String get editProfileFirstName => 'Имя';
@override
String get editProfileLastName => 'Фамилия';
@override
String get editProfileRemovePhoto => 'Удалить фото';
}
+61 -1
View File
@@ -104,5 +104,65 @@
}
}
},
"profileMenuSpoof": "Подмена данных"
"profileMenuSpoof": "Подмена данных",
"infoTitle": "Info",
"infoAccountSection": "Аккаунт",
"infoServerSection": "Сервер",
"infoUserSection": "Пользователь",
"infoYMapSection": "Y-Map",
"infoFileUploadTypes": "запрещённые типы файлов",
"infoWhiteListLinks": "безопасные ссылки",
"infoRegistrationTime": "Дата регистрации:",
"infoCountry": "Регион аккаунта:",
"infoVideoChatHistory": "videoChatHistory",
"infoUpdateTime": "Последнее обновление аватарки:",
"infoId": "id аккаунта:",
"infoChatMarker": "chatMarker",
"infoAccountRemovalEnabled": "Мгновенное удаление аккаунта:",
"infoImageSize": "image-size",
"infoGce": "gce",
"infoGcce": "gcce",
"infoMaxMsgLength": "макс. длина сообщения:",
"infoQuotesEnabled": "quotes-enabled",
"infoCallsEndpoint": "calls-endpoint",
"infoSendLocationEnabled": "отправка гео.:",
"infoLgce": "lgce",
"infoWud": "wud",
"infoVideoMsgEnabled": "Кружки:",
"infoGrse": "grse",
"infoEditTimeout": "Можно редактировать сообщение в течении:",
"infoImageQuality": "image-quality",
"infoUnsafeFilesAlert": "unsafe-files-alert",
"infoAccountNicknameEnabled": "account-nickname-enabled",
"infoMentionsEntityNamesLimit": "макс. кол-во упоминаний:",
"infoReactionsEnabled": "reactions-enabled",
"infoTile": "tile",
"infoGeocoder": "geocoder",
"infoStatic": "static",
"chatInfoSubscribers": "подписчиков:",
"chatInfoInvitedBy": "Приглашён от:",
"chatInfoLink": "ссылка:",
"chatInfoOfficial": "оффициальный:",
"chatInfoComments": "комментарии:",
"chatInfoAplus": "подтверждён Роскомнадзором:",
"chatInfoSignAdmin": "Подпись админов:",
"chatInfoLastChanged": "последнее изменение:",
"chatInfoJoinTime": "заход в канал:",
"chatInfoCreated": "канал создан:",
"chatInfoTitle": "Информация",
"chatInfoMembers": "участников:",
"chatInfoLastSeen": "был(а) недавно",
"chatInfoHasBots": "Есть боты:",
"chatInfoBlockedCount": "в ЧС группы:",
"chatInfoOfficialStatus": "Официальный статус:",
"chatInfoLastChanged": "последнее изменение:",
"chatInfoJoined": "Зашли в:",
"chatInfoGroupCreated": "Группа создана в:",
"chatInfoGroupOwner": "Создатель группы:",
"chatInfoDialogStarted": "ЛС начат в:",
"editProfileTitle": "Редактирование профиля",
"editProfileSave": "Сохранить",
"editProfileFirstName": "Имя",
"editProfileLastName": "Фамилия",
"editProfileRemovePhoto": "Удалить фото"
}
+263 -57
View File
@@ -1,13 +1,24 @@
import 'dart:async';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:komet/l10n/app_localizations.dart';
import 'package:m3e_collection/m3e_collection.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'backend/api.dart';
import 'core/config/app_accent.dart';
import 'core/config/app_bubble_shape.dart';
import 'core/config/app_cache_extent.dart';
import 'core/config/app_fonts.dart';
import 'backend/modules/account.dart';
import 'backend/modules/contacts.dart';
import 'backend/modules/messages.dart';
import 'core/push/push_service.dart';
import 'core/storage/app_database.dart';
import 'core/transport/vpn_bypass.dart';
import 'core/storage/token_storage.dart';
import 'core/utils/haptics.dart';
import 'core/protocol/packet.dart';
import 'frontend/debug/fps_overlay_layer.dart';
import 'frontend/screens/auth/login_screen.dart';
@@ -34,15 +45,40 @@ Future<Locale> _loadInitialLocale() async {
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await AppDatabase.init();
final activeAccountId = await TokenStorage.getActiveAccountId();
if (activeAccountId != null) {
await ContactsModule.primeCacheFromDb(activeAccountId);
}
await api.connect();
final packageInfo = await PackageInfo.fromPlatform();
if (packageInfo.packageName == 'ru.oneme.app') {
await PushService.instance.init(api: api, account: accountModule);
}
final initialLocale = await _loadInitialLocale();
await Haptics.load();
final prefs = await SharedPreferences.getInstance();
final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false;
final initialVpnBypass = prefs.getBool(VpnBypassService.prefKey) ?? false;
final initialFontId =
prefs.getString(AppFonts.prefKey) ?? AppFonts.fallback.id;
final initialFontScale = AppFonts.clampScale(
prefs.getDouble(AppFonts.scalePrefKey) ?? AppFonts.defaultScale,
);
final initialAccentSeed = await AppAccent.load();
AppBubbleShape.current.value = await AppBubbleShape.load();
AppCacheExtent.current.value = await AppCacheExtent.load();
runApp(
KometApp(
initialLocale: initialLocale,
initialFpsOverlay: initialFpsOverlay,
initialVpnBypass: initialVpnBypass,
initialFontId: initialFontId,
initialFontScale: initialFontScale,
initialAccentSeed: initialAccentSeed,
),
);
}
@@ -52,10 +88,18 @@ class KometApp extends StatefulWidget {
super.key,
required this.initialLocale,
this.initialFpsOverlay = false,
this.initialVpnBypass = false,
required this.initialFontId,
required this.initialFontScale,
this.initialAccentSeed,
});
final Locale initialLocale;
final bool initialFpsOverlay;
final bool initialVpnBypass;
final String initialFontId;
final double initialFontScale;
final Color? initialAccentSeed;
static final navigatorKey = GlobalKey<NavigatorState>();
static KometAppState? stateOf(BuildContext context) {
@@ -70,31 +114,58 @@ class KometAppState extends State<KometApp> {
static const _fallbackSeed = Color(0xFFC1C4FF);
late Locale _locale;
late String _fontId;
bool _isLoggingOut = false;
late final ValueNotifier<Color?> accentSeed = ValueNotifier(
widget.initialAccentSeed,
);
StreamSubscription<SessionExpiredException>? _sessionExpiredSub;
StreamSubscription<LoginStatus>? _loginStatusSub;
StreamSubscription<VpnBypassResult>? _vpnBypassSub;
String? _lastVpnNotice;
DateTime _lastVpnNoticeAt = DateTime.fromMillisecondsSinceEpoch(0);
late final ValueNotifier<bool> fpsOverlayEnabled = ValueNotifier(
widget.initialFpsOverlay,
);
late final ValueNotifier<bool> vpnBypassEnabled = ValueNotifier(
widget.initialVpnBypass,
);
late final ValueNotifier<double> fontScale = ValueNotifier(
widget.initialFontScale,
);
final _profileUpdateController = StreamController<void>.broadcast();
Stream<void> get profileUpdateStream => _profileUpdateController.stream;
@override
void initState() {
super.initState();
_locale = widget.initialLocale;
_fontId = widget.initialFontId;
api.setReconnectCallback(() async {
try {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId != null &&
await TokenStorage.readToken(accountId) != null) {
if (accountId != null) {
final token = await TokenStorage.readToken(accountId);
await accountModule.login(accountId: accountId, token: token);
if (token != null) {
await accountModule.login(accountId: accountId, token: token);
}
}
} catch (_) {}
});
api.sessionExpiredStream.listen((SessionExpiredException e) async {
_loginStatusSub = accountModule.loginStatusStream.listen((status) {
if (status == LoginStatus.success) {
PushService.instance.onLoginSuccess();
}
});
_sessionExpiredSub = api.sessionExpiredStream.listen((SessionExpiredException e) async {
if (_isLoggingOut) return;
_isLoggingOut = true;
await PushService.instance.unregister();
final accountId = await TokenStorage.getActiveAccountId();
if (accountId != null) {
await accountModule.removeAccount(accountId);
@@ -114,11 +185,39 @@ class KometAppState extends State<KometApp> {
}
_isLoggingOut = false;
});
_vpnBypassSub = VpnBypassService.instance.events.listen((r) {
final msg = r.bound
? 'Соединение через VPN не работает — '
'используется ${r.boundInterface ?? r.transport ?? 'прямое подключение'}'
: 'Соединение через VPN не работает, обойти не удалось'
'${r.reason != null ? ' (${r.reason})' : ''}';
final now = DateTime.now();
if (msg == _lastVpnNotice &&
now.difference(_lastVpnNoticeAt).inSeconds < 10) {
return;
}
_lastVpnNotice = msg;
_lastVpnNoticeAt = now;
final overlay = KometApp.navigatorKey.currentState?.overlay;
if (overlay != null) {
showCustomNotificationOnOverlay(overlay, msg);
}
});
}
@override
void dispose() {
_sessionExpiredSub?.cancel();
_loginStatusSub?.cancel();
_vpnBypassSub?.cancel();
_profileUpdateController.close();
fpsOverlayEnabled.dispose();
vpnBypassEnabled.dispose();
fontScale.dispose();
accentSeed.dispose();
super.dispose();
}
@@ -129,6 +228,13 @@ class KometAppState extends State<KometApp> {
await prefs.setBool('dev_fps_overlay', value);
}
Future<void> setVpnBypassEnabled(bool value) async {
if (vpnBypassEnabled.value == value) return;
vpnBypassEnabled.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(VpnBypassService.prefKey, value);
}
Future<void> applyLocale(Locale locale) async {
if (!AppLocalizations.supportedLocales.any(
(l) => l.languageCode == locale.languageCode,
@@ -142,6 +248,94 @@ class KometAppState extends State<KometApp> {
}
}
String get fontId => _fontId;
Future<void> applyAccentColor(Color? seed) async {
await AppAccent.save(seed);
accentSeed.value = seed;
}
Future<void> applyAppFont(String fontId) async {
if (_fontId == fontId) return;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(AppFonts.prefKey, fontId);
if (mounted) {
setState(() => _fontId = fontId);
}
}
Future<void> applyFontScale(double scale, {bool persist = true}) async {
final next = AppFonts.clampScale(scale);
fontScale.value = next;
if (persist) {
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(AppFonts.scalePrefKey, next);
}
}
void notifyProfileUpdate() {
_profileUpdateController.add(null);
}
String? _themeCacheFontId;
ColorScheme? _themeCacheLight;
ColorScheme? _themeCacheDark;
ThemeData? _lightTheme;
ThemeData? _darkTheme;
Color? _seedCacheKey;
ColorScheme? _seedCacheLight;
ColorScheme? _seedCacheDark;
({ColorScheme light, ColorScheme dark}) _schemesForSeed(Color seed) {
if (_seedCacheKey == seed &&
_seedCacheLight != null &&
_seedCacheDark != null) {
return (light: _seedCacheLight!, dark: _seedCacheDark!);
}
_seedCacheKey = seed;
_seedCacheLight = ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.light,
);
_seedCacheDark = ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.dark,
);
return (light: _seedCacheLight!, dark: _seedCacheDark!);
}
void _rebuildThemesIfNeeded(ColorScheme light, ColorScheme dark) {
if (_themeCacheFontId == _fontId &&
_themeCacheLight == light &&
_themeCacheDark == dark) {
return;
}
_themeCacheFontId = _fontId;
_themeCacheLight = light;
_themeCacheDark = dark;
_lightTheme = withM3ETheme(
ThemeData(
useMaterial3: true,
colorScheme: light,
textTheme: AppFonts.textTheme(
_fontId,
ThemeData(brightness: Brightness.light).textTheme,
),
),
);
_darkTheme = withM3ETheme(
ThemeData(
useMaterial3: true,
colorScheme: dark,
textTheme: AppFonts.textTheme(
_fontId,
ThemeData(brightness: Brightness.dark).textTheme,
),
),
);
}
ColorScheme _adjustDarkScheme(ColorScheme base) {
return base.copyWith(
surface: Color.alphaBlend(
@@ -180,60 +374,72 @@ class KometAppState extends State<KometApp> {
Widget build(BuildContext context) {
return DynamicColorBuilder(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
final lightBase =
lightDynamic ??
ColorScheme.fromSeed(
seedColor: _fallbackSeed,
brightness: Brightness.light,
);
final darkBase =
darkDynamic ??
ColorScheme.fromSeed(
seedColor: _fallbackSeed,
brightness: Brightness.dark,
);
return ValueListenableBuilder<Color?>(
valueListenable: accentSeed,
builder: (context, seed, _) {
final ColorScheme lightBase;
final ColorScheme darkBase;
if (seed != null) {
final s = _schemesForSeed(seed);
lightBase = s.light;
darkBase = s.dark;
} else if (lightDynamic != null && darkDynamic != null) {
lightBase = lightDynamic;
darkBase = darkDynamic;
} else {
final s = _schemesForSeed(_fallbackSeed);
lightBase = lightDynamic ?? s.light;
darkBase = darkDynamic ?? s.dark;
}
final lightScheme = _adjustLightScheme(lightBase);
final darkScheme = _adjustDarkScheme(darkBase);
final lightScheme = _adjustLightScheme(lightBase);
final darkScheme = _adjustDarkScheme(darkBase);
return MaterialApp(
title: 'Komet',
debugShowCheckedModeBanner: false,
locale: _locale,
themeMode: ThemeMode.system,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
theme: ThemeData(
useMaterial3: true,
colorScheme: lightScheme,
textTheme: GoogleFonts.interTextTheme(
ThemeData(brightness: Brightness.light).textTheme,
),
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: darkScheme,
textTheme: GoogleFonts.interTextTheme(
ThemeData(brightness: Brightness.dark).textTheme,
),
),
navigatorKey: KometApp.navigatorKey,
builder: (context, child) {
return ValueListenableBuilder<bool>(
valueListenable: fpsOverlayEnabled,
builder: (context, fpsOn, _) {
return Stack(
fit: StackFit.expand,
clipBehavior: Clip.none,
children: [
child ?? const SizedBox.shrink(),
if (fpsOn) const FpsOverlayLayer(),
],
_rebuildThemesIfNeeded(lightScheme, darkScheme);
return MaterialApp(
title: 'Komet',
debugShowCheckedModeBanner: false,
locale: _locale,
themeMode: ThemeMode.system,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
theme: _lightTheme,
darkTheme: _darkTheme,
navigatorKey: KometApp.navigatorKey,
builder: (context, child) {
return ValueListenableBuilder<double>(
valueListenable: fontScale,
child: child ?? const SizedBox.shrink(),
builder: (context, scale, appChild) {
Widget scaledChild = appChild!;
if ((scale - 1.0).abs() > 0.001) {
scaledChild = MediaQuery.withClampedTextScaling(
minScaleFactor: scale,
maxScaleFactor: scale,
child: scaledChild,
);
}
return ValueListenableBuilder<bool>(
valueListenable: fpsOverlayEnabled,
child: scaledChild,
builder: (context, fpsOn, sChild) {
return Stack(
fit: StackFit.expand,
clipBehavior: Clip.none,
children: [
sChild!,
if (fpsOn) const FpsOverlayLayer(),
],
);
},
);
},
);
},
home: const _StartupScreen(),
);
},
home: const _StartupScreen(),
);
},
);
@@ -261,16 +467,16 @@ class _StartupScreenState extends State<_StartupScreen> {
return;
}
try {
await accountModule.login(accountId: accountId);
} catch (_) {}
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const ChatListScreen()),
);
}
try {
await accountModule.login(accountId: accountId);
} catch (_) {}
}
void _goToLogin() {
+50 -24
View File
@@ -136,7 +136,8 @@ class VideoAttachment extends MessageAttachment {
} else if (previewRaw is List) {
try {
final bytes = List<int>.from(previewRaw);
previewStr = String.fromCharCodes(bytes);
final base64 = String.fromCharCodes(bytes);
previewStr = 'data:image/webp;base64,$base64';
} catch (_) {}
}
@@ -192,18 +193,32 @@ class AudioAttachment extends MessageAttachment {
} else if (previewRaw is List) {
try {
final bytes = List<int>.from(previewRaw);
previewStr = String.fromCharCodes(bytes);
final base64 = String.fromCharCodes(bytes);
previewStr = 'data:image/webp;base64,$base64';
} catch (_) {}
}
String? waveStr;
final waveRaw = map['wave'];
if (waveRaw is String) {
waveStr = waveRaw;
} else if (waveRaw is List) {
try {
final bytes = List<int>.from(waveRaw);
final base64 = String.fromCharCodes(bytes);
waveStr = 'data:image/webp;base64,$base64';
} catch (_) {}
}
return AudioAttachment(
previewData: previewStr,
baseUrl: map['baseUrl'] as String?,
baseUrl: map['baseUrl']?.toString(),
fileUrl: map['url']?.toString(),
audioId: map['audioId'] as int?,
audioToken: map['audioToken'] as String?,
audioToken: map['token']?.toString(),
duration: map['duration'] as int?,
size: map['size'] as int?,
waveform: map['waveform'] as String?,
waveform: waveStr,
);
}
@@ -244,7 +259,8 @@ class FileAttachment extends MessageAttachment {
} else if (previewRaw is List) {
try {
final bytes = List<int>.from(previewRaw);
previewStr = String.fromCharCodes(bytes);
final base64 = String.fromCharCodes(bytes);
previewStr = 'data:image/webp;base64,$base64';
} catch (_) {}
}
@@ -294,15 +310,16 @@ class StickerAttachment extends MessageAttachment {
} else if (previewRaw is List) {
try {
final bytes = List<int>.from(previewRaw);
previewStr = String.fromCharCodes(bytes);
final base64 = String.fromCharCodes(bytes);
previewStr = 'data:image/webp;base64,$base64';
} catch (_) {}
}
return StickerAttachment(
previewData: previewStr,
baseUrl: map['baseUrl'] as String?,
stickerId: map['stickerId'] as String?,
stickerPackId: map['stickerPackId'] as String?,
baseUrl: (map['url'] ?? map['baseUrl'])?.toString(),
stickerId: map['stickerId']?.toString(),
stickerPackId: map['setId']?.toString() ?? map['stickerPackId']?.toString(),
width: map['width'] as int?,
height: map['height'] as int?,
);
@@ -344,15 +361,15 @@ class ContactAttachment extends MessageAttachment {
factory ContactAttachment.fromMap(Map<String, dynamic> map) {
return ContactAttachment(
previewData: map['previewData'] as String?,
baseUrl: map['baseUrl'] as String?,
userId: map['userId'] as String?,
firstName: map['firstName'] as String?,
lastName: map['lastName'] as String?,
phoneNumber: map['phoneNumber'] as String?,
photoUrl: map['photoUrl'] as String?,
contactId: map['contactId'] as int?,
name: map['name'] as String?,
previewData: map['previewData']?.toString(),
baseUrl: map['baseUrl']?.toString(),
userId: map['userId']?.toString(),
firstName: map['firstName']?.toString(),
lastName: map['lastName']?.toString(),
phoneNumber: map['phoneNumber']?.toString(),
photoUrl: map['photoUrl']?.toString(),
contactId: map['contactId'] is int ? map['contactId'] as int : int.tryParse(map['contactId']?.toString() ?? ''),
name: map['name']?.toString(),
);
}
@@ -414,6 +431,7 @@ class ControlAttachment extends MessageAttachment {
final String? event;
final String? title;
final List<int>? userIds;
final int? userId;
const ControlAttachment({
super.previewData,
@@ -422,15 +440,22 @@ class ControlAttachment extends MessageAttachment {
this.event,
this.title,
this.userIds,
this.userId,
}) : super(type: AttachmentType.control);
factory ControlAttachment.fromMap(Map<String, dynamic> map) {
String? title = map['title']?.toString();
if ((title == null || title.isEmpty) && map['shortMessage'] != null) {
title = map['shortMessage'].toString();
}
return ControlAttachment(
previewData: map['previewData'] as String?,
baseUrl: map['baseUrl'] as String?,
event: map['event'] as String?,
title: map['title'] as String?,
userIds: (map['userIds'] as List?)?.cast<int>(),
previewData: map['previewData']?.toString(),
baseUrl: map['baseUrl']?.toString(),
event: map['event']?.toString(),
title: title,
userIds: (map['userIds'] as List?)?.map((e) => e is int ? e : int.tryParse(e?.toString() ?? '') ?? 0).toList(),
userId: map['userId'] is int ? map['userId'] as int : int.tryParse(map['userId']?.toString() ?? ''),
);
}
@@ -442,6 +467,7 @@ class ControlAttachment extends MessageAttachment {
'event': event,
'title': title,
'userIds': userIds,
'userId': userId,
};
}
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
+340 -52
View File
@@ -1,6 +1,30 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: "8f89e371e2883de35cdc78f648e725fa4da5f3b6c927269f00fa68f1ea92b598"
url: "https://pub.dev"
source: hosted
version: "1.3.71"
app_bar_m3e:
dependency: transitive
description:
name: app_bar_m3e
sha256: a8cef3d2cfe8d254fac355816ede509e7b437066d9fef8e7ba779a202485bc9f
url: "https://pub.dev"
source: hosted
version: "0.1.2"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
@@ -17,6 +41,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
button_group_m3e:
dependency: transitive
description:
name: button_group_m3e
sha256: bb8ce524f87e806c89abb4cd430425de22cd53feeacc122e3b5da45c76ba2246
url: "https://pub.dev"
source: hosted
version: "0.3.1"
button_m3e:
dependency: transitive
description:
name: button_m3e
sha256: "6754ddeb9068ad2005bd26d5ceabc41268029465095686d7d228296c2e706909"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
cached_network_image:
dependency: "direct main"
description:
name: cached_network_image
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
cached_network_image_platform_interface:
dependency: transitive
description:
name: cached_network_image_platform_interface
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
cached_network_image_web:
dependency: transitive
description:
name: cached_network_image_web
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
characters:
dependency: transitive
description:
@@ -49,6 +113,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev"
source: hosted
version: "0.3.5+2"
crypto:
dependency: transitive
description:
@@ -57,14 +129,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
source: hosted
version: "1.0.9"
dart_lz4:
dependency: "direct main"
description:
@@ -73,6 +137,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
device_info_plus:
dependency: "direct main"
description:
@@ -105,6 +177,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.8"
expressive_refresh:
dependency: transitive
description:
name: expressive_refresh
sha256: "99bb70ae1719ebdedbaf3b4a49031ef7b916da157f7843b8e83efbe6633b20ad"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
fab_m3e:
dependency: transitive
description:
name: fab_m3e
sha256: e4f5abfa3c8c092005449d56dcac45b85e2dbe9c32789d672c5ed71428e43b59
url: "https://pub.dev"
source: hosted
version: "0.1.1"
fake_async:
dependency: transitive
description:
@@ -129,11 +217,83 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
url: "https://pub.dev"
source: hosted
version: "8.3.7"
firebase_core:
dependency: "direct main"
description:
name: firebase_core
sha256: "93a5bde9775fd5adcc937f39dfa04ae0bc89c4d79bea6abc49de3f7b049d9ff6"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
firebase_core_platform_interface:
dependency: transitive
description:
name: firebase_core_platform_interface
sha256: "4a120366dbf7d5a8ee9438978530b664b855728fb8dcc3a201017660817e555b"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
firebase_core_web:
dependency: transitive
description:
name: firebase_core_web
sha256: "7c98f10b8c8e5adedc0b810b66a877120696675e2c22d9ca9caca092da0d9e57"
url: "https://pub.dev"
source: hosted
version: "3.7.0"
firebase_messaging:
dependency: "direct main"
description:
name: firebase_messaging
sha256: "8d0dc81a31cd030170508dc3e89bfd14355b20a1b991340af5f018e37daab5d7"
url: "https://pub.dev"
source: hosted
version: "16.2.2"
firebase_messaging_platform_interface:
dependency: transitive
description:
name: firebase_messaging_platform_interface
sha256: "37abb0b0535c5497605ee94c12470e1ebbbe47e71a22d0c20bffcc912311f8cb"
url: "https://pub.dev"
source: hosted
version: "4.7.11"
firebase_messaging_web:
dependency: transitive
description:
name: firebase_messaging_web
sha256: "54e22b43e2c26a2728a3f68c188de0f9011993ae19ae959a06d476dad935c776"
url: "https://pub.dev"
source: hosted
version: "4.1.7"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_cache_manager:
dependency: transitive
description:
name: flutter_cache_manager
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_lints:
dependency: "direct dev"
description:
@@ -142,59 +302,51 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
url: "https://pub.dev"
source: hosted
version: "21.0.0"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
url: "https://pub.dev"
source: hosted
version: "8.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
url: "https://pub.dev"
source: hosted
version: "11.0.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
url: "https://pub.dev"
source: hosted
version: "10.0.0"
flutter_secure_storage_darwin:
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3"
name: flutter_plugin_android_lifecycle
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
version: "2.0.34"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -253,6 +405,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
icon_button_m3e:
dependency: transitive
description:
name: icon_button_m3e
sha256: c4524d6141a468679821bbb635b833ac6831925d8a6ae4a4511430b0e4ab9c67
url: "https://pub.dev"
source: hosted
version: "0.2.1"
intl:
dependency: "direct main"
description:
@@ -285,6 +445,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.2"
libcompress:
dependency: "direct main"
description:
name: libcompress
sha256: "1f55be8dc9e622efa1584ad899e05880d71469b7107f35be1858e91d0fadf1d4"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
lints:
dependency: transitive
description:
@@ -293,6 +461,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.0"
loading_indicator_m3e:
dependency: transitive
description:
name: loading_indicator_m3e
sha256: "3fe97385b4f84382c25b901abf88fcee4200e6e78fcd5ed7e037a34d7db53038"
url: "https://pub.dev"
source: hosted
version: "0.1.1"
logger:
dependency: "direct main"
description:
@@ -309,6 +485,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
m3e_collection:
dependency: "direct main"
description:
name: m3e_collection
sha256: "623079bd3fe39bac1a3a10656647cdef6a8b2aa8bb8ee5ff99779facd05b18f6"
url: "https://pub.dev"
source: hosted
version: "0.3.7"
m3e_design:
dependency: transitive
description:
name: m3e_design
sha256: "15ff0ef4c43553d855c5e866a9aee8231d44919fe2bb354b1259337bdfd659b4"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
matcher:
dependency: transitive
description:
@@ -325,6 +517,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.13.0"
material_new_shapes:
dependency: transitive
description:
name: material_new_shapes
sha256: e4bc375205e187e8fb232573387112dd8c0dd45b03af8aa2b3c79eb4b9e3e0dc
url: "https://pub.dev"
source: hosted
version: "1.0.0"
material_symbols_icons:
dependency: "direct main"
description:
@@ -365,6 +565,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.17.6"
navigation_bar_m3e:
dependency: transitive
description:
name: navigation_bar_m3e
sha256: "20d0ce28c783fd7b530e542e1e62c9edc402028303af08667fcbfd89162c83b2"
url: "https://pub.dev"
source: hosted
version: "0.1.1"
navigation_rail_m3e:
dependency: transitive
description:
name: navigation_rail_m3e
sha256: "029a1f556c4ecaf8318ae7d4ad09af117bdb449e483e3d9ec04085a9e341db3d"
url: "https://pub.dev"
source: hosted
version: "0.3.5"
objective_c:
dependency: transitive
description:
@@ -373,6 +589,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "9.3.0"
octo_image:
dependency: transitive
description:
name: octo_image
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
package_info_plus:
dependency: "direct main"
description:
@@ -445,6 +669,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
@@ -461,6 +693,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
progress_indicator_m3e:
dependency: transitive
description:
name: progress_indicator_m3e
sha256: "1a2ca029d14427d31093552a0e916b453f58643b78ef805ce6f4669845ac8f70"
url: "https://pub.dev"
source: hosted
version: "0.1.1"
pub_semver:
dependency: transitive
description:
@@ -469,6 +709,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shared_preferences:
dependency: "direct main"
description:
@@ -530,6 +778,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
slider_m3e:
dependency: transitive
description:
name: slider_m3e
sha256: e4c25e94b46ebf3164f407e9db6cce70aa51b92db5143d720f6e55a2a709aa67
url: "https://pub.dev"
source: hosted
version: "0.1.1"
source_span:
dependency: transitive
description:
@@ -538,6 +794,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.2"
split_button_m3e:
dependency: transitive
description:
name: split_button_m3e
sha256: "8864b612e2475cf8d070783d5d834c8da98b7489365e4597e5b4f6ef85d50f66"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
sqflite:
dependency: "direct main"
description:
@@ -650,6 +914,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.11.0"
toolbar_m3e:
dependency: transitive
description:
name: toolbar_m3e
sha256: "5a02108fc47d5b14dc645fd52d3cbdbd7cc3c2c60c8410c5a69d9cf7ac852cc9"
url: "https://pub.dev"
source: hosted
version: "0.1.1"
typed_data:
dependency: transitive
description:
@@ -658,6 +930,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math:
dependency: transitive
description:
@@ -706,6 +986,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
+6 -2
View File
@@ -36,24 +36,28 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
dart_lz4: ^1.0.0
libcompress: ^1.0.0
msgpack_dart: ^1.0.1
logger: ^2.6.2
device_info_plus: 12.3.0
flutter_timezone: ^5.0.1
timezone: ^0.11.0
flutter_secure_storage: ^10.0.0
file_picker: ^8.0.0
sqflite: ^2.4.2
sqflite_common_ffi: ^2.4.0+2
path: ^1.9.1
google_fonts: ^6.2.1
m3e_collection: ^0.3.7
material_symbols_icons: ^4.2906.0
dynamic_color: ^1.8.1
shared_preferences: ^2.5.4
package_info_plus: ^9.0.1
mobile_scanner: ^7.2.0
cached_network_image: ^3.4.1
firebase_core: ^4.1.1
firebase_messaging: ^16.0.2
flutter_local_notifications: ^21.0.0
dev_dependencies:
flutter_test: