Merge pull request #20 from KometTeam/feature/FullStack
Feature/full stack
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.*
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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' ||
|
||||
|
||||
@@ -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>?)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -8,7 +8,7 @@ class PacketSender {
|
||||
int get currentSeq => _seq;
|
||||
|
||||
int _nextSeq() {
|
||||
_seq = (_seq + 1) % 256;
|
||||
_seq = (_seq + 1) % 65536;
|
||||
return _seq;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+340
-52
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user