Merge pull request #32 from KometTeam/feature/FullStack
ну тут много всего
@@ -0,0 +1,293 @@
|
||||
name: Release (main)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
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 libsecret-1-dev libjsoncpp-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: Install ldid
|
||||
run: brew install ldid
|
||||
- name: Pseudo-sign with ldid
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APP="build/ios/iphoneos/Runner.app"
|
||||
ENT="$(mktemp -t ent).plist"
|
||||
cat > "$ENT" <<'PLIST'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>platform-application</key><true/>
|
||||
<key>get-task-allow</key><true/>
|
||||
<key>com.apple.private.security.no-container</key><true/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
if [ -d "$APP/Frameworks" ]; then
|
||||
find "$APP/Frameworks" -type f -name "*.dylib" -print0 | xargs -0 -I{} ldid -S "{}"
|
||||
find "$APP/Frameworks" -type d -name "*.framework" | while read -r fw; do
|
||||
bin="$fw/$(basename "$fw" .framework)"
|
||||
[ -f "$bin" ] && ldid -S "$bin"
|
||||
done
|
||||
fi
|
||||
if [ -d "$APP/PlugIns" ]; then
|
||||
find "$APP/PlugIns" -type d -name "*.appex" | while read -r ext; do
|
||||
bin="$ext/$(basename "$ext" .appex)"
|
||||
[ -f "$bin" ] && ldid -S"$ENT" "$bin"
|
||||
done
|
||||
fi
|
||||
ldid -S"$ENT" "$APP/Runner"
|
||||
- name: Package 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-pseudosigned.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:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Compute version
|
||||
id: ver
|
||||
run: |
|
||||
PUBSPEC_VERSION=$(grep '^version:' pubspec.yaml | awk '{print $2}' | cut -d'+' -f1)
|
||||
echo "tag=v${PUBSPEC_VERSION}+${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||
echo "name=Komet ${PUBSPEC_VERSION} (сборка ${GITHUB_RUN_NUMBER})" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: List artifacts
|
||||
run: ls -lhR dist
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.ver.outputs.tag }}
|
||||
name: ${{ steps.ver.outputs.name }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: true
|
||||
body: |
|
||||
Автоматическая сборка из ветки `${{ github.ref_name }}` @ `${{ github.sha }}`.
|
||||
|
||||
## Какой файл качать
|
||||
|
||||
### Android — версия Komet (без push-уведомлений)
|
||||
- **`Komet-android-komet-arm64-v8a.apk`** — рекомендуется для большинства современных телефонов (64-битный ARM).
|
||||
- `Komet-android-komet-universal.apk` — универсальный APK, работает везде, но весит больше.
|
||||
- `Komet-android-komet-armeabi-v7a.apk` — для старых 32-битных устройств.
|
||||
- `Komet-android-komet-x86_64.apk` — для эмуляторов и редких x86-устройств.
|
||||
- `Komet-android-komet.aab` — App Bundle для публикации в Google Play (не для ручной установки).
|
||||
|
||||
### Android — версия OneMe (с push-уведомлениями через Firebase)
|
||||
- **`Komet-android-oneme-arm64-v8a.apk`** — рекомендуется для большинства современных телефонов.
|
||||
- `Komet-android-oneme-universal.apk` — универсальный APK.
|
||||
- `Komet-android-oneme-armeabi-v7a.apk` — для старых 32-битных устройств.
|
||||
- `Komet-android-oneme-x86_64.apk` — для эмуляторов и x86-устройств.
|
||||
- `Komet-android-oneme.aab` — App Bundle для Google Play.
|
||||
|
||||
### Windows
|
||||
- **`Komet-windows-x64.zip`** — портативная сборка для Windows 10/11 (x64). Распаковать и запустить `Komet.exe`.
|
||||
|
||||
### Linux
|
||||
- **`Komet-linux-x64.tar.gz`** — сборка для 64-битного Linux. Требуются `libgtk-3`, `libsecret-1`, `libjsoncpp`. Распаковать и запустить бинарник.
|
||||
|
||||
### macOS
|
||||
- **`Komet-macos.zip`** — `.app` для macOS. Распаковать и перетащить в `/Applications`. Приложение не нотаризовано — при первом запуске разрешите через «Системные настройки → Защита и безопасность».
|
||||
|
||||
### iOS
|
||||
- **`Komet-ios-pseudosigned.ipa`** — IPA с псевдо-подписью (ldid).
|
||||
⚠️ Не устанавливается через App Store. Подходит для:
|
||||
- jailbreak (Filza, rootless),
|
||||
- TrollStore,
|
||||
- сайдлоада через AltStore / Sideloadly (потребуется ваш Apple ID для повторной подписи).
|
||||
files: dist/*
|
||||
@@ -133,4 +133,9 @@ agents.md
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.*
|
||||
.env.*
|
||||
# Локальные дампы трафика и скрипты анализа (содержат секреты)
|
||||
komet.txt
|
||||
original_app.txt
|
||||
fingerprint.py
|
||||
PCAPdroid_*.txt
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
|
||||
Copyright (c) 2005-2014, The Android Open Source Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
<application
|
||||
android:label="komet"
|
||||
android:label="Komet"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
@@ -24,13 +26,37 @@
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
</activity>
|
||||
<activity-alias
|
||||
android:name=".DefaultIcon"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="Komet"
|
||||
android:targetActivity=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</activity-alias>
|
||||
<activity-alias
|
||||
android:name=".MinimalIcon"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_minimal"
|
||||
android:label="Komet"
|
||||
android:targetActivity=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<service
|
||||
android:name=".UploadForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:exported="false" />
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package ru.komet.app
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
@@ -18,11 +22,32 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
class MainActivity : FlutterActivity() {
|
||||
|
||||
private val channelName = "ru.komet.app/vpn_bypass"
|
||||
private val iconAliases = listOf("DefaultIcon", "MinimalIcon")
|
||||
|
||||
private companion object {
|
||||
const val LOG_TAG = "VpnBypass"
|
||||
}
|
||||
|
||||
private fun applyIcon(name: String) {
|
||||
val pm = packageManager
|
||||
for (alias in iconAliases) {
|
||||
val component = ComponentName(packageName, "$packageName.$alias")
|
||||
val state = if (alias == name) {
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
} else {
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||
}
|
||||
pm.setComponentEnabledSetting(
|
||||
component,
|
||||
state,
|
||||
PackageManager.DONT_KILL_APP,
|
||||
)
|
||||
}
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
finishAndRemoveTask()
|
||||
}, 250L)
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
MethodChannel(
|
||||
@@ -36,6 +61,70 @@ class MainActivity : FlutterActivity() {
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
"ru.komet.app/app_icon",
|
||||
).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"setAppIcon" -> {
|
||||
val name = call.argument<String>("name")
|
||||
if (name == null || !iconAliases.contains(name)) {
|
||||
result.error("INVALID_ICON", "Unknown icon: $name", null)
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
try {
|
||||
applyIcon(name)
|
||||
result.success(null)
|
||||
} catch (e: Exception) {
|
||||
result.error("APPLY_FAILED", e.message, null)
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
"ru.komet.app/upload_service",
|
||||
).setMethodCallHandler { call, result ->
|
||||
val ctx = this
|
||||
when (call.method) {
|
||||
"start" -> {
|
||||
val filename = call.argument<String>("filename") ?: "Файл"
|
||||
val intent = Intent(ctx, UploadForegroundService::class.java).apply {
|
||||
action = UploadForegroundService.ACTION_START
|
||||
putExtra(UploadForegroundService.EXTRA_FILENAME, filename)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(intent)
|
||||
} else {
|
||||
startService(intent)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"update" -> {
|
||||
val filename = call.argument<String>("filename") ?: "Файл"
|
||||
val progress = call.argument<Int>("progress") ?: 0
|
||||
val speed = call.argument<Long>("speed") ?: 0L
|
||||
val intent = Intent(ctx, UploadForegroundService::class.java).apply {
|
||||
action = UploadForegroundService.ACTION_UPDATE
|
||||
putExtra(UploadForegroundService.EXTRA_FILENAME, filename)
|
||||
putExtra(UploadForegroundService.EXTRA_PROGRESS, progress)
|
||||
putExtra(UploadForegroundService.EXTRA_SPEED, speed)
|
||||
}
|
||||
startService(intent)
|
||||
result.success(null)
|
||||
}
|
||||
"stop" -> {
|
||||
startService(Intent(ctx, UploadForegroundService::class.java).apply {
|
||||
action = UploadForegroundService.ACTION_STOP
|
||||
})
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun connectivityManager(): ConnectivityManager =
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package ru.komet.app
|
||||
|
||||
import android.app.*
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
class UploadForegroundService : Service() {
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "komet_upload"
|
||||
const val NOTIFICATION_ID = 9001
|
||||
const val ACTION_START = "ru.komet.app.UPLOAD_START"
|
||||
const val ACTION_UPDATE = "ru.komet.app.UPLOAD_UPDATE"
|
||||
const val ACTION_STOP = "ru.komet.app.UPLOAD_STOP"
|
||||
const val EXTRA_FILENAME = "filename"
|
||||
const val EXTRA_PROGRESS = "progress" // 0-100
|
||||
const val EXTRA_SPEED = "speed" // bytes/sec (Long)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START -> {
|
||||
val filename = intent.getStringExtra(EXTRA_FILENAME) ?: "Файл"
|
||||
val notification = buildNotification(filename, 0, 0, indeterminate = true)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
ACTION_UPDATE -> {
|
||||
val filename = intent.getStringExtra(EXTRA_FILENAME) ?: "Файл"
|
||||
val progress = intent.getIntExtra(EXTRA_PROGRESS, 0)
|
||||
val speed = intent.getLongExtra(EXTRA_SPEED, 0L)
|
||||
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.notify(NOTIFICATION_ID, buildNotification(filename, progress, speed))
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Загрузка файлов",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply { setShowBadge(false) }
|
||||
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager)
|
||||
.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(
|
||||
filename: String,
|
||||
progress: Int,
|
||||
speedBps: Long,
|
||||
indeterminate: Boolean = false
|
||||
): Notification {
|
||||
val body = when {
|
||||
indeterminate -> "Подготовка..."
|
||||
speedBps > 0 -> "$progress% · ${formatSpeed(speedBps)}"
|
||||
else -> "$progress%"
|
||||
}
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_upload)
|
||||
.setContentTitle(filename)
|
||||
.setContentText(body)
|
||||
.setProgress(100, progress, indeterminate)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSilent(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun formatSpeed(bps: Long): String = when {
|
||||
bps < 1_024L -> "$bps Б/с"
|
||||
bps < 1_048_576L -> "${bps / 1024} КБ/с"
|
||||
else -> "${"%.1f".format(bps / 1_048_576.0)} МБ/с"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 49 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_minimal_background"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_minimal_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#000000</color>
|
||||
<color name="ic_launcher_minimal_background">#000000</color>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 63 KiB |
@@ -15,6 +15,8 @@
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
B1B2C3D41234567890ABCDEF /* MinimalIcon@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = A1B2C3D41234567890ABCDEF /* MinimalIcon@2x.png */; };
|
||||
B1B2C3D41234567890ABCDF0 /* MinimalIcon@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = A1B2C3D41234567890ABCDF0 /* MinimalIcon@3x.png */; };
|
||||
E835CC948F2F948DC3BBF405 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2AE8CB2654009FE276CACA4B /* Pods_RunnerTests.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -62,6 +64,8 @@
|
||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
A1B2C3D41234567890ABCDEF /* MinimalIcon@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "MinimalIcon@2x.png"; sourceTree = "<group>"; };
|
||||
A1B2C3D41234567890ABCDF0 /* MinimalIcon@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "MinimalIcon@3x.png"; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
9D7B0FFE63E0BD5D67D7758D /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
@@ -143,6 +147,8 @@
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||
97C147021CF9000F007C117D /* Info.plist */,
|
||||
A1B2C3D41234567890ABCDEF /* MinimalIcon@2x.png */,
|
||||
A1B2C3D41234567890ABCDF0 /* MinimalIcon@3x.png */,
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
@@ -264,6 +270,8 @@
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||
B1B2C3D41234567890ABCDEF /* MinimalIcon@2x.png in Resources */,
|
||||
B1B2C3D41234567890ABCDF0 /* MinimalIcon@3x.png in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -539,7 +547,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
@@ -596,7 +604,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
|
||||
@@ -8,6 +8,43 @@ import UIKit
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
|
||||
let controller = window?.rootViewController as? FlutterViewController
|
||||
if let messenger = controller?.binaryMessenger {
|
||||
let channel = FlutterMethodChannel(
|
||||
name: "ru.komet.app/app_icon",
|
||||
binaryMessenger: messenger
|
||||
)
|
||||
channel.setMethodCallHandler { (call, result) in
|
||||
guard call.method == "setAppIcon" else {
|
||||
result(FlutterMethodNotImplemented)
|
||||
return
|
||||
}
|
||||
let args = call.arguments as? [String: Any]
|
||||
let name = args?["name"] as? String
|
||||
let iconName: String? = (name == "DefaultIcon") ? nil : name
|
||||
if !UIApplication.shared.supportsAlternateIcons {
|
||||
result(FlutterError(
|
||||
code: "UNSUPPORTED",
|
||||
message: "Alternate icons are not supported",
|
||||
details: nil
|
||||
))
|
||||
return
|
||||
}
|
||||
UIApplication.shared.setAlternateIconName(iconName) { error in
|
||||
if let error = error {
|
||||
result(FlutterError(
|
||||
code: "APPLY_FAILED",
|
||||
message: error.localizedDescription,
|
||||
details: nil
|
||||
))
|
||||
} else {
|
||||
result(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,122 +1 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 439 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 700 B |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 7.8 KiB |
@@ -13,7 +13,22 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>komet</string>
|
||||
<string>Komet</string>
|
||||
<key>CFBundleIcons</key>
|
||||
<dict>
|
||||
<key>CFBundleAlternateIcons</key>
|
||||
<dict>
|
||||
<key>MinimalIcon</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>MinimalIcon</string>
|
||||
</array>
|
||||
<key>UIPrerenderedIcon</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -39,6 +39,12 @@ class Api {
|
||||
|
||||
Map<dynamic, dynamic>? get userAgent => _userAgent;
|
||||
|
||||
int? _callsSeed;
|
||||
String? _deviceId;
|
||||
|
||||
int? get callsSeed => _callsSeed;
|
||||
String? get deviceId => _deviceId;
|
||||
|
||||
List<CountryName>? _registrationCountries;
|
||||
|
||||
List<CountryName> get registrationCountries =>
|
||||
@@ -111,6 +117,7 @@ class Api {
|
||||
try {
|
||||
final response = await sendHandshake();
|
||||
if (response.isOk) {
|
||||
_callsSeed = response.payload['callsSeed'] as int?;
|
||||
_registrationCountries = _parseRegistrationCountries(response.payload);
|
||||
_setSessionState(SessionState.online);
|
||||
_startPinging();
|
||||
@@ -164,7 +171,7 @@ class Api {
|
||||
String architecture = 'arm64';
|
||||
String appVersion = SpoofingService.hardcodedAppVersion;
|
||||
int buildNumber = SpoofingService.hardcodedBuildNumber;
|
||||
String screen = '1920x1080';
|
||||
String screen = '420dpi 420dpi 1080x2340';
|
||||
|
||||
tz.initializeTimeZones();
|
||||
final timeZoneName = await FlutterTimezone.getLocalTimezone();
|
||||
@@ -230,23 +237,25 @@ class Api {
|
||||
|
||||
_userAgent = {
|
||||
'deviceType': deviceType,
|
||||
'locale': locale,
|
||||
'deviceLocale': deviceLocale,
|
||||
'osVersion': osVersion,
|
||||
'deviceName': deviceName,
|
||||
'appVersion': appVersion,
|
||||
'screen': screen,
|
||||
'osVersion': osVersion,
|
||||
'timezone': timezone,
|
||||
'screen': screen,
|
||||
'pushDeviceType': 'GCM',
|
||||
'arch': architecture,
|
||||
'locale': locale,
|
||||
'buildNumber': buildNumber,
|
||||
'deviceName': deviceName,
|
||||
'deviceLocale': deviceLocale,
|
||||
};
|
||||
|
||||
_deviceId = deviceId;
|
||||
|
||||
final payload = <dynamic, dynamic>{
|
||||
'mt_instanceid': await DeviceIdentity.instanceId(),
|
||||
'userAgent': _userAgent,
|
||||
'clientSessionId': DeviceIdentity.clientSessionId,
|
||||
'deviceId': deviceId,
|
||||
'userAgent': _userAgent,
|
||||
};
|
||||
|
||||
return sendRequest(Opcode.sessionInit, payload);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import '../api.dart';
|
||||
import '../../core/protocol/chat_cache_fingerprint.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
@@ -9,6 +11,7 @@ import '../../core/utils/logger.dart';
|
||||
import 'chats.dart';
|
||||
import 'contacts.dart';
|
||||
import 'folders.dart';
|
||||
import 'messages.dart';
|
||||
|
||||
String _normalizeAuthPhone(String phone) {
|
||||
final digits = phone.replaceAll(RegExp(r'\D'), '');
|
||||
@@ -222,6 +225,20 @@ class RequestCodeResult {
|
||||
const RequestCodeResult({required this.token});
|
||||
}
|
||||
|
||||
class PresetAvatar {
|
||||
final int id;
|
||||
final String url;
|
||||
|
||||
const PresetAvatar({required this.id, required this.url});
|
||||
}
|
||||
|
||||
class PresetAvatarCategory {
|
||||
final String name;
|
||||
final List<PresetAvatar> avatars;
|
||||
|
||||
const PresetAvatarCategory({required this.name, required this.avatars});
|
||||
}
|
||||
|
||||
class VerifyCodeResult {
|
||||
final Map<dynamic, dynamic> payload;
|
||||
|
||||
@@ -231,6 +248,37 @@ class VerifyCodeResult {
|
||||
|
||||
String? get registerToken => _nestedToken('REGISTER');
|
||||
|
||||
bool get isRegistration => registerToken != null && loginToken == null;
|
||||
|
||||
List<PresetAvatarCategory> get presetAvatars {
|
||||
final raw = payload['presetAvatars'];
|
||||
if (raw is! List) return const [];
|
||||
final categories = <PresetAvatarCategory>[];
|
||||
for (final cat in raw) {
|
||||
if (cat is! Map) continue;
|
||||
final avatarsRaw = cat['avatars'];
|
||||
if (avatarsRaw is! List) continue;
|
||||
final avatars = <PresetAvatar>[];
|
||||
for (final a in avatarsRaw) {
|
||||
if (a is! Map) continue;
|
||||
final id = a['id'];
|
||||
final url = a['url'];
|
||||
if (id is int && url is String && url.isNotEmpty) {
|
||||
avatars.add(PresetAvatar(id: id, url: url));
|
||||
}
|
||||
}
|
||||
if (avatars.isNotEmpty) {
|
||||
categories.add(
|
||||
PresetAvatarCategory(
|
||||
name: cat['name']?.toString() ?? '',
|
||||
avatars: avatars,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return categories;
|
||||
}
|
||||
|
||||
bool get requiresPassword => payload['passwordChallenge'] != null;
|
||||
|
||||
Map<dynamic, dynamic>? get passwordChallenge {
|
||||
@@ -483,7 +531,7 @@ class AccountModule {
|
||||
return newProfile;
|
||||
}
|
||||
|
||||
Future<ProfileData> updateProfileAvatar(String photoToken, String avatarType) async {
|
||||
Future<ProfileData> updateProfileAvatar(String photoToken, {String avatarType = 'USER_AVATAR'}) async {
|
||||
_ensureOnline();
|
||||
final packet = await _api.sendRequest(Opcode.profile, {
|
||||
'photoToken': photoToken,
|
||||
@@ -620,15 +668,17 @@ class AccountModule {
|
||||
String? hint,
|
||||
}) async {
|
||||
_ensureOnline();
|
||||
final capabilities = <int>[0, if (hint != null) 3, 4];
|
||||
final payload = <dynamic, dynamic>{
|
||||
'expectedCapabilities': [0, 3, 4],
|
||||
'expectedCapabilities': capabilities,
|
||||
'trackId': trackId,
|
||||
'password': password,
|
||||
};
|
||||
if (hint != null) payload['hint'] = hint;
|
||||
final packet = await _api.sendRequest(Opcode.authSet2fa, payload);
|
||||
_checkPacketError(packet, 'confirm2fa');
|
||||
return _processProfileUpdate(packet);
|
||||
return _processProfileUpdate(
|
||||
_api.sendRequest(Opcode.authSet2fa, payload),
|
||||
'confirm2fa',
|
||||
);
|
||||
}
|
||||
|
||||
// 2FA Management (when already set)
|
||||
@@ -669,6 +719,11 @@ class AccountModule {
|
||||
);
|
||||
}
|
||||
|
||||
Future<TwoFactorDetails> get2faStatus() async {
|
||||
final trackId = await enter2faPanel();
|
||||
return get2faDetails(trackId);
|
||||
}
|
||||
|
||||
Future<void> check2faPassword(String trackId, String password) async {
|
||||
_ensureOnline();
|
||||
final packet = await _api.sendRequest(Opcode.authLoginCheckPassword, {
|
||||
@@ -706,15 +761,16 @@ class AccountModule {
|
||||
}
|
||||
|
||||
final payload = <dynamic, dynamic>{
|
||||
'expectedCapabilities': [1, 3],
|
||||
'expectedCapabilities': <int>[1, if (hint != null) 3],
|
||||
'trackId': trackId,
|
||||
'password': newPassword,
|
||||
};
|
||||
if (hint != null) payload['hint'] = hint;
|
||||
|
||||
final packet = await _api.sendRequest(Opcode.authSet2fa, payload);
|
||||
_checkPacketError(packet, 'update2faPassword');
|
||||
return _processProfileUpdate(packet);
|
||||
return _processProfileUpdate(
|
||||
_api.sendRequest(Opcode.authSet2fa, payload),
|
||||
'update2faPassword',
|
||||
);
|
||||
}
|
||||
|
||||
Future<ProfileData> update2faEmail({
|
||||
@@ -739,9 +795,10 @@ class AccountModule {
|
||||
'expectedCapabilities': [4],
|
||||
'trackId': trackId,
|
||||
};
|
||||
final packet = await _api.sendRequest(Opcode.authSet2fa, payload);
|
||||
_checkPacketError(packet, 'update2faEmail');
|
||||
return _processProfileUpdate(packet);
|
||||
return _processProfileUpdate(
|
||||
_api.sendRequest(Opcode.authSet2fa, payload),
|
||||
'update2faEmail',
|
||||
);
|
||||
}
|
||||
|
||||
Future<ProfileData> remove2fa(String trackId) async {
|
||||
@@ -751,34 +808,46 @@ class AccountModule {
|
||||
'trackId': trackId,
|
||||
'remove2fa': true,
|
||||
};
|
||||
final packet = await _api.sendRequest(Opcode.authSet2fa, payload);
|
||||
_checkPacketError(packet, 'remove2fa');
|
||||
return _processProfileUpdate(packet);
|
||||
return _processProfileUpdate(
|
||||
_api.sendRequest(Opcode.authSet2fa, payload),
|
||||
'remove2fa',
|
||||
);
|
||||
}
|
||||
|
||||
Future<ProfileData> _processProfileUpdate(Packet packet) async {
|
||||
_api.registerPushHandler(Opcode.notifProfile, (p) {});
|
||||
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>());
|
||||
}
|
||||
}
|
||||
}
|
||||
Future<ProfileData> _processProfileUpdate(
|
||||
Future<Packet> requestFuture,
|
||||
String tag,
|
||||
) async {
|
||||
final completer = Completer<ProfileData>();
|
||||
final sub = _api.pushStream
|
||||
.where((p) => p.opcode == Opcode.notifProfile)
|
||||
.listen((push) {
|
||||
if (completer.isCompleted) return;
|
||||
final payload = push.payload;
|
||||
if (payload is! Map) return;
|
||||
final profile = payload['profile'];
|
||||
if (profile is! Map) return;
|
||||
final contact = profile['contact'];
|
||||
if (contact is! Map) return;
|
||||
completer.complete(
|
||||
ProfileData.fromServerMap(contact.cast<dynamic, dynamic>()),
|
||||
);
|
||||
});
|
||||
final timer = Timer(const Duration(seconds: 15), () {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(
|
||||
Exception('Таймаут ожидания обновления профиля'),
|
||||
);
|
||||
}
|
||||
} on TimeoutException {
|
||||
throw Exception('Таймаут ожидания обновления профиля');
|
||||
});
|
||||
try {
|
||||
final packet = await requestFuture;
|
||||
_checkPacketError(packet, tag);
|
||||
return await completer.future;
|
||||
} finally {
|
||||
_api.unregisterPushHandler(Opcode.notifProfile);
|
||||
timer.cancel();
|
||||
await sub.cancel();
|
||||
}
|
||||
throw Exception('Не удалось получить обновлённый профиль');
|
||||
}
|
||||
|
||||
Future<RequestCodeResult> requestCode(
|
||||
@@ -826,6 +895,61 @@ class AccountModule {
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<int> completeRegistration({
|
||||
required String token,
|
||||
required String firstName,
|
||||
String? lastName,
|
||||
int? photoId,
|
||||
}) async {
|
||||
_ensureOnline();
|
||||
|
||||
final payload = <dynamic, dynamic>{
|
||||
'token': token,
|
||||
'tokenType': AuthRequestType.register.value,
|
||||
'firstName': firstName,
|
||||
};
|
||||
if (lastName != null && lastName.isNotEmpty) {
|
||||
payload['lastName'] = lastName;
|
||||
}
|
||||
if (photoId != null) {
|
||||
payload['photoId'] = photoId;
|
||||
payload['avatarType'] = 'PRESET_AVATAR';
|
||||
}
|
||||
|
||||
logger.i('Завершение регистрации (opcode=${Opcode.authConfirm})');
|
||||
|
||||
final packet = await _api.sendRequest(Opcode.authConfirm, payload);
|
||||
|
||||
_checkPacketError(packet, 'completeRegistration');
|
||||
|
||||
final data = packet.payload;
|
||||
if (data is! Map) {
|
||||
throw Exception(
|
||||
'completeRegistration: неожиданный тип payload: ${data.runtimeType}',
|
||||
);
|
||||
}
|
||||
|
||||
final profileMap = data['profile'];
|
||||
if (profileMap is! Map) {
|
||||
throw Exception('completeRegistration: отсутствует profile в ответе');
|
||||
}
|
||||
final contact = profileMap['contact'];
|
||||
if (contact is! Map) {
|
||||
throw Exception('completeRegistration: отсутствует profile.contact');
|
||||
}
|
||||
final accountId = contact['id'] as int?;
|
||||
if (accountId == null) {
|
||||
throw Exception('completeRegistration: отсутствует id аккаунта');
|
||||
}
|
||||
|
||||
final profile = ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
|
||||
await AppDatabase.saveProfile(profile, isActive: true);
|
||||
await TokenStorage.setActiveAccount(accountId);
|
||||
|
||||
logger.i('Регистрация завершена, accountId=$accountId');
|
||||
return accountId;
|
||||
}
|
||||
|
||||
Future<LoginResult> login({
|
||||
int? accountId,
|
||||
String? token,
|
||||
@@ -920,19 +1044,54 @@ class AccountModule {
|
||||
_checkPacketError(packet, 'authorizeWebQrLogin');
|
||||
}
|
||||
|
||||
Future<void> beginAddAccount() async {
|
||||
try {
|
||||
await _api.disconnect();
|
||||
} catch (_) {}
|
||||
|
||||
await TokenStorage.clearActiveAccount();
|
||||
|
||||
ContactCache.clear();
|
||||
TranscriptionCache.clear();
|
||||
ChatsModule.resetForAccountSwitch();
|
||||
|
||||
logger.i('Добавление аккаунта: сессия сброшена, активный аккаунт очищен');
|
||||
}
|
||||
|
||||
Future<ProfileData> switchAccount(int accountId) async {
|
||||
final profile = await AppDatabase.loadProfile(accountId);
|
||||
if (profile == null) {
|
||||
throw StateError('switchAccount: аккаунт $accountId не найден в базе');
|
||||
}
|
||||
final token = await TokenStorage.readToken(accountId);
|
||||
if (token == null) {
|
||||
throw StateError('switchAccount: нет токена для аккаунта $accountId');
|
||||
}
|
||||
|
||||
try {
|
||||
await _api.disconnect();
|
||||
} catch (_) {}
|
||||
|
||||
await AppDatabase.setActiveAccount(accountId);
|
||||
await TokenStorage.setActiveAccount(accountId);
|
||||
|
||||
ContactCache.clear();
|
||||
TranscriptionCache.clear();
|
||||
ChatsModule.resetForAccountSwitch();
|
||||
await ContactsModule.primeCacheFromDb(accountId);
|
||||
|
||||
try {
|
||||
await _api.connect();
|
||||
} catch (_) {}
|
||||
|
||||
logger.i('Активный аккаунт переключён на $accountId');
|
||||
return profile;
|
||||
}
|
||||
|
||||
Future<List<ProfileData>> listAccounts() async {
|
||||
return AppDatabase.loadAllProfiles();
|
||||
}
|
||||
|
||||
Future<void> removeAccount(int accountId) async {
|
||||
await AppDatabase.deleteAccount(accountId);
|
||||
await TokenStorage.deleteAccount(accountId);
|
||||
@@ -996,9 +1155,18 @@ class AccountModule {
|
||||
final payload = <dynamic, dynamic>{
|
||||
'token': token,
|
||||
'interactive': true,
|
||||
if (_api.userAgent != null) 'userAgent': _api.userAgent,
|
||||
'exp': {
|
||||
'chatsCountGroups': Uint8List.fromList([0x0b, 0x32]),
|
||||
},
|
||||
};
|
||||
|
||||
final callsSeed = _api.callsSeed;
|
||||
final deviceId = _api.deviceId;
|
||||
if (callsSeed != null && deviceId != null) {
|
||||
payload['chatCacheFingerprint'] =
|
||||
ChatCacheFingerprint.compute(callsSeed, deviceId);
|
||||
}
|
||||
|
||||
if (sync != null) {
|
||||
payload['presenceSync'] = sync.presenceSync;
|
||||
payload['chatsSync'] = sync.chatsSync;
|
||||
@@ -1008,9 +1176,6 @@ class AccountModule {
|
||||
payload['bannersSync'] = sync.bannersSync;
|
||||
payload['lastLogin'] = sync.lastLogin;
|
||||
if (sync.configHash != null) payload['configHash'] = sync.configHash;
|
||||
if (sync.chatCacheFingerprint != null) {
|
||||
payload['chatCacheFingerprint'] = sync.chatCacheFingerprint;
|
||||
}
|
||||
} else {
|
||||
payload['presenceSync'] = 0;
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.dart';
|
||||
import '../../core/cache/info_cache.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/storage/token_storage.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../api.dart';
|
||||
import 'folders.dart';
|
||||
import 'messages.dart' show ContactCache;
|
||||
import 'messages.dart' show ContactCache, CachedMessage;
|
||||
|
||||
Map<int, int> _parseParticipants(dynamic raw) {
|
||||
try {
|
||||
@@ -146,27 +147,317 @@ class CachedChat {
|
||||
};
|
||||
}
|
||||
|
||||
sealed class MessageEvent {
|
||||
final int chatId;
|
||||
const MessageEvent(this.chatId);
|
||||
}
|
||||
|
||||
class MessageAddedEvent extends MessageEvent {
|
||||
final CachedMessage message;
|
||||
const MessageAddedEvent(super.chatId, this.message);
|
||||
}
|
||||
|
||||
class MessageEditedEvent extends MessageEvent {
|
||||
final CachedMessage message;
|
||||
const MessageEditedEvent(super.chatId, this.message);
|
||||
}
|
||||
|
||||
class MessageRemovedEvent extends MessageEvent {
|
||||
final String messageId;
|
||||
const MessageRemovedEvent(super.chatId, this.messageId);
|
||||
}
|
||||
|
||||
class MessageReactionsChangedEvent extends MessageEvent {
|
||||
final String messageId;
|
||||
final Map<String, dynamic>? reactionInfo;
|
||||
const MessageReactionsChangedEvent(super.chatId, this.messageId, this.reactionInfo);
|
||||
}
|
||||
|
||||
class ChatsModule {
|
||||
static const int muteOff = 0;
|
||||
static const int muteForever = -1;
|
||||
|
||||
/// Sentinel в `lastMsgText` когда последнее сообщение в чате удалено,
|
||||
/// а кеша истории нет — UI должен отрисовать курсивную плашку.
|
||||
static const String lastMsgPlaceholder = '__komet_lastmsg_placeholder__';
|
||||
|
||||
static final _messageEventsController =
|
||||
StreamController<MessageEvent>.broadcast();
|
||||
static Stream<MessageEvent> get messageEvents =>
|
||||
_messageEventsController.stream;
|
||||
|
||||
static final ValueNotifier<int> chatsChanged = ValueNotifier(0);
|
||||
static void _bump() => chatsChanged.value = chatsChanged.value + 1;
|
||||
|
||||
static StreamSubscription<Packet>? _globalPushSub;
|
||||
static StreamSubscription<SessionState>? _globalStateSub;
|
||||
|
||||
static final Set<int> _dirtyChats = {};
|
||||
static final Set<int> _knownChats = {};
|
||||
|
||||
static bool isChatDirty(int chatId) => _dirtyChats.contains(chatId);
|
||||
static void markChatClean(int chatId) => _dirtyChats.remove(chatId);
|
||||
static void markChatDirty(int chatId) => _dirtyChats.add(chatId);
|
||||
static void registerKnownChat(int chatId) => _knownChats.add(chatId);
|
||||
|
||||
static void attachGlobalPushHandlers(Api api) {
|
||||
_globalPushSub?.cancel();
|
||||
_globalStateSub?.cancel();
|
||||
_globalPushSub = api.pushStream.listen(_handleGlobalPush);
|
||||
_globalStateSub = api.stateStream.listen(_handleSessionState);
|
||||
if (api.state != SessionState.online) {
|
||||
_markAllKnownChatsDirty();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _handleSessionState(SessionState state) async {
|
||||
if (state == SessionState.disconnected) {
|
||||
ContactInfoFetch.clear();
|
||||
PresenceFetch.clear();
|
||||
ChatInfoFetch.clear();
|
||||
await _markAllKnownChatsDirty();
|
||||
}
|
||||
}
|
||||
|
||||
static void resetForAccountSwitch() {
|
||||
_dirtyChats.clear();
|
||||
_knownChats.clear();
|
||||
ContactInfoFetch.clear();
|
||||
PresenceFetch.clear();
|
||||
ChatInfoFetch.clear();
|
||||
}
|
||||
|
||||
static Future<void> _markAllKnownChatsDirty() async {
|
||||
if (_knownChats.isEmpty) {
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) return;
|
||||
final rows = await AppDatabase.loadChats(accountId);
|
||||
for (final row in rows) {
|
||||
final id = row['id'];
|
||||
if (id is int) _knownChats.add(id);
|
||||
}
|
||||
}
|
||||
_dirtyChats.addAll(_knownChats);
|
||||
}
|
||||
|
||||
static Future<void> _handleGlobalPush(Packet packet) async {
|
||||
switch (packet.opcode) {
|
||||
case Opcode.notifMessage:
|
||||
await _handleNotifMessage(packet);
|
||||
case Opcode.notifMark:
|
||||
await _handleNotifMark(packet);
|
||||
case Opcode.notifMsgReactionsChanged:
|
||||
await _handleNotifMsgReactionsChanged(packet);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _handleNotifMessage(Packet packet) async {
|
||||
final payload = packet.payload;
|
||||
if (payload is! Map) return;
|
||||
final chatId = payload['chatId'];
|
||||
if (chatId is! int) return;
|
||||
final msg = payload['message'];
|
||||
if (msg is! Map) return;
|
||||
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) return;
|
||||
|
||||
final senderId = msg['sender'] as int?;
|
||||
final msgIdStr = msg['id']?.toString();
|
||||
final msgIdInt = (msg['id'] is int)
|
||||
? msg['id'] as int
|
||||
: int.tryParse(msgIdStr ?? '');
|
||||
final msgTime = msg['time'] as int?;
|
||||
final msgText = msg['text'] as String?;
|
||||
final status = msg['status'] as String?;
|
||||
final unread = payload['unread'] as int?;
|
||||
|
||||
var rows = await AppDatabase.loadChat(accountId, chatId);
|
||||
if (rows.isEmpty) {
|
||||
try {
|
||||
final chatInfo = await ChatInfoFetch.get(chatId);
|
||||
if (chatInfo != null) {
|
||||
await cacheServerChat(chatInfo, accountId);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.w('notifMessage: fetch info for unknown chat $chatId failed: $e');
|
||||
return;
|
||||
}
|
||||
rows = await AppDatabase.loadChat(accountId, chatId);
|
||||
if (rows.isEmpty) return;
|
||||
}
|
||||
|
||||
if (status == 'REMOVED' && msgIdStr != null) {
|
||||
await AppDatabase.deleteMessage(accountId, chatId, msgIdStr);
|
||||
final cachedChat = CachedChat.fromDbRow(rows.first);
|
||||
if (cachedChat.lastMsgId == msgIdInt) {
|
||||
await _reconcileLastMessage(accountId, chatId, rows.first, unread: unread);
|
||||
} else if (unread != null) {
|
||||
final newRow = Map<String, dynamic>.from(rows.first);
|
||||
newRow['unread_count'] = unread;
|
||||
await AppDatabase.saveChats([newRow]);
|
||||
}
|
||||
_messageEventsController.add(MessageRemovedEvent(chatId, msgIdStr));
|
||||
_bump();
|
||||
return;
|
||||
}
|
||||
|
||||
CachedMessage? emittedMessage;
|
||||
if (status == 'EDITED' && msgIdStr != null) {
|
||||
final existing = await AppDatabase.loadMessage(accountId, chatId, msgIdStr);
|
||||
if (existing != null) {
|
||||
Map<String, dynamic> mergedPayload;
|
||||
final existingPayloadRaw = existing['payload'];
|
||||
if (existingPayloadRaw is String && existingPayloadRaw.isNotEmpty) {
|
||||
try {
|
||||
mergedPayload = Map<String, dynamic>.from(
|
||||
jsonDecode(existingPayloadRaw) as Map,
|
||||
);
|
||||
} catch (_) {
|
||||
mergedPayload = Map<String, dynamic>.from(msg);
|
||||
}
|
||||
} else {
|
||||
mergedPayload = Map<String, dynamic>.from(msg);
|
||||
}
|
||||
for (final entry in msg.entries) {
|
||||
if (entry.key == 'reactionInfo') continue;
|
||||
mergedPayload[entry.key.toString()] = entry.value;
|
||||
}
|
||||
final newRow = Map<String, dynamic>.from(existing);
|
||||
newRow['text'] = msgText;
|
||||
newRow['status'] = status;
|
||||
newRow['payload'] = jsonEncode(mergedPayload);
|
||||
await AppDatabase.saveMessages([newRow]);
|
||||
emittedMessage = CachedMessage.fromDbRow(newRow);
|
||||
_messageEventsController.add(MessageEditedEvent(chatId, emittedMessage));
|
||||
}
|
||||
} else if (msgIdStr != null) {
|
||||
final existing = await AppDatabase.loadMessage(accountId, chatId, msgIdStr);
|
||||
if (existing == null) {
|
||||
final cached = CachedMessage.fromPushPayload(accountId, chatId, msg);
|
||||
await AppDatabase.saveMessages([cached.toDbRow()]);
|
||||
emittedMessage = cached;
|
||||
_messageEventsController.add(MessageAddedEvent(chatId, cached));
|
||||
}
|
||||
}
|
||||
|
||||
final cached = CachedChat.fromDbRow(rows.first);
|
||||
final isStaleLast = status != 'REMOVED' &&
|
||||
msgIdInt != null &&
|
||||
cached.lastMsgId == msgIdInt &&
|
||||
status != 'EDITED';
|
||||
if (isStaleLast) {
|
||||
_bump();
|
||||
return;
|
||||
}
|
||||
|
||||
final newRow = Map<String, dynamic>.from(rows.first);
|
||||
if (status != 'REMOVED') {
|
||||
if (msgIdInt != null) newRow['last_msg_id'] = msgIdInt;
|
||||
if (msgTime != null) {
|
||||
newRow['last_msg_time'] = msgTime;
|
||||
if (status != 'EDITED') {
|
||||
newRow['last_event_time'] = msgTime;
|
||||
}
|
||||
}
|
||||
newRow['last_msg_text'] = msgText;
|
||||
if (senderId != null) newRow['last_msg_sender'] = senderId;
|
||||
}
|
||||
if (unread != null) newRow['unread_count'] = unread;
|
||||
|
||||
await AppDatabase.saveChats([newRow]);
|
||||
_bump();
|
||||
}
|
||||
|
||||
static Future<void> _reconcileLastMessage(
|
||||
int accountId,
|
||||
int chatId,
|
||||
Map<String, dynamic> chatRow, {
|
||||
int? unread,
|
||||
}) async {
|
||||
final latest = await AppDatabase.loadMessages(accountId, chatId, limit: 1);
|
||||
final newRow = Map<String, dynamic>.from(chatRow);
|
||||
if (latest.isNotEmpty) {
|
||||
final m = latest.first;
|
||||
newRow['last_msg_id'] = int.tryParse(m['id']?.toString() ?? '');
|
||||
newRow['last_msg_text'] = m['text'];
|
||||
newRow['last_msg_time'] = m['time'];
|
||||
newRow['last_msg_sender'] = m['sender_id'];
|
||||
} else {
|
||||
newRow['last_msg_id'] = null;
|
||||
newRow['last_msg_text'] = lastMsgPlaceholder;
|
||||
newRow['last_msg_sender'] = null;
|
||||
}
|
||||
if (unread != null) newRow['unread_count'] = unread;
|
||||
await AppDatabase.saveChats([newRow]);
|
||||
}
|
||||
|
||||
/// Вызывается после успешного фетча истории чата —
|
||||
/// если в превью был placeholder, заменяем его на актуальное
|
||||
/// последнее сообщение из кеша.
|
||||
static Future<void> reconcileLastMessageIfPlaceholder(
|
||||
int accountId,
|
||||
int chatId,
|
||||
) async {
|
||||
final rows = await AppDatabase.loadChat(accountId, chatId);
|
||||
if (rows.isEmpty) return;
|
||||
final chat = CachedChat.fromDbRow(rows.first);
|
||||
if (chat.lastMsgText != lastMsgPlaceholder) return;
|
||||
await _reconcileLastMessage(accountId, chatId, rows.first);
|
||||
_bump();
|
||||
}
|
||||
|
||||
static Future<void> _handleNotifMsgReactionsChanged(Packet packet) async {
|
||||
final payload = packet.payload;
|
||||
if (payload is! Map) return;
|
||||
final chatId = payload['chatId'];
|
||||
if (chatId is! int) return;
|
||||
final messageId = payload['messageId']?.toString();
|
||||
if (messageId == null || messageId.isEmpty) return;
|
||||
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) return;
|
||||
|
||||
final existing = await AppDatabase.loadMessage(accountId, chatId, messageId);
|
||||
if (existing == null) return;
|
||||
|
||||
Map<String, dynamic> payloadMap;
|
||||
final raw = existing['payload'];
|
||||
if (raw is String && raw.isNotEmpty) {
|
||||
try {
|
||||
payloadMap = Map<String, dynamic>.from(jsonDecode(raw) as Map);
|
||||
} catch (_) {
|
||||
payloadMap = {};
|
||||
}
|
||||
} else {
|
||||
payloadMap = {};
|
||||
}
|
||||
|
||||
final counters = payload['counters'];
|
||||
final totalCount = payload['totalCount'];
|
||||
final reactionInfo = <String, dynamic>{};
|
||||
final prev = payloadMap['reactionInfo'];
|
||||
if (prev is Map && prev['yourReaction'] != null) {
|
||||
reactionInfo['yourReaction'] = prev['yourReaction'];
|
||||
}
|
||||
if (counters is List) reactionInfo['counters'] = counters;
|
||||
if (totalCount is int) reactionInfo['totalCount'] = totalCount;
|
||||
if (reactionInfo['counters'] == null || (counters is List && counters.isEmpty)) {
|
||||
payloadMap.remove('reactionInfo');
|
||||
} else {
|
||||
payloadMap['reactionInfo'] = reactionInfo;
|
||||
}
|
||||
|
||||
final newRow = Map<String, dynamic>.from(existing);
|
||||
newRow['payload'] = jsonEncode(payloadMap);
|
||||
await AppDatabase.saveMessages([newRow]);
|
||||
final emitted = payloadMap['reactionInfo'] as Map<String, dynamic>?;
|
||||
_messageEventsController.add(
|
||||
MessageReactionsChangedEvent(chatId, messageId, emitted),
|
||||
);
|
||||
_bump();
|
||||
}
|
||||
|
||||
static Future<void> _handleNotifMark(Packet packet) async {
|
||||
final payload = packet.payload;
|
||||
if (payload is! Map) return;
|
||||
@@ -221,12 +512,15 @@ class ChatsModule {
|
||||
if (accountId == null) return;
|
||||
|
||||
final dialogRows = await AppDatabase.loadDialogChats(accountId);
|
||||
final byParticipant = <int, List<Map<String, dynamic>>>{};
|
||||
final byParticipant =
|
||||
<int, List<({Map<String, dynamic> row, CachedChat cached})>>{};
|
||||
for (final row in dialogRows) {
|
||||
final cached = CachedChat.fromDbRow(row);
|
||||
for (final pid in cached.participants.keys) {
|
||||
if (pid == accountId) continue;
|
||||
byParticipant.putIfAbsent(pid, () => []).add(row);
|
||||
byParticipant
|
||||
.putIfAbsent(pid, () => [])
|
||||
.add((row: row, cached: cached));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,8 +532,9 @@ class ChatsModule {
|
||||
final options = ContactCache.getOptions(contactId) ?? const <String>{};
|
||||
final affected = byParticipant[contactId];
|
||||
if (affected == null) continue;
|
||||
for (final row in affected) {
|
||||
final cached = CachedChat.fromDbRow(row);
|
||||
for (final entry in affected) {
|
||||
final row = entry.row;
|
||||
final cached = entry.cached;
|
||||
final sameTitle = cached.title == name;
|
||||
final sameAvatar = (cached.iconUrl ?? '') == (avatar ?? '');
|
||||
final sameOptions = cached.options.length == options.length &&
|
||||
@@ -288,6 +583,7 @@ class ChatsModule {
|
||||
logger.w('cacheServerChat: parse returned null for chat=${chat['id']}');
|
||||
return null;
|
||||
}
|
||||
_knownChats.add(parsed.id);
|
||||
final ex = existing[parsed.id];
|
||||
if (ex != null && _sameContent(ex, parsed)) {
|
||||
return parsed;
|
||||
@@ -383,7 +679,11 @@ class ChatsModule {
|
||||
static Future<List<CachedChat>> getChats(int accountId) async {
|
||||
try {
|
||||
final rows = await AppDatabase.loadChats(accountId);
|
||||
return rows.map(CachedChat.fromDbRow).toList();
|
||||
final chats = rows.map(CachedChat.fromDbRow).toList();
|
||||
for (final c in chats) {
|
||||
_knownChats.add(c.id);
|
||||
}
|
||||
return chats;
|
||||
} catch (e) {
|
||||
logger.e("Ошибка при получении чатов: $e");
|
||||
return [];
|
||||
@@ -661,6 +961,40 @@ class ChatsModule {
|
||||
return packet.isOk;
|
||||
}
|
||||
|
||||
static Future<bool> setChatOptions(
|
||||
Api api, {
|
||||
required int chatId,
|
||||
required Map<String, dynamic> options,
|
||||
}) async {
|
||||
final packet = await api.sendRequest(Opcode.chatUpdate, {
|
||||
'chatId': chatId,
|
||||
'options': options,
|
||||
});
|
||||
return packet.isOk;
|
||||
}
|
||||
|
||||
static Future<bool> setChatTitle(
|
||||
Api api, {
|
||||
required int chatId,
|
||||
required String title,
|
||||
}) async {
|
||||
final packet = await api.sendRequest(Opcode.chatUpdate, {
|
||||
'chatId': chatId,
|
||||
'theme': title,
|
||||
});
|
||||
if (!packet.isOk) return false;
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) return true;
|
||||
final rows = await AppDatabase.loadChat(accountId, chatId);
|
||||
if (rows.isNotEmpty) {
|
||||
final updated = Map<String, dynamic>.from(rows.first);
|
||||
updated['title'] = title;
|
||||
await AppDatabase.saveChats([updated]);
|
||||
_bump();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static Future<String?> togglePin(
|
||||
Api api, {
|
||||
required List<int> chatIds,
|
||||
@@ -777,6 +1111,20 @@ class ChatsModule {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> leaveChat(Api api, {required int chatId}) async {
|
||||
try {
|
||||
await api.sendRequest(Opcode.chatLeave, {'chatId': chatId});
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId != null) {
|
||||
await AppDatabase.deleteChat(chatId, accountId);
|
||||
_bump();
|
||||
}
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<CachedChat>> refreshChats(
|
||||
Api api,
|
||||
List<int> chatIds,
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../models/attachment.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../api.dart';
|
||||
import 'chats.dart';
|
||||
import 'messages.dart';
|
||||
|
||||
class CloudFile {
|
||||
final String name;
|
||||
final int? size;
|
||||
final int time;
|
||||
final int? fileId;
|
||||
final String messageId;
|
||||
final int chatId;
|
||||
final int accountId;
|
||||
|
||||
const CloudFile({
|
||||
required this.name,
|
||||
this.size,
|
||||
required this.time,
|
||||
this.fileId,
|
||||
required this.messageId,
|
||||
required this.chatId,
|
||||
required this.accountId,
|
||||
});
|
||||
}
|
||||
|
||||
class CloudStorageModule {
|
||||
static const _prefix = 'CLST';
|
||||
static const _tempName = 'Облачное хранилище';
|
||||
|
||||
// Key: "$accountId:$fileId" — scoped per account
|
||||
static final Map<String, ({String url, int expires})> _linkCache = {};
|
||||
|
||||
static int _computeSpecialNumber(int groupId) {
|
||||
final s = groupId.abs().toString();
|
||||
final len = s.length;
|
||||
if (len < 4) {
|
||||
final n = int.parse(s);
|
||||
return n + n;
|
||||
}
|
||||
final first = int.parse(s.substring(0, 4));
|
||||
final last = int.parse(s.substring(len - 4));
|
||||
return first + last;
|
||||
}
|
||||
|
||||
static bool isCloudStorageGroup(CachedChat chat) {
|
||||
if (chat.type != 'CHAT') return false;
|
||||
final title = chat.title;
|
||||
if (title == null || !title.startsWith(_prefix)) return false;
|
||||
final numStr = title.substring(_prefix.length);
|
||||
final provided = int.tryParse(numStr);
|
||||
if (provided == null) return false;
|
||||
return provided == _computeSpecialNumber(chat.id);
|
||||
}
|
||||
|
||||
static CachedChat? findEnvGroup(List<CachedChat> chats) {
|
||||
for (final c in chats) {
|
||||
if (isCloudStorageGroup(c)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<CachedChat> findOrphanGroups(List<CachedChat> chats) =>
|
||||
chats.where((c) => c.type == 'CHAT' && c.title == _tempName).toList();
|
||||
|
||||
// Env group ID cache — avoids scanning all chats on every screen open
|
||||
static Future<void> cacheEnvGroupId(int accountId, int groupId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('cloud_storage_env_$accountId', groupId);
|
||||
}
|
||||
|
||||
static Future<int?> getCachedEnvGroupId(int accountId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getInt('cloud_storage_env_$accountId');
|
||||
}
|
||||
|
||||
static Future<void> clearEnvGroupCache(int accountId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('cloud_storage_env_$accountId');
|
||||
}
|
||||
|
||||
static Future<void> _configurePrivacy(Api api, int chatId) async {
|
||||
await Future.wait([
|
||||
ChatsModule.setChatOptions(api, chatId: chatId, options: {'ONLY_OWNER_CAN_CHANGE_ICON_TITLE': true}),
|
||||
ChatsModule.setChatOptions(api, chatId: chatId, options: {'ONLY_ADMIN_CAN_ADD_MEMBER': true}),
|
||||
ChatsModule.setChatOptions(api, chatId: chatId, options: {'ALL_CAN_PIN_MESSAGE': false}),
|
||||
ChatsModule.setChatOptions(api, chatId: chatId, options: {'ONLY_ADMIN_CAN_CALL': true}),
|
||||
]);
|
||||
}
|
||||
|
||||
static Future<CachedChat?> setupEnv(Api api) async {
|
||||
final temp = await ChatsModule.createGroupChat(
|
||||
api,
|
||||
title: _tempName,
|
||||
userIds: [],
|
||||
);
|
||||
if (temp == null) return null;
|
||||
final name = '$_prefix${_computeSpecialNumber(temp.id)}';
|
||||
final ok = await ChatsModule.setChatTitle(api, chatId: temp.id, title: name);
|
||||
if (!ok) return null;
|
||||
await _configurePrivacy(api, temp.id);
|
||||
return temp;
|
||||
}
|
||||
|
||||
// Turns an orphan "Облачное хранилище" group into a valid env group
|
||||
static Future<CachedChat?> repairOrphan(Api api, CachedChat orphan) async {
|
||||
final name = '$_prefix${_computeSpecialNumber(orphan.id)}';
|
||||
final ok = await ChatsModule.setChatTitle(api, chatId: orphan.id, title: name);
|
||||
if (!ok) return null;
|
||||
await _configurePrivacy(api, orphan.id);
|
||||
return orphan;
|
||||
}
|
||||
|
||||
static Future<List<CloudFile>> fetchFiles(
|
||||
MessagesModule messages,
|
||||
int accountId,
|
||||
int chatId, {
|
||||
int count = 200,
|
||||
}) async {
|
||||
final msgs = await messages.fetchHistory(accountId, chatId, count: count);
|
||||
final files = <CloudFile>[];
|
||||
for (final msg in msgs) {
|
||||
for (final a in msg.attachments ?? []) {
|
||||
if (a is FileAttachment && a.name != null) {
|
||||
files.add(CloudFile(
|
||||
name: a.name!,
|
||||
size: a.size,
|
||||
time: msg.time,
|
||||
fileId: a.fileId,
|
||||
messageId: msg.id,
|
||||
chatId: chatId,
|
||||
accountId: accountId,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
// Fetches only the last few messages to find a newly uploaded file — avoids full 200-msg reload
|
||||
static Future<CloudFile?> fetchLatestFile(
|
||||
MessagesModule messages,
|
||||
int accountId,
|
||||
int chatId, {
|
||||
int? expectedFileId,
|
||||
}) async {
|
||||
final msgs = await messages.fetchHistory(accountId, chatId, count: 5);
|
||||
for (final msg in msgs) {
|
||||
for (final a in msg.attachments ?? []) {
|
||||
if (a is FileAttachment && a.name != null) {
|
||||
if (expectedFileId == null || a.fileId == expectedFileId) {
|
||||
return CloudFile(
|
||||
name: a.name!,
|
||||
size: a.size,
|
||||
time: msg.time,
|
||||
fileId: a.fileId,
|
||||
messageId: msg.id,
|
||||
chatId: chatId,
|
||||
accountId: accountId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static ({String url, int expires})? getCachedLink(int accountId, int fileId) {
|
||||
final key = '$accountId:$fileId';
|
||||
final entry = _linkCache[key];
|
||||
if (entry == null) return null;
|
||||
if (entry.expires <= DateTime.now().millisecondsSinceEpoch) {
|
||||
_linkCache.remove(key);
|
||||
return null;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
static Future<({String url, int expires})?> fetchFileUrl(
|
||||
Api api, {
|
||||
required int accountId,
|
||||
required int fileId,
|
||||
required int chatId,
|
||||
required String messageId,
|
||||
}) async {
|
||||
try {
|
||||
final packet = await api.sendRequest(Opcode.fileDownload, {
|
||||
'fileId': fileId,
|
||||
'chatId': chatId,
|
||||
'messageId': int.tryParse(messageId) ?? messageId,
|
||||
});
|
||||
if (!packet.isOk) return null;
|
||||
final data = packet.payload;
|
||||
if (data is! Map) return null;
|
||||
final url = data['url'] as String?;
|
||||
if (url == null) return null;
|
||||
final uri = Uri.tryParse(url);
|
||||
final expiresStr = uri?.queryParameters['expires'];
|
||||
final expires = int.tryParse(expiresStr ?? '') ?? 0;
|
||||
final entry = (url: url, expires: expires);
|
||||
_linkCache['$accountId:$fileId'] = entry;
|
||||
return entry;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,13 +190,23 @@ class FileUploader {
|
||||
Socket? socket;
|
||||
try {
|
||||
socket = await _openSocket(uri);
|
||||
final boundary = '----KometBoundary${DateTime.now().microsecondsSinceEpoch}';
|
||||
final preamble = utf8.encode(
|
||||
'--$boundary\r\n'
|
||||
'Content-Disposition: form-data; name="file"; filename="$filename"\r\n'
|
||||
'Content-Type: ${_contentTypeForFilename(filename)}\r\n'
|
||||
'\r\n',
|
||||
);
|
||||
final epilogue = utf8.encode('\r\n--$boundary--\r\n');
|
||||
_writeImageHeaders(
|
||||
socket,
|
||||
uri,
|
||||
bytes.length,
|
||||
contentType: _contentTypeForFilename(filename),
|
||||
preamble.length + bytes.length + epilogue.length,
|
||||
boundary: boundary,
|
||||
);
|
||||
socket.add(preamble);
|
||||
socket.add(bytes);
|
||||
socket.add(epilogue);
|
||||
await socket.flush();
|
||||
|
||||
final response = await _readFullResponse(
|
||||
@@ -208,7 +218,6 @@ class FileUploader {
|
||||
} catch (_) {}
|
||||
|
||||
if (response == null) {
|
||||
logger.w('uploadImage: empty/timed-out response');
|
||||
return null;
|
||||
}
|
||||
final (status, body) = response;
|
||||
@@ -230,12 +239,12 @@ class FileUploader {
|
||||
}
|
||||
}
|
||||
|
||||
void _writeImageHeaders(Socket socket, Uri uri, int total, {required String contentType}) {
|
||||
void _writeImageHeaders(Socket socket, Uri uri, int total, {required String boundary}) {
|
||||
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
|
||||
final headers = StringBuffer()
|
||||
..write('POST $path HTTP/1.1\r\n')
|
||||
..write('Host: ${uri.host}\r\n')
|
||||
..write('Content-Type: $contentType\r\n')
|
||||
..write('Content-Type: multipart/form-data; boundary=$boundary\r\n')
|
||||
..write('Content-Length: $total\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')
|
||||
@@ -273,36 +282,63 @@ class FileUploader {
|
||||
Timer? timer;
|
||||
StreamSubscription<List<int>>? sub;
|
||||
|
||||
void finish() {
|
||||
void finishWith((int, String)? value) {
|
||||
timer?.cancel();
|
||||
sub?.cancel();
|
||||
if (completer.isCompleted) return;
|
||||
if (!completer.isCompleted) completer.complete(value);
|
||||
}
|
||||
|
||||
(int, String)? tryParse({required bool atClose}) {
|
||||
final headerEnd = _findHeaderEnd(bytes);
|
||||
if (headerEnd == -1) {
|
||||
completer.complete(null);
|
||||
return;
|
||||
}
|
||||
if (headerEnd == -1) return null;
|
||||
final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true);
|
||||
final lines = headerStr.split('\r\n');
|
||||
final parts = lines.first.split(' ');
|
||||
final status = parts.length >= 2 ? (int.tryParse(parts[1]) ?? 0) : 0;
|
||||
final chunked = lines.skip(1).any(
|
||||
final headerLines = lines.skip(1);
|
||||
final chunked = headerLines.any(
|
||||
(l) => l.toLowerCase().startsWith('transfer-encoding:') &&
|
||||
l.toLowerCase().contains('chunked'),
|
||||
);
|
||||
int? contentLength;
|
||||
for (final l in headerLines) {
|
||||
if (l.toLowerCase().startsWith('content-length:')) {
|
||||
contentLength = int.tryParse(l.split(':').last.trim());
|
||||
}
|
||||
}
|
||||
final rawBody = utf8.decode(bytes.sublist(headerEnd), allowMalformed: true);
|
||||
final body = chunked ? _decodeChunked(rawBody) : rawBody;
|
||||
completer.complete((status, body));
|
||||
if (chunked) {
|
||||
if (!atClose && !rawBody.contains('\r\n0\r\n')) return null;
|
||||
return (status, _decodeChunked(rawBody));
|
||||
}
|
||||
if (contentLength != null && !atClose && bytes.length - headerEnd < contentLength) {
|
||||
return null;
|
||||
}
|
||||
return (status, rawBody);
|
||||
}
|
||||
|
||||
void fail() {
|
||||
timer?.cancel();
|
||||
sub?.cancel();
|
||||
if (!completer.isCompleted) completer.complete(null);
|
||||
}
|
||||
|
||||
sub = socket.listen(bytes.addAll, onError: (_) => fail(), onDone: finish);
|
||||
timer = Timer(timeout, fail);
|
||||
sub = socket.listen(
|
||||
(chunk) {
|
||||
bytes.addAll(chunk);
|
||||
final parsed = tryParse(atClose: false);
|
||||
if (parsed != null) finishWith(parsed);
|
||||
},
|
||||
onError: (e) {
|
||||
logger.w('uploadImage: socket error after ${bytes.length} bytes: $e');
|
||||
finishWith(tryParse(atClose: true));
|
||||
},
|
||||
onDone: () {
|
||||
final parsed = tryParse(atClose: true);
|
||||
if (parsed == null) {
|
||||
logger.w('uploadImage: connection closed without HTTP response (${bytes.length} bytes)');
|
||||
}
|
||||
finishWith(parsed);
|
||||
},
|
||||
);
|
||||
timer = Timer(timeout, () {
|
||||
logger.w('uploadImage: response timeout after ${bytes.length} bytes');
|
||||
finishWith(tryParse(atClose: true));
|
||||
});
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,10 +44,13 @@ class FoldersModule {
|
||||
List<dynamic>? foldersOrder,
|
||||
) {
|
||||
if (foldersOrder == null || foldersOrder.isEmpty) return;
|
||||
final orderedIds = foldersOrder.map((id) => id.toString()).toList();
|
||||
final orderIndex = <String, int>{};
|
||||
for (var i = 0; i < foldersOrder.length; i++) {
|
||||
orderIndex.putIfAbsent(foldersOrder[i].toString(), () => i);
|
||||
}
|
||||
folders.sort((a, b) {
|
||||
final aIndex = orderedIds.indexOf(a.id);
|
||||
final bIndex = orderedIds.indexOf(b.id);
|
||||
final aIndex = orderIndex[a.id] ?? -1;
|
||||
final bIndex = orderIndex[b.id] ?? -1;
|
||||
if (aIndex == -1 && bIndex == -1) return 0;
|
||||
if (aIndex == -1) return 1;
|
||||
if (bIndex == -1) return -1;
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../api.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../models/attachment.dart';
|
||||
import 'chats.dart' show ChatsModule;
|
||||
@@ -24,6 +25,12 @@ class ContactCache {
|
||||
static String? getAvatar(int id) => _avatarCache[id];
|
||||
static Set<String>? getOptions(int id) => _optionsCache[id];
|
||||
static bool isOfficial(int id) => _optionsCache[id]?.contains('OFFICIAL') ?? false;
|
||||
|
||||
static void clear() {
|
||||
_nameCache.clear();
|
||||
_avatarCache.clear();
|
||||
_optionsCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class TranscriptionResult {
|
||||
@@ -52,6 +59,8 @@ class TranscriptionCache {
|
||||
static TranscriptionResult? get(String messageId) => _cache[messageId];
|
||||
|
||||
static bool has(String messageId) => _cache.containsKey(messageId);
|
||||
|
||||
static void clear() => _cache.clear();
|
||||
}
|
||||
|
||||
class FileHistoryEntry {
|
||||
@@ -236,6 +245,29 @@ class CachedMessage {
|
||||
'status': status,
|
||||
'payload': payload != null ? jsonEncode(payload) : null,
|
||||
};
|
||||
|
||||
static CachedMessage fromPushPayload(int accountId, int chatId, Map msg) {
|
||||
List<MessageAttachment>? attachments;
|
||||
final attaches = msg['attaches'];
|
||||
if (attaches is List && attaches.isNotEmpty) {
|
||||
attachments = attaches
|
||||
.whereType<Map>()
|
||||
.map((a) =>
|
||||
MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
|
||||
.toList();
|
||||
}
|
||||
return CachedMessage(
|
||||
id: msg['id']?.toString() ?? '',
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
senderId: msg['sender'] as int? ?? 0,
|
||||
text: msg['text'] as String?,
|
||||
time: (msg['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch,
|
||||
status: (msg['status'] as String?) ?? 'sent',
|
||||
payload: Map<String, dynamic>.from(msg),
|
||||
attachments: attachments,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MessagesModule {
|
||||
@@ -462,8 +494,9 @@ class MessagesModule {
|
||||
int fileId, {
|
||||
String? token,
|
||||
bool notify = true,
|
||||
int maxAttempts = 5,
|
||||
int maxAttempts = 20,
|
||||
Duration retryDelay = const Duration(seconds: 1),
|
||||
Duration initialDelay = const Duration(seconds: 3),
|
||||
}) async {
|
||||
final payload = {
|
||||
'chatId': chatId,
|
||||
@@ -482,14 +515,18 @@ class MessagesModule {
|
||||
'notify': notify,
|
||||
};
|
||||
|
||||
await Future.delayed(initialDelay);
|
||||
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (response.isOk) return true;
|
||||
final err = response.payload is Map ? response.payload['error'] : null;
|
||||
if (err != 'attachment.not.ready' || attempt == maxAttempts - 1) {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (response.isOk) return true;
|
||||
return false;
|
||||
} on PacketError catch (e) {
|
||||
if (e.errorKey != 'attachment.not.ready') rethrow;
|
||||
if (attempt == maxAttempts - 1) return false;
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -531,6 +568,43 @@ class MessagesModule {
|
||||
}
|
||||
}
|
||||
|
||||
/// Запрашивает у сервера ссылку на воспроизведение видео (opcode 83).
|
||||
///
|
||||
/// Формат подтверждён дампом: запрос `{messageId, chatId, token, videoId}`,
|
||||
/// ответ содержит `MP4_1080/MP4_720/...`, `HLS`, `DASH`, `EXTERNAL`.
|
||||
/// Возвращает лучший доступный progressive-MP4 (или HLS как запасной).
|
||||
Future<String?> getVideoUrl({
|
||||
required String messageId,
|
||||
required int chatId,
|
||||
required String token,
|
||||
required int videoId,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.videoPlay, {
|
||||
'messageId': int.tryParse(messageId) ?? 0,
|
||||
'chatId': chatId,
|
||||
'token': token,
|
||||
'videoId': videoId,
|
||||
});
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is! Map) return null;
|
||||
|
||||
const mp4Keys = ['MP4_1080', 'MP4_720', 'MP4_480', 'MP4_360', 'MP4_240'];
|
||||
for (final key in mp4Keys) {
|
||||
final url = data[key];
|
||||
if (url is String && url.isNotEmpty) return url;
|
||||
}
|
||||
final hls = data['HLS'];
|
||||
if (hls is String && hls.isNotEmpty) return hls;
|
||||
final external = data['EXTERNAL'];
|
||||
if (external is String && external.isNotEmpty) return external;
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List?> downloadVideo(String baseUrl, String videoToken) async {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||
@@ -551,23 +625,6 @@ class MessagesModule {
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getVideoUrl(String baseUrl, String videoToken) async {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||
'url': baseUrl,
|
||||
'token': videoToken,
|
||||
});
|
||||
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is! Map) return null;
|
||||
|
||||
return data['content'] as String?;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List?> downloadFile(String baseUrl, String fileToken) async {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||
@@ -588,18 +645,27 @@ class MessagesModule {
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getFileUrl(String baseUrl, String fileToken) async {
|
||||
/// Запрашивает у сервера временный CDN-URL для скачивания файла (opcode 88).
|
||||
///
|
||||
/// Формат подтверждён дампом: запрос `{messageId, chatId, fileId}`,
|
||||
/// ответ `{url: "https://fd.oneme.ru/getfile?..."}`.
|
||||
Future<String?> getFileUrl({
|
||||
required String messageId,
|
||||
required int chatId,
|
||||
required int fileId,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||
'url': baseUrl,
|
||||
'token': fileToken,
|
||||
'messageId': int.tryParse(messageId) ?? 0,
|
||||
'chatId': chatId,
|
||||
'fileId': fileId,
|
||||
});
|
||||
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is! Map) return null;
|
||||
|
||||
return data['content'] as String?;
|
||||
return data['url'] as String?;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../models/poll.dart';
|
||||
|
||||
class PollsModule extends ChangeNotifier {
|
||||
final Api _api;
|
||||
|
||||
PollsModule(this._api);
|
||||
|
||||
final Map<int, Poll> _cache = {};
|
||||
final Set<int> _inFlight = {};
|
||||
|
||||
Poll? get(int pollId) => _cache[pollId];
|
||||
|
||||
Future<void> fetch(
|
||||
int chatId,
|
||||
String messageId,
|
||||
int pollId, {
|
||||
bool force = false,
|
||||
}) async {
|
||||
if (pollId == 0) return;
|
||||
if (!force && (_cache.containsKey(pollId) || _inFlight.contains(pollId))) {
|
||||
return;
|
||||
}
|
||||
_inFlight.add(pollId);
|
||||
try {
|
||||
final mid = int.tryParse(messageId) ?? 0;
|
||||
final response = await _api.sendRequest(Opcode.getPollUpdates, {
|
||||
'chatId': chatId,
|
||||
'polls': [
|
||||
{'messageId': mid, 'pollId': pollId},
|
||||
],
|
||||
});
|
||||
if (!response.isOk) return;
|
||||
|
||||
final data = response.payload;
|
||||
if (data is! Map) return;
|
||||
|
||||
final polls = data['polls'];
|
||||
if (polls is! List) return;
|
||||
|
||||
var changed = false;
|
||||
for (final p in polls) {
|
||||
if (p is Map) {
|
||||
final poll = Poll.fromServerMap(p);
|
||||
if (poll.pollId != 0) {
|
||||
_cache[poll.pollId] = poll;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) notifyListeners();
|
||||
} catch (_) {
|
||||
// тихо игнорируем — опрос просто не отобразится
|
||||
} finally {
|
||||
_inFlight.remove(pollId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import '../../main.dart';
|
||||
import 'cloud_storage.dart';
|
||||
import 'file_uploader.dart';
|
||||
import 'upload_notification_service.dart';
|
||||
|
||||
class UploadManager {
|
||||
UploadManager._();
|
||||
static final instance = UploadManager._();
|
||||
|
||||
StreamSubscription<UploadEvent>? _sub;
|
||||
bool get isActive => _sub != null;
|
||||
|
||||
// UI callbacks — registered by the screen while it is mounted
|
||||
void Function(double progress, int speedBps)? onProgress;
|
||||
void Function(CloudFile file)? onDone;
|
||||
void Function(String error)? onError;
|
||||
|
||||
Future<void> start({
|
||||
required int chatId,
|
||||
required int accountId,
|
||||
required File file,
|
||||
required String filename,
|
||||
required int totalSize,
|
||||
}) async {
|
||||
await cancel(); // cancel any previous upload
|
||||
|
||||
await UploadNotificationService.start(filename);
|
||||
|
||||
var lastSentBytes = 0;
|
||||
var lastSpeedMs = DateTime.now().millisecondsSinceEpoch;
|
||||
var speedBps = 0;
|
||||
var lastNotifPercent = -1;
|
||||
|
||||
_sub = fileUploader
|
||||
.upload(
|
||||
chatId: chatId,
|
||||
file: file,
|
||||
filename: filename,
|
||||
totalSize: totalSize,
|
||||
)
|
||||
.listen(
|
||||
(event) async {
|
||||
switch (event) {
|
||||
case UploadProgress(:final sent, :final total):
|
||||
final progress = total > 0 ? sent / total : 0.0;
|
||||
|
||||
// Speed: recompute every 500 ms
|
||||
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||
final elapsed = nowMs - lastSpeedMs;
|
||||
if (elapsed >= 500) {
|
||||
speedBps = ((sent - lastSentBytes) * 1000 / elapsed).round();
|
||||
lastSentBytes = sent;
|
||||
lastSpeedMs = nowMs;
|
||||
}
|
||||
|
||||
onProgress?.call(progress, speedBps);
|
||||
|
||||
// Throttle notification to once per 1% change
|
||||
final percent = total > 0 ? (sent * 100 ~/ total) : 0;
|
||||
if (percent != lastNotifPercent) {
|
||||
lastNotifPercent = percent;
|
||||
UploadNotificationService.update(
|
||||
filename: filename,
|
||||
progressPercent: percent,
|
||||
speedBps: speedBps,
|
||||
);
|
||||
}
|
||||
|
||||
case UploadDone(:final fileId):
|
||||
_sub = null;
|
||||
UploadNotificationService.stop();
|
||||
final newest = await CloudStorageModule.fetchLatestFile(
|
||||
messagesModule,
|
||||
accountId,
|
||||
chatId,
|
||||
expectedFileId: fileId,
|
||||
);
|
||||
if (newest != null) {
|
||||
onDone?.call(newest);
|
||||
}
|
||||
|
||||
case UploadError(:final message):
|
||||
_sub = null;
|
||||
UploadNotificationService.stop();
|
||||
onError?.call(message);
|
||||
}
|
||||
},
|
||||
onError: (_) {
|
||||
_sub = null;
|
||||
UploadNotificationService.stop();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cancel() async {
|
||||
await _sub?.cancel();
|
||||
_sub = null;
|
||||
await UploadNotificationService.stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class UploadNotificationService {
|
||||
static const _ch = MethodChannel('ru.komet.app/upload_service');
|
||||
|
||||
static Future<void> start(String filename) async {
|
||||
if (!Platform.isAndroid) return;
|
||||
try { await _ch.invokeMethod('start', {'filename': filename}); } catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> update({
|
||||
required String filename,
|
||||
required int progressPercent,
|
||||
required int speedBps,
|
||||
}) async {
|
||||
if (!Platform.isAndroid) return;
|
||||
try {
|
||||
await _ch.invokeMethod('update', {
|
||||
'filename': filename,
|
||||
'progress': progressPercent,
|
||||
'speed': speedBps,
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> stop() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
try { await _ch.invokeMethod('stop'); } catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../../backend/api.dart';
|
||||
import '../protocol/opcode_map.dart';
|
||||
|
||||
Api? _api;
|
||||
|
||||
void attachInfoCacheApi(Api api) {
|
||||
_api = api;
|
||||
}
|
||||
|
||||
class _Entry<T> {
|
||||
T? value;
|
||||
DateTime? fetchedAt;
|
||||
DateTime? failedAt;
|
||||
Future<T?>? inFlight;
|
||||
}
|
||||
|
||||
class InfoCache<T> {
|
||||
final Duration ttl;
|
||||
final Duration failureBackoff;
|
||||
final Future<T?> Function(int id) fetcher;
|
||||
final Map<int, _Entry<T>> _entries = {};
|
||||
|
||||
InfoCache({
|
||||
required this.ttl,
|
||||
required this.fetcher,
|
||||
this.failureBackoff = const Duration(seconds: 10),
|
||||
});
|
||||
|
||||
bool _isFresh(_Entry<T> e) {
|
||||
if (e.fetchedAt == null) return false;
|
||||
return DateTime.now().difference(e.fetchedAt!) < ttl;
|
||||
}
|
||||
|
||||
bool _isInFailureBackoff(_Entry<T> e) {
|
||||
if (e.failedAt == null) return false;
|
||||
return DateTime.now().difference(e.failedAt!) < failureBackoff;
|
||||
}
|
||||
|
||||
Future<T?> get(int id, {bool forceRefresh = false}) {
|
||||
final entry = _entries.putIfAbsent(id, () => _Entry<T>());
|
||||
|
||||
if (!forceRefresh && _isFresh(entry)) {
|
||||
return Future.value(entry.value);
|
||||
}
|
||||
if (!forceRefresh && _isInFailureBackoff(entry)) {
|
||||
return Future.value(null);
|
||||
}
|
||||
if (entry.inFlight != null) return entry.inFlight!;
|
||||
|
||||
final future = _runFetch(entry, id);
|
||||
entry.inFlight = future;
|
||||
return future;
|
||||
}
|
||||
|
||||
Future<T?> _runFetch(_Entry<T> entry, int id) async {
|
||||
try {
|
||||
final result = await fetcher(id);
|
||||
entry.value = result;
|
||||
entry.fetchedAt = DateTime.now();
|
||||
entry.failedAt = null;
|
||||
return result;
|
||||
} catch (_) {
|
||||
entry.failedAt = DateTime.now();
|
||||
return null;
|
||||
} finally {
|
||||
entry.inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
T? peek(int id) {
|
||||
final entry = _entries[id];
|
||||
if (entry == null || !_isFresh(entry)) return null;
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
void invalidate(int id) => _entries.remove(id);
|
||||
void clear() => _entries.clear();
|
||||
|
||||
void putValue(int id, T value, {DateTime? at}) {
|
||||
final entry = _entries.putIfAbsent(id, () => _Entry<T>());
|
||||
entry.value = value;
|
||||
entry.fetchedAt = at ?? DateTime.now();
|
||||
entry.failedAt = null;
|
||||
}
|
||||
|
||||
void markFailed(int id, {DateTime? at}) {
|
||||
final entry = _entries.putIfAbsent(id, () => _Entry<T>());
|
||||
entry.failedAt = at ?? DateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
class ContactInfoFetch {
|
||||
static final _cache = InfoCache<Map<String, dynamic>>(
|
||||
ttl: const Duration(minutes: 5),
|
||||
fetcher: _fetch,
|
||||
);
|
||||
|
||||
static Future<Map<String, dynamic>?> get(int id, {bool forceRefresh = false}) =>
|
||||
_cache.get(id, forceRefresh: forceRefresh);
|
||||
|
||||
static Map<String, dynamic>? peek(int id) => _cache.peek(id);
|
||||
|
||||
static void invalidate(int id) => _cache.invalidate(id);
|
||||
static void clear() => _cache.clear();
|
||||
|
||||
static Future<Map<String, dynamic>?> _fetch(int id) async {
|
||||
final api = _api;
|
||||
if (api == null || api.state != SessionState.online) return null;
|
||||
final resp = await api.sendRequest(Opcode.contactInfo, {
|
||||
'contactIds': [id],
|
||||
});
|
||||
final data = resp.payload;
|
||||
if (data is! Map) return null;
|
||||
final contacts = data['contacts'];
|
||||
if (contacts is! List || contacts.isEmpty) return null;
|
||||
final first = contacts.first;
|
||||
if (first is! Map) return null;
|
||||
return Map<String, dynamic>.from(first);
|
||||
}
|
||||
}
|
||||
|
||||
class PresenceFetch {
|
||||
static final _cache = InfoCache<Map<String, dynamic>>(
|
||||
ttl: const Duration(seconds: 60),
|
||||
fetcher: _fetch,
|
||||
);
|
||||
|
||||
static Future<Map<String, dynamic>?> get(int id, {bool forceRefresh = false}) =>
|
||||
_cache.get(id, forceRefresh: forceRefresh);
|
||||
|
||||
static Map<String, dynamic>? peek(int id) => _cache.peek(id);
|
||||
|
||||
static void invalidate(int id) => _cache.invalidate(id);
|
||||
static void clear() => _cache.clear();
|
||||
|
||||
static Future<Map<String, dynamic>?> _fetch(int id) async {
|
||||
final results = await _fetchBatch([id]);
|
||||
return results[id];
|
||||
}
|
||||
|
||||
static Future<Map<int, Map<String, dynamic>>> getMany(
|
||||
List<int> ids, {
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final result = <int, Map<String, dynamic>>{};
|
||||
final missing = <int>[];
|
||||
for (final id in ids) {
|
||||
if (!forceRefresh) {
|
||||
final cached = _cache.peek(id);
|
||||
if (cached != null) {
|
||||
result[id] = cached;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
missing.add(id);
|
||||
}
|
||||
if (missing.isNotEmpty) {
|
||||
final fetched = await _fetchBatch(missing);
|
||||
final now = DateTime.now();
|
||||
for (final id in missing) {
|
||||
final value = fetched[id];
|
||||
if (value != null) {
|
||||
_cache.putValue(id, value, at: now);
|
||||
result[id] = value;
|
||||
} else {
|
||||
_cache.markFailed(id, at: now);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static Future<Map<int, Map<String, dynamic>>> _fetchBatch(List<int> ids) async {
|
||||
final api = _api;
|
||||
if (api == null || api.state != SessionState.online || ids.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
final resp = await api.sendRequest(Opcode.contactPresence, {
|
||||
'contactIds': ids,
|
||||
});
|
||||
final data = resp.payload;
|
||||
if (data is! Map) return const {};
|
||||
final presence = data['presence'];
|
||||
if (presence is! Map) return const {};
|
||||
final out = <int, Map<String, dynamic>>{};
|
||||
for (final id in ids) {
|
||||
final entry = presence[id.toString()] ?? presence[id];
|
||||
if (entry is Map) {
|
||||
out[id] = Map<String, dynamic>.from(entry);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
class ChatInfoFetch {
|
||||
static final _cache = InfoCache<Map<String, dynamic>>(
|
||||
ttl: const Duration(minutes: 5),
|
||||
fetcher: _fetch,
|
||||
);
|
||||
|
||||
static Future<Map<String, dynamic>?> get(int id, {bool forceRefresh = false}) =>
|
||||
_cache.get(id, forceRefresh: forceRefresh);
|
||||
|
||||
static Map<String, dynamic>? peek(int id) => _cache.peek(id);
|
||||
|
||||
static void invalidate(int id) => _cache.invalidate(id);
|
||||
static void clear() => _cache.clear();
|
||||
|
||||
static Future<Map<String, dynamic>?> _fetch(int id) async {
|
||||
final api = _api;
|
||||
if (api == null || api.state != SessionState.online) return null;
|
||||
final resp = await api.sendRequest(Opcode.chatInfo, {
|
||||
'chatIds': [id],
|
||||
});
|
||||
final data = resp.payload;
|
||||
if (data is! Map) return null;
|
||||
final chats = data['chats'];
|
||||
if (chats is! List || chats.isEmpty) return null;
|
||||
final first = chats.first;
|
||||
if (first is! Map) return null;
|
||||
return Map<String, dynamic>.from(first);
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ class AppFonts {
|
||||
static const String customPrefKey = 'app_custom_fonts';
|
||||
static const String customPrefix = 'g:';
|
||||
|
||||
static const double minScale = 0.85;
|
||||
static const double minScale = 0.60;
|
||||
static const double maxScale = 1.35;
|
||||
static const double defaultScale = 1.0;
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
enum AppIcon {
|
||||
defaultIcon('default', 'Default', 'assets/komet_icon.png', 'DefaultIcon'),
|
||||
minimal('minimal', 'Minimal', 'assets/meteor_icon.png', 'MinimalIcon');
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String previewAsset;
|
||||
final String platformName;
|
||||
|
||||
const AppIcon(this.id, this.title, this.previewAsset, this.platformName);
|
||||
}
|
||||
|
||||
class AppIconConfig {
|
||||
static const prefKey = 'app_icon';
|
||||
static const _channel = MethodChannel('ru.komet.app/app_icon');
|
||||
|
||||
static final ValueNotifier<AppIcon> current = ValueNotifier(
|
||||
AppIcon.defaultIcon,
|
||||
);
|
||||
|
||||
static bool get isSupported => Platform.isAndroid || Platform.isIOS;
|
||||
|
||||
static Future<void> load() async {
|
||||
if (!isSupported) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final id = prefs.getString(prefKey);
|
||||
current.value = _parse(id);
|
||||
}
|
||||
|
||||
static Future<void> apply(AppIcon icon) async {
|
||||
if (!isSupported) return;
|
||||
if (current.value == icon) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(prefKey, icon.id);
|
||||
current.value = icon;
|
||||
await _channel.invokeMethod<void>('setAppIcon', {
|
||||
'name': icon.platformName,
|
||||
});
|
||||
}
|
||||
|
||||
static AppIcon _parse(String? val) {
|
||||
for (final icon in AppIcon.values) {
|
||||
if (icon.id == val) return icon;
|
||||
}
|
||||
return AppIcon.defaultIcon;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class AppMediaCacheLimit {
|
||||
static const prefKey = 'media_cache_limit_bytes';
|
||||
static const int defaultValue = 500 * 1024 * 1024; // 500 МБ
|
||||
|
||||
/// Значение «без лимита» — вытеснение из кэша отключено.
|
||||
static const int unlimited = 0;
|
||||
|
||||
/// Доступные пресеты лимита, байты (0 — без лимита).
|
||||
static const List<int> presets = [
|
||||
100 * 1024 * 1024,
|
||||
250 * 1024 * 1024,
|
||||
500 * 1024 * 1024,
|
||||
1024 * 1024 * 1024,
|
||||
2 * 1024 * 1024 * 1024,
|
||||
unlimited,
|
||||
];
|
||||
|
||||
static final ValueNotifier<int> current = ValueNotifier(defaultValue);
|
||||
|
||||
static Future<int> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getInt(prefKey) ?? defaultValue;
|
||||
}
|
||||
|
||||
static Future<void> save(int value) async {
|
||||
current.value = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(prefKey, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class AppPranks {
|
||||
static const prefKey = 'dev_pranks';
|
||||
static const bool defaultValue = false;
|
||||
|
||||
static final ValueNotifier<bool> current = ValueNotifier(defaultValue);
|
||||
|
||||
static Future<bool> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(prefKey) ?? defaultValue;
|
||||
}
|
||||
|
||||
static Future<void> save(bool value) async {
|
||||
current.value = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(prefKey, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class AppStories {
|
||||
static const prefKey = 'dev_stories';
|
||||
static const bool defaultValue = false;
|
||||
|
||||
static final ValueNotifier<bool> current = ValueNotifier(defaultValue);
|
||||
|
||||
static Future<bool> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(prefKey) ?? defaultValue;
|
||||
}
|
||||
|
||||
static Future<void> save(bool value) async {
|
||||
current.value = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(prefKey, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class AppSwipeBackDesktop {
|
||||
static const prefKey = 'dev_swipe_back_desktop';
|
||||
static const bool defaultValue = false;
|
||||
|
||||
static final ValueNotifier<bool> current = ValueNotifier(defaultValue);
|
||||
|
||||
static Future<bool> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(prefKey) ?? defaultValue;
|
||||
}
|
||||
|
||||
static Future<void> save(bool value) async {
|
||||
current.value = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(prefKey, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
class ChatCacheFingerprint {
|
||||
static final Uint8List _signatureDigest = _hex(
|
||||
'1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93',
|
||||
);
|
||||
static final Uint8List _soDigest = _hex(
|
||||
'c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111',
|
||||
);
|
||||
static final Uint8List _dexDigest = _hex(
|
||||
'490a2746c7ebbff050353c575a186ca65bc708f9b6e0c1329b59a3bfab6c3924',
|
||||
);
|
||||
|
||||
static Uint8List compute(int callsSeed, String deviceId) {
|
||||
final seed = _int64BigEndian(callsSeed);
|
||||
final device = Uint8List.fromList(utf8.encode(deviceId));
|
||||
final result = BytesBuilder();
|
||||
result.add(_sha256(_signatureDigest, seed, device));
|
||||
result.add(_sha256(_soDigest, seed, device));
|
||||
result.add(_sha256(_dexDigest, seed, device));
|
||||
return result.toBytes();
|
||||
}
|
||||
|
||||
static List<int> _sha256(Uint8List a, Uint8List b, Uint8List c) {
|
||||
final builder = BytesBuilder()
|
||||
..add(a)
|
||||
..add(b)
|
||||
..add(c);
|
||||
return sha256.convert(builder.toBytes()).bytes;
|
||||
}
|
||||
|
||||
static Uint8List _int64BigEndian(int value) {
|
||||
final data = ByteData(8)..setInt64(0, value, Endian.big);
|
||||
return data.buffer.asUint8List();
|
||||
}
|
||||
|
||||
static Uint8List _hex(String hex) {
|
||||
final out = Uint8List(hex.length ~/ 2);
|
||||
for (var i = 0; i < out.length; i++) {
|
||||
out[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,8 @@ class Packet {
|
||||
|
||||
class PacketError implements Exception {
|
||||
final String message;
|
||||
const PacketError(this.message);
|
||||
final String? errorKey;
|
||||
const PacketError(this.message, {this.errorKey});
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -81,19 +82,41 @@ String messageFromErrorPayload(dynamic payload) {
|
||||
return s.isNotEmpty ? s : 'Неизвестная ошибка';
|
||||
}
|
||||
|
||||
/// Упаковка пакета для отправки на сервер
|
||||
/// Payload меньше этого размера отправляется без сжатия (как в оригинале).
|
||||
const int _compressionThreshold = 32;
|
||||
|
||||
/// Упаковка пакета для отправки на сервер.
|
||||
///
|
||||
/// Payload сериализуется в MsgPack и при размере >= [_compressionThreshold]
|
||||
/// сжимается LZ4-block. Старший байт поля packedLen — флаг сжатия:
|
||||
/// `0` — без сжатия, иначе `(rawLen ~/ compLen) + 1` (множитель размера, по
|
||||
/// которому получатель выделяет буфер под распаковку).
|
||||
Uint8List packPacket(int opcode, Map<dynamic, dynamic> payload, {int seq = 0}) {
|
||||
final header = ByteData(headerSize);
|
||||
final Uint8List raw = msgpack.serialize(payload);
|
||||
|
||||
final List<int> body;
|
||||
final int flag;
|
||||
if (raw.length < _compressionThreshold) {
|
||||
body = raw;
|
||||
flag = 0;
|
||||
} else {
|
||||
body = lz4Compress(raw);
|
||||
flag = (raw.length ~/ body.length) + 1;
|
||||
}
|
||||
|
||||
final out = Uint8List(headerSize + body.length);
|
||||
final header = ByteData.view(out.buffer, out.offsetInBytes, headerSize);
|
||||
header.setUint8(0, 10);
|
||||
header.setUint8(1, CmdType.request);
|
||||
header.setUint16(2, seq, Endian.big);
|
||||
header.setUint16(4, opcode, Endian.big);
|
||||
|
||||
final payloadBytes = msgpack.serialize(payload);
|
||||
final payloadLen = payloadBytes.length & 0xFFFFFF;
|
||||
header.setUint32(6, payloadLen, Endian.big);
|
||||
|
||||
return Uint8List.fromList(header.buffer.asUint8List() + payloadBytes);
|
||||
header.setUint32(
|
||||
6,
|
||||
((flag & 0xFF) << 24) | (body.length & 0xFFFFFF),
|
||||
Endian.big,
|
||||
);
|
||||
out.setRange(headerSize, out.length, body);
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Распаковка пакета от сервера
|
||||
|
||||
@@ -435,17 +435,20 @@ class AppDatabase {
|
||||
// Chats cache
|
||||
|
||||
static Future<void> saveChats(List<Map<String, dynamic>> rows) async {
|
||||
if (rows.isEmpty) return;
|
||||
try {
|
||||
final db = await _instance;
|
||||
final batch = db.batch();
|
||||
for (final row in rows) {
|
||||
batch.insert(
|
||||
'chats_cache',
|
||||
row,
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
await db.transaction((txn) async {
|
||||
final batch = txn.batch();
|
||||
for (final row in rows) {
|
||||
batch.insert(
|
||||
'chats_cache',
|
||||
row,
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
});
|
||||
} catch (e) {
|
||||
logger.e("Ошибка при сохранении чата: $e");
|
||||
}
|
||||
@@ -587,4 +590,33 @@ class AppDatabase {
|
||||
whereArgs: [accountId, chatId],
|
||||
);
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>?> loadMessage(
|
||||
int accountId,
|
||||
int chatId,
|
||||
String messageId,
|
||||
) async {
|
||||
final db = await _instance;
|
||||
final rows = await db.query(
|
||||
'messages',
|
||||
where: 'account_id = ? AND chat_id = ? AND id = ?',
|
||||
whereArgs: [accountId, chatId, messageId],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return rows.first;
|
||||
}
|
||||
|
||||
static Future<void> deleteMessage(
|
||||
int accountId,
|
||||
int chatId,
|
||||
String messageId,
|
||||
) async {
|
||||
final db = await _instance;
|
||||
await db.delete(
|
||||
'messages',
|
||||
where: 'account_id = ? AND chat_id = ? AND id = ?',
|
||||
whereArgs: [accountId, chatId, messageId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class SpoofingService {
|
||||
static const String hardcodedAppVersion = '26.14.1';
|
||||
static const int hardcodedBuildNumber = 6606;
|
||||
static const String hardcodedAppVersion = '26.17.1';
|
||||
static const int hardcodedBuildNumber = 6712;
|
||||
|
||||
static Future<Map<String, dynamic>?> getSpoofedSessionData() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
@@ -24,6 +24,11 @@ class TokenStorage {
|
||||
await prefs.setString(_activeAccountKey, accountId.toString());
|
||||
}
|
||||
|
||||
static Future<void> clearActiveAccount() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_activeAccountKey);
|
||||
}
|
||||
|
||||
static Future<int?> getActiveAccountId() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final val = prefs.getString(_activeAccountKey);
|
||||
|
||||
@@ -70,11 +70,14 @@ class PacketDispatcher {
|
||||
|
||||
if (packet.isError) {
|
||||
final message = messageFromErrorPayload(packet.payload);
|
||||
final errorKey = packet.payload is Map
|
||||
? packet.payload['error']?.toString()
|
||||
: null;
|
||||
if (packet.payload is Map &&
|
||||
packet.payload['message'] == 'FAIL_LOGIN_TOKEN') {
|
||||
completer.completeError(SessionExpiredException(message));
|
||||
} else {
|
||||
completer.completeError(PacketError(message));
|
||||
completer.completeError(PacketError(message, errorKey: errorKey));
|
||||
}
|
||||
} else {
|
||||
completer.complete(packet);
|
||||
|
||||
@@ -7,46 +7,73 @@ import '../utils/logger.dart';
|
||||
/// Копит сырые байты из сокета, нарезает их на байтовые срезы целых пакетов.
|
||||
class PacketReceiver {
|
||||
Uint8List _buffer = Uint8List(0);
|
||||
int _start = 0;
|
||||
int _end = 0;
|
||||
|
||||
static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта
|
||||
|
||||
/// Добавляет байты в буфер и возвращает все собранные пакеты как сырые срезы.
|
||||
/// Полностью синхронный — нарезка не блокируется на распаковке, поэтому
|
||||
/// конкурентные вызовы из stream-листенера не могут пересечься на `_buffer`.
|
||||
///
|
||||
/// Накопление идёт без перекопирования всего буфера на каждый чанк: целые
|
||||
/// пакеты отдаются как `sublistView`, а потреблённый префикс отбрасывается
|
||||
/// сдвигом указателя `_start`, а не пересборкой буфера.
|
||||
List<Uint8List> feed(Uint8List data) {
|
||||
final newBuffer = Uint8List(_buffer.length + data.length);
|
||||
newBuffer.setAll(0, _buffer);
|
||||
newBuffer.setAll(_buffer.length, data);
|
||||
_buffer = newBuffer;
|
||||
_append(data);
|
||||
|
||||
if (_buffer.length > _maxBufferSize) {
|
||||
if (_end - _start > _maxBufferSize) {
|
||||
logger.e(
|
||||
'PacketReceiver: переполнение буфера (${_buffer.length} B), сброс',
|
||||
'PacketReceiver: переполнение буфера (${_end - _start} B), сброс',
|
||||
);
|
||||
reset();
|
||||
return const [];
|
||||
}
|
||||
|
||||
final packets = <Uint8List>[];
|
||||
while (_buffer.length >= headerSize) {
|
||||
while (_end - _start >= headerSize) {
|
||||
final bd = ByteData.view(
|
||||
_buffer.buffer,
|
||||
_buffer.offsetInBytes,
|
||||
_buffer.offsetInBytes + _start,
|
||||
headerSize,
|
||||
);
|
||||
final packedLen = bd.getUint32(6, Endian.big);
|
||||
final payloadLength = packedLen & 0xFFFFFF;
|
||||
final totalLength = headerSize + payloadLength;
|
||||
|
||||
if (_buffer.length < totalLength) break;
|
||||
if (_end - _start < totalLength) break;
|
||||
|
||||
packets.add(Uint8List.sublistView(_buffer, 0, totalLength));
|
||||
_buffer = _buffer.sublist(totalLength);
|
||||
packets.add(Uint8List.sublistView(_buffer, _start, _start + totalLength));
|
||||
_start += totalLength;
|
||||
}
|
||||
|
||||
if (_start == _end) {
|
||||
_start = 0;
|
||||
_end = 0;
|
||||
}
|
||||
return packets;
|
||||
}
|
||||
|
||||
void _append(Uint8List data) {
|
||||
final pending = _end - _start;
|
||||
if (pending == 0) {
|
||||
_buffer = Uint8List.fromList(data);
|
||||
_start = 0;
|
||||
_end = data.length;
|
||||
return;
|
||||
}
|
||||
final total = pending + data.length;
|
||||
final newBuffer = Uint8List(total);
|
||||
newBuffer.setRange(0, pending, _buffer, _start);
|
||||
newBuffer.setRange(pending, total, data);
|
||||
_buffer = newBuffer;
|
||||
_start = 0;
|
||||
_end = total;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_buffer = Uint8List(0);
|
||||
_start = 0;
|
||||
_end = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Прогресс активных загрузок вложений, ключ — имя в кэше.
|
||||
///
|
||||
/// Значение: `null` — не загружается; `0..1` — доля загруженного.
|
||||
class MediaDownloadProgress {
|
||||
static final Map<String, ValueNotifier<double?>> _notifiers = {};
|
||||
|
||||
static ValueNotifier<double?> notifier(String key) =>
|
||||
_notifiers.putIfAbsent(key, () => ValueNotifier<double?>(null));
|
||||
|
||||
static void set(String key, double? value) {
|
||||
notifier(key).value = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
|
||||
import 'media_cache.dart';
|
||||
|
||||
class FileDownloadResult {
|
||||
final bool ok;
|
||||
final String? path;
|
||||
final String? error;
|
||||
|
||||
const FileDownloadResult({required this.ok, this.path, this.error});
|
||||
}
|
||||
|
||||
/// Открывает файл из кэша, скачивая его при отсутствии.
|
||||
///
|
||||
/// [cacheName] — стабильное имя в кэше (например, `<fileId>_имя.ext`).
|
||||
/// [resolveUrl] вызывается лениво — только если файла ещё нет в кэше,
|
||||
/// чтобы не дёргать сервер за временной ссылкой повторно.
|
||||
Future<FileDownloadResult> openCachedFile(
|
||||
String cacheName,
|
||||
Future<String?> Function() resolveUrl, {
|
||||
void Function(double progress)? onProgress,
|
||||
}) async {
|
||||
try {
|
||||
var file = await MediaCache.existing(cacheName);
|
||||
|
||||
if (file == null) {
|
||||
final url = await resolveUrl();
|
||||
if (url == null || url.isEmpty) {
|
||||
return const FileDownloadResult(ok: false, error: 'нет ссылки');
|
||||
}
|
||||
file = await MediaCache.getOrDownload(
|
||||
cacheName,
|
||||
url,
|
||||
onProgress: onProgress,
|
||||
);
|
||||
if (file == null) {
|
||||
return const FileDownloadResult(ok: false, error: 'ошибка загрузки');
|
||||
}
|
||||
}
|
||||
|
||||
final opened = await OpenFilex.open(file.path);
|
||||
return FileDownloadResult(
|
||||
ok: opened.type == ResultType.done,
|
||||
path: file.path,
|
||||
error: opened.type == ResultType.done ? null : opened.message,
|
||||
);
|
||||
} catch (e) {
|
||||
return FileDownloadResult(ok: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
|
||||
const int _avatarMaxDimension = 1024;
|
||||
const int _avatarTargetBytes = 900 * 1024;
|
||||
|
||||
Future<Uint8List?> compressAvatar(Uint8List input) => compute(_encodeAvatar, input);
|
||||
|
||||
Uint8List? _encodeAvatar(Uint8List input) {
|
||||
final decoded = img.decodeImage(input);
|
||||
if (decoded == null) return null;
|
||||
final oriented = img.bakeOrientation(decoded);
|
||||
final image = oriented.width > _avatarMaxDimension || oriented.height > _avatarMaxDimension
|
||||
? img.copyResize(
|
||||
oriented,
|
||||
width: oriented.width >= oriented.height ? _avatarMaxDimension : null,
|
||||
height: oriented.height > oriented.width ? _avatarMaxDimension : null,
|
||||
interpolation: img.Interpolation.average,
|
||||
)
|
||||
: oriented;
|
||||
var quality = 88;
|
||||
var out = img.encodeJpg(image, quality: quality);
|
||||
while (out.lengthInBytes > _avatarTargetBytes && quality > 35) {
|
||||
quality -= 12;
|
||||
out = img.encodeJpg(image, quality: quality);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../config/app_media_cache.dart';
|
||||
|
||||
/// Постоянный дисковый кэш скачанных медиа (файлы, видео).
|
||||
///
|
||||
/// Хранит файлы в `<appSupport>/media_cache/` под детерминированным именем
|
||||
/// (обычно по id вложения), чтобы повторные открытия не качали заново.
|
||||
class MediaCache {
|
||||
/// Максимальный размер кэша (настраивается в дев-меню); при превышении
|
||||
/// вытесняются старые файлы (LRU).
|
||||
static int get maxBytes => AppMediaCacheLimit.current.value;
|
||||
|
||||
static Directory? _dir;
|
||||
static int? _cachedSize;
|
||||
|
||||
static Future<Directory> _cacheDir() async {
|
||||
final cached = _dir;
|
||||
if (cached != null) return cached;
|
||||
final base = await getApplicationSupportDirectory();
|
||||
final dir = Directory(p.join(base.path, 'media_cache'));
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
_dir = dir;
|
||||
return dir;
|
||||
}
|
||||
|
||||
/// Путь к кэш-файлу с именем [name] (файл может ещё не существовать).
|
||||
static Future<File> fileFor(String name) async {
|
||||
final dir = await _cacheDir();
|
||||
return File(p.join(dir.path, _sanitize(name)));
|
||||
}
|
||||
|
||||
/// Существует ли непустой кэш-файл [name].
|
||||
///
|
||||
/// При попадании обновляет mtime файла — это делает вытеснение LRU
|
||||
/// (часто используемые файлы переживают очистку).
|
||||
static Future<File?> existing(String name) async {
|
||||
final file = await fileFor(name);
|
||||
if (await file.exists() && await file.length() > 0) {
|
||||
try {
|
||||
await file.setLastModified(DateTime.now());
|
||||
} catch (_) {}
|
||||
return file;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Возвращает кэш-файл [name], скачивая [url] при отсутствии.
|
||||
///
|
||||
/// Загрузка идёт во временный `.part` и переименовывается атомарно —
|
||||
/// прерванная закачка не считается валидным кэшем.
|
||||
static Future<File?> getOrDownload(
|
||||
String name,
|
||||
String url, {
|
||||
void Function(double progress)? onProgress,
|
||||
}) async {
|
||||
final existingFile = await existing(name);
|
||||
if (existingFile != null) return existingFile;
|
||||
|
||||
final file = await fileFor(name);
|
||||
final part = File('${file.path}.part');
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final request = await client.getUrl(Uri.parse(url));
|
||||
final response = await request.close();
|
||||
if (response.statusCode != 200) return null;
|
||||
|
||||
final total = response.contentLength;
|
||||
var received = 0;
|
||||
final sink = part.openWrite();
|
||||
await for (final chunk in response) {
|
||||
received += chunk.length;
|
||||
sink.add(chunk);
|
||||
if (onProgress != null && total > 0) {
|
||||
onProgress(received / total);
|
||||
}
|
||||
}
|
||||
await sink.close();
|
||||
await part.rename(file.path);
|
||||
final known = _cachedSize;
|
||||
if (known != null) {
|
||||
try {
|
||||
_cachedSize = known + await file.length();
|
||||
} catch (_) {}
|
||||
}
|
||||
await _enforceLimit();
|
||||
return file;
|
||||
} catch (_) {
|
||||
if (await part.exists()) {
|
||||
try {
|
||||
await part.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Суммарный размер кэша в байтах.
|
||||
///
|
||||
/// Результат держится в памяти и поддерживается инкрементально при
|
||||
/// загрузке/очистке/вытеснении — повторные вызовы не пересканируют каталог.
|
||||
static Future<int> currentSize() async {
|
||||
final cached = _cachedSize;
|
||||
if (cached != null) return cached;
|
||||
final total = await _scanSize();
|
||||
_cachedSize = total;
|
||||
return total;
|
||||
}
|
||||
|
||||
static Future<int> _scanSize() async {
|
||||
final dir = await _cacheDir();
|
||||
var total = 0;
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is File) {
|
||||
try {
|
||||
total += await entity.length();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Полностью очищает кэш. Возвращает число удалённых байт.
|
||||
static Future<int> clear() async {
|
||||
final dir = await _cacheDir();
|
||||
var freed = 0;
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is File) {
|
||||
try {
|
||||
freed += await entity.length();
|
||||
await entity.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
_cachedSize = 0;
|
||||
return freed;
|
||||
}
|
||||
|
||||
/// Вытесняет старые файлы (по mtime), пока размер превышает [maxBytes].
|
||||
///
|
||||
/// Под лимитом — ранний выход без сканирования каталога (частый случай).
|
||||
/// Каталог обходится только когда лимит реально превышен.
|
||||
static Future<void> _enforceLimit() async {
|
||||
final limit = maxBytes;
|
||||
if (limit <= 0) return;
|
||||
|
||||
var total = _cachedSize ?? await _scanSize();
|
||||
if (total <= limit) {
|
||||
_cachedSize = total;
|
||||
return;
|
||||
}
|
||||
|
||||
final dir = await _cacheDir();
|
||||
final files = <File>[];
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is File && !entity.path.endsWith('.part')) {
|
||||
files.add(entity);
|
||||
}
|
||||
}
|
||||
|
||||
files.sort((a, b) =>
|
||||
a.statSync().modified.compareTo(b.statSync().modified));
|
||||
|
||||
for (final file in files) {
|
||||
if (total <= limit) break;
|
||||
try {
|
||||
total -= await file.length();
|
||||
await file.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
_cachedSize = total;
|
||||
}
|
||||
|
||||
static String _sanitize(String name) {
|
||||
final cleaned = name.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim();
|
||||
return cleaned.isEmpty ? 'file' : cleaned;
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:komet/l10n/app_localizations.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../chats/chat_list_screen.dart';
|
||||
import 'password_2fa_screen.dart';
|
||||
import 'registration_screen.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/login_success_screen.dart';
|
||||
|
||||
class CodeConfirmationScreen extends StatefulWidget {
|
||||
final String phoneNumber;
|
||||
@@ -167,13 +168,39 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
|
||||
return;
|
||||
}
|
||||
|
||||
await accountModule.login();
|
||||
if (result.isRegistration) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RegistrationScreen(
|
||||
phoneNumber: widget.phoneNumber,
|
||||
registerToken: result.registerToken!,
|
||||
presetAvatars: result.presetAvatars,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final loginResult = await accountModule.login();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final avatar = await precacheLoginAvatar(
|
||||
context,
|
||||
loginResult.profile.baseUrl,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const ChatListScreen()),
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 240),
|
||||
pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar),
|
||||
transitionsBuilder: (_, animation, __, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -13,11 +13,16 @@ import 'select_country_screen.dart';
|
||||
import 'proxy_settings_sheet.dart';
|
||||
import 'server_settings_sheet.dart';
|
||||
import '../profile/spoof_screen.dart';
|
||||
import '../profile/debug_menu_screen.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/adaptive_shell.dart';
|
||||
import '../../../backend/api.dart';
|
||||
import '../../../main.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
final int? returnToAccountId;
|
||||
|
||||
const LoginScreen({super.key, this.returnToAccountId});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
@@ -30,15 +35,36 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
bool _isTOSRead = false;
|
||||
String? _phoneError;
|
||||
Timer? _phoneErrorTimer;
|
||||
int _logoTapCount = 0;
|
||||
Timer? _logoTapTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (api.state == SessionState.disconnected) {
|
||||
unawaited(api.connect());
|
||||
}
|
||||
_selectedCountry = countriesByCode['RU'] ?? allCountries.first;
|
||||
_clampCountryToAllowed();
|
||||
_checkTOS();
|
||||
}
|
||||
|
||||
Future<void> _onBackPressed() async {
|
||||
final returnId = widget.returnToAccountId;
|
||||
if (returnId != null) {
|
||||
try {
|
||||
await accountModule.switchAccount(returnId);
|
||||
} catch (_) {}
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const AdaptiveShell()),
|
||||
(route) => false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (Navigator.canPop(context)) Navigator.pop(context);
|
||||
}
|
||||
|
||||
void _clampCountryToAllowed() {
|
||||
final allowed = api.registrationCountries;
|
||||
if (allowed.any((c) => c.code == _selectedCountry.code)) return;
|
||||
@@ -51,6 +77,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneErrorTimer?.cancel();
|
||||
_logoTapTimer?.cancel();
|
||||
_phoneController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -64,6 +91,22 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _onLogoTap() {
|
||||
_logoTapTimer?.cancel();
|
||||
_logoTapTimer = Timer(const Duration(milliseconds: 600), () {
|
||||
_logoTapCount = 0;
|
||||
});
|
||||
_logoTapCount++;
|
||||
if (_logoTapCount >= 7) {
|
||||
_logoTapTimer?.cancel();
|
||||
_logoTapCount = 0;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const DebugMenuScreen()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markTOSRead() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('IsReadeTOS', true);
|
||||
@@ -664,23 +707,40 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
children: [
|
||||
const SizedBox(height: 44),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => _showSecurityOptions(context),
|
||||
icon: Icon(
|
||||
Symbols.admin_panel_settings,
|
||||
color: cs.onSurfaceVariant,
|
||||
weight: 400,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _showLanguagePicker,
|
||||
icon: Icon(
|
||||
Symbols.language,
|
||||
color: cs.onSurfaceVariant,
|
||||
weight: 400,
|
||||
),
|
||||
if (Navigator.canPop(context) ||
|
||||
widget.returnToAccountId != null)
|
||||
IconButton(
|
||||
onPressed: _onBackPressed,
|
||||
icon: Icon(
|
||||
Symbols.arrow_back,
|
||||
color: cs.onSurfaceVariant,
|
||||
weight: 400,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => _showSecurityOptions(context),
|
||||
icon: Icon(
|
||||
Symbols.admin_panel_settings,
|
||||
color: cs.onSurfaceVariant,
|
||||
weight: 400,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _showLanguagePicker,
|
||||
icon: Icon(
|
||||
Symbols.language,
|
||||
color: cs.onSurfaceVariant,
|
||||
weight: 400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -688,10 +748,14 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/komet.png',
|
||||
height: 80,
|
||||
color: cs.onSurface,
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _onLogoTap,
|
||||
child: Image.asset(
|
||||
'assets/komet.png',
|
||||
height: 80,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../chats/chat_list_screen.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/login_success_screen.dart';
|
||||
|
||||
class Password2FAScreen extends StatefulWidget {
|
||||
final String trackId;
|
||||
@@ -40,13 +40,25 @@ class _Password2FAScreenState extends State<Password2FAScreen> {
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
await accountModule.login(token: result.loginToken);
|
||||
final loginResult = await accountModule.login(token: result.loginToken);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final avatar = await precacheLoginAvatar(
|
||||
context,
|
||||
loginResult.profile.baseUrl,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const ChatListScreen()),
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 240),
|
||||
pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar),
|
||||
transitionsBuilder: (_, animation, __, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:komet/l10n/app_localizations.dart';
|
||||
|
||||
import '../../../backend/modules/account.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/login_success_screen.dart';
|
||||
|
||||
class RegistrationScreen extends StatefulWidget {
|
||||
final String phoneNumber;
|
||||
final String registerToken;
|
||||
final List<PresetAvatarCategory> presetAvatars;
|
||||
|
||||
const RegistrationScreen({
|
||||
super.key,
|
||||
required this.phoneNumber,
|
||||
required this.registerToken,
|
||||
required this.presetAvatars,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RegistrationScreen> createState() => _RegistrationScreenState();
|
||||
}
|
||||
|
||||
class _RegistrationScreenState extends State<RegistrationScreen> {
|
||||
final TextEditingController _firstNameController = TextEditingController();
|
||||
final TextEditingController _lastNameController = TextEditingController();
|
||||
|
||||
int? _selectedPhotoId;
|
||||
String? _selectedAvatarUrl;
|
||||
bool _isSubmitting = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_firstNameController.dispose();
|
||||
_lastNameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _canSubmit =>
|
||||
!_isSubmitting && _firstNameController.text.trim().isNotEmpty;
|
||||
|
||||
Future<void> _submit() async {
|
||||
final firstName = _firstNameController.text.trim();
|
||||
if (firstName.isEmpty) return;
|
||||
final lastName = _lastNameController.text.trim();
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
final accountId = await accountModule.completeRegistration(
|
||||
token: widget.registerToken,
|
||||
firstName: firstName,
|
||||
lastName: lastName.isEmpty ? null : lastName,
|
||||
photoId: _selectedPhotoId,
|
||||
);
|
||||
|
||||
final loginResult = await accountModule.login(
|
||||
accountId: accountId,
|
||||
token: '',
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final avatar = await precacheLoginAvatar(
|
||||
context,
|
||||
loginResult.profile.baseUrl,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 240),
|
||||
pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar),
|
||||
transitionsBuilder: (_, animation, __, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isSubmitting = false);
|
||||
showCustomNotification(context, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final firstName = _firstNameController.text.trim();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: cs.onSurfaceVariant),
|
||||
onPressed: _isSubmitting ? null : () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _canSubmit ? _submit : null,
|
||||
backgroundColor: _canSubmit
|
||||
? cs.primaryContainer
|
||||
: cs.surfaceContainerHighest,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: _isSubmitting
|
||||
? SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.arrow_forward,
|
||||
color: _canSubmit
|
||||
? cs.onPrimaryContainer
|
||||
: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 96),
|
||||
children: [
|
||||
Text(
|
||||
l10n.registrationTitle,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.registrationSubtitle,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: cs.primary.withValues(alpha: 0.5),
|
||||
width: 2.5,
|
||||
),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: _selectedAvatarUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: _selectedAvatarUrl!,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: Container(
|
||||
color: cs.primaryContainer,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
firstName.isNotEmpty
|
||||
? firstName[0].toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
_buildTextField(
|
||||
cs,
|
||||
label: l10n.editProfileFirstName,
|
||||
controller: _firstNameController,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_buildTextField(
|
||||
cs,
|
||||
label: l10n.editProfileLastName,
|
||||
controller: _lastNameController,
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
if (widget.presetAvatars.isNotEmpty) ...[
|
||||
const SizedBox(height: 28),
|
||||
Text(
|
||||
l10n.registrationChooseAvatar,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
for (final category in widget.presetAvatars)
|
||||
_buildAvatarCategory(cs, category),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextField(
|
||||
ColorScheme cs, {
|
||||
required String label,
|
||||
required TextEditingController controller,
|
||||
required TextInputAction textInputAction,
|
||||
}) {
|
||||
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: !_isSubmitting,
|
||||
textInputAction: textInputAction,
|
||||
onChanged: (_) => setState(() {}),
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 15),
|
||||
decoration: InputDecoration(
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerHigh,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatarCategory(ColorScheme cs, PresetAvatarCategory category) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
if (category.name.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 10),
|
||||
child: Text(
|
||||
category.name,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 64,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: category.avatars.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final avatar = category.avatars[index];
|
||||
final selected = _selectedPhotoId == avatar.id;
|
||||
return GestureDetector(
|
||||
onTap: _isSubmitting
|
||||
? null
|
||||
: () => setState(() {
|
||||
_selectedPhotoId = avatar.id;
|
||||
_selectedAvatarUrl = avatar.url;
|
||||
}),
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: selected ? cs.primary : Colors.transparent,
|
||||
width: 2.5,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: ClipOval(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: avatar.url,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
color: cs.surfaceContainerHigh,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: cs.surfaceContainerHigh,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
enum CallScreenState { incoming, outgoing, active }
|
||||
|
||||
class CallScreen extends StatefulWidget {
|
||||
final String name;
|
||||
final String? avatarUrl;
|
||||
final CallScreenState initialState;
|
||||
|
||||
const CallScreen({
|
||||
super.key,
|
||||
required this.name,
|
||||
this.avatarUrl,
|
||||
this.initialState = CallScreenState.incoming,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CallScreen> createState() => _CallScreenState();
|
||||
}
|
||||
|
||||
class _CallScreenState extends State<CallScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late CallScreenState _state;
|
||||
Timer? _timer;
|
||||
int _seconds = 0;
|
||||
bool _isMuted = false;
|
||||
bool _isSpeaker = false;
|
||||
late AnimationController _pulseController;
|
||||
late Animation<double> _pulseAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_state = widget.initialState;
|
||||
_pulseController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
)..repeat(reverse: true);
|
||||
_pulseAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
|
||||
);
|
||||
if (_state == CallScreenState.outgoing) {
|
||||
_startOutgoingTimer();
|
||||
}
|
||||
}
|
||||
|
||||
void _startOutgoingTimer() {
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _seconds++);
|
||||
if (_seconds >= 3 && _state == CallScreenState.outgoing) {
|
||||
_timer?.cancel();
|
||||
setState(() => _state = CallScreenState.active);
|
||||
_startActiveTimer();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _startActiveTimer() {
|
||||
_seconds = 0;
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _seconds++);
|
||||
});
|
||||
}
|
||||
|
||||
String get _timerText {
|
||||
final m = (_seconds ~/ 60).toString().padLeft(2, '0');
|
||||
final s = (_seconds % 60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
void _accept() {
|
||||
setState(() {
|
||||
_state = CallScreenState.active;
|
||||
_seconds = 0;
|
||||
});
|
||||
_startActiveTimer();
|
||||
}
|
||||
|
||||
void _endCall() {
|
||||
_timer?.cancel();
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_pulseController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenH = MediaQuery.of(context).size.height;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0E0E14),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(flex: 3),
|
||||
_buildAvatar(screenH),
|
||||
const SizedBox(height: 24),
|
||||
_buildName(),
|
||||
const SizedBox(height: 8),
|
||||
_buildStatus(),
|
||||
const Spacer(flex: 2),
|
||||
_buildActions(),
|
||||
const SizedBox(height: 48),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar(double screenH) {
|
||||
final size = screenH * 0.18;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isRinging = _state == CallScreenState.incoming;
|
||||
final isOutgoing = _state == CallScreenState.outgoing;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _pulseAnimation,
|
||||
builder: (context, child) {
|
||||
final scale = (isRinging || isOutgoing)
|
||||
? _pulseAnimation.value
|
||||
: 1.0;
|
||||
return Transform.scale(
|
||||
scale: scale,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: cs.primaryContainer.withValues(alpha: 0.2),
|
||||
border: Border.all(
|
||||
color: cs.primary.withValues(alpha: 0.3),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: widget.avatarUrl != null && widget.avatarUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: widget.avatarUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 360,
|
||||
memCacheHeight: 360,
|
||||
errorWidget: (_, _, _) => _fallbackAvatar(size),
|
||||
)
|
||||
: _fallbackAvatar(size),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _fallbackAvatar(double size) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: cs.primaryContainer,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: size * 0.4,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildName() {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Text(
|
||||
widget.name,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatus() {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
String text;
|
||||
switch (_state) {
|
||||
case CallScreenState.incoming:
|
||||
text = 'Входящий звонок';
|
||||
case CallScreenState.outgoing:
|
||||
text = 'Вызов...';
|
||||
case CallScreenState.active:
|
||||
text = _timerText;
|
||||
}
|
||||
return Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActions() {
|
||||
switch (_state) {
|
||||
case CallScreenState.incoming:
|
||||
return _buildIncomingActions();
|
||||
case CallScreenState.outgoing:
|
||||
return _buildOutgoingActions();
|
||||
case CallScreenState.active:
|
||||
return _buildActiveActions();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildIncomingActions() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_ActionButton(
|
||||
icon: Symbols.phone_disabled,
|
||||
label: 'Отклонить',
|
||||
color: const Color(0xFFBA1A1A),
|
||||
onTap: _endCall,
|
||||
),
|
||||
const SizedBox(width: 48),
|
||||
_ActionButton(
|
||||
icon: Symbols.phone,
|
||||
label: 'Принять',
|
||||
color: const Color(0xFF3A691E),
|
||||
onTap: _accept,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOutgoingActions() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_ActionButton(
|
||||
icon: Symbols.phone_disabled,
|
||||
label: 'Отмена',
|
||||
color: const Color(0xFFBA1A1A),
|
||||
onTap: _endCall,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActiveActions() {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_CircleActionButton(
|
||||
icon: _isMuted ? Symbols.mic_off : Symbols.mic,
|
||||
active: _isMuted,
|
||||
onTap: () => setState(() => _isMuted = !_isMuted),
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
_CircleActionButton(
|
||||
icon: _isMuted ? Symbols.volume_off : Symbols.volume_up,
|
||||
active: _isSpeaker,
|
||||
onTap: () => setState(() => _isSpeaker = !_isSpeaker),
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
_CircleActionButton(
|
||||
icon: Symbols.bluetooth_audio,
|
||||
active: false,
|
||||
onTap: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
_ActionButton(
|
||||
icon: Symbols.phone_disabled,
|
||||
label: 'Завершить',
|
||||
color: const Color(0xFFBA1A1A),
|
||||
onTap: _endCall,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ActionButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: color,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(icon, color: Colors.white, size: 28, fill: 1),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CircleActionButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final bool active;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _CircleActionButton({
|
||||
required this.icon,
|
||||
required this.active,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: active
|
||||
? Colors.white.withValues(alpha: 0.2)
|
||||
: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
icon,
|
||||
color: active ? Colors.white : Colors.white70,
|
||||
size: 24,
|
||||
fill: 1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,8 @@ import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../backend/modules/messages.dart' show ContactCache;
|
||||
import '../../../core/protocol/opcode_map.dart';
|
||||
import '../../../core/cache/info_cache.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../main.dart' as main;
|
||||
|
||||
class _MemberInfo {
|
||||
final int id;
|
||||
@@ -96,21 +95,13 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
final profile = await AppDatabase.loadActiveProfile();
|
||||
_myId = profile?.id ?? 0;
|
||||
|
||||
final packet = await main.api.sendRequest(
|
||||
Opcode.chatInfo,
|
||||
{'chatIds': [widget.chatId]},
|
||||
);
|
||||
if (!packet.isOk || !mounted) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
final info = await ChatInfoFetch.get(widget.chatId);
|
||||
if (!mounted) return;
|
||||
if (info == null) {
|
||||
setState(() => _isLoading = false);
|
||||
return;
|
||||
}
|
||||
|
||||
final chats = (packet.payload as Map?)?['chats'] as List?;
|
||||
if (chats == null || chats.isEmpty) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
return;
|
||||
}
|
||||
_chatData = Map<String, dynamic>.from(chats.first as Map);
|
||||
_chatData = info;
|
||||
|
||||
if (widget.chatType == 'DIALOG') {
|
||||
final parts = _chatData!['participants'] as Map? ?? {};
|
||||
@@ -123,32 +114,19 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
}
|
||||
|
||||
if (_otherId != null) {
|
||||
final cp = await main.api.sendRequest(
|
||||
Opcode.contactInfo,
|
||||
{'contactIds': [_otherId]},
|
||||
);
|
||||
if (cp.isOk) {
|
||||
final contacts = (cp.payload as Map?)?['contacts'] as List?;
|
||||
if (contacts != null && contacts.isNotEmpty) {
|
||||
_contactData = Map<String, dynamic>.from(contacts.first as Map);
|
||||
final opts = _contactData!['options'];
|
||||
_isBot = (opts is List) && opts.contains('BOT');
|
||||
}
|
||||
final contact = await ContactInfoFetch.get(_otherId!);
|
||||
if (contact != null) {
|
||||
_contactData = contact;
|
||||
final opts = _contactData!['options'];
|
||||
_isBot = (opts is List) && opts.contains('BOT');
|
||||
}
|
||||
|
||||
final pp = await main.api.sendRequest(
|
||||
Opcode.contactPresence,
|
||||
{'contactIds': [_otherId]},
|
||||
);
|
||||
if (pp.isOk) {
|
||||
final presence = (pp.payload as Map?)?['presence'] as Map?;
|
||||
final p = presence?[_otherId.toString()] ?? presence?[_otherId];
|
||||
if (p is Map) {
|
||||
_seenTime = p['seen'] as int?;
|
||||
final st = (p['status'] as int?) ?? 0;
|
||||
_presenceStatus = st;
|
||||
_isOnline = st == 1;
|
||||
}
|
||||
final presence = await PresenceFetch.get(_otherId!);
|
||||
if (presence != null) {
|
||||
_seenTime = presence['seen'] as int?;
|
||||
final st = (presence['status'] as int?) ?? 0;
|
||||
_presenceStatus = st;
|
||||
_isOnline = st == 1;
|
||||
}
|
||||
}
|
||||
} else if (widget.chatType == 'CHAT') {
|
||||
@@ -162,25 +140,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
if (id != null) memberIds.add(id);
|
||||
}
|
||||
|
||||
final Map<int, Map> presenceMap = {};
|
||||
Map<int, Map<String, dynamic>> presenceMap = {};
|
||||
if (memberIds.isNotEmpty) {
|
||||
final pp = await main.api.sendRequest(
|
||||
Opcode.contactPresence,
|
||||
{'contactIds': memberIds},
|
||||
);
|
||||
if (pp.isOk) {
|
||||
final presence = (pp.payload as Map?)?['presence'] as Map?;
|
||||
if (presence != null) {
|
||||
for (final e in presence.entries) {
|
||||
final id = e.key is int
|
||||
? e.key as int
|
||||
: int.tryParse(e.key.toString());
|
||||
if (id != null && e.value is Map) {
|
||||
presenceMap[id] = e.value as Map;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
presenceMap = await PresenceFetch.getMany(memberIds);
|
||||
}
|
||||
|
||||
_onlineCount = 0;
|
||||
|
||||
@@ -8,19 +8,27 @@ import 'dart:ui' as ui;
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'chat_screen.dart';
|
||||
import 'create_group_flow.dart';
|
||||
import '../../widgets/adaptive_shell.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/swipe_route.dart';
|
||||
|
||||
import '../calls/calls_tab.dart';
|
||||
import '../contacts/contacts_tab.dart';
|
||||
import '../profile/settings_tab.dart';
|
||||
import '../auth/login_screen.dart';
|
||||
import '../../widgets/account_switcher_overlay.dart';
|
||||
import '../../../backend/api.dart';
|
||||
import '../../../core/utils/haptics.dart';
|
||||
import '../../../core/config/app_stories.dart';
|
||||
import '../../../backend/models/chat_folder.dart';
|
||||
import '../../../backend/modules/account.dart';
|
||||
import '../../../backend/modules/chats.dart';
|
||||
import '../../../backend/modules/cloud_storage.dart';
|
||||
import '../../../backend/modules/folders.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../main.dart' show accountModule, api, messagesModule;
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import '../../../main.dart'
|
||||
show accountModule, api, messagesModule, appRouteObserver;
|
||||
|
||||
class _StoriesScrollPhysics extends BouncingScrollPhysics {
|
||||
final bool Function() blockPositive;
|
||||
@@ -56,7 +64,9 @@ class _StoriesScrollPhysics extends BouncingScrollPhysics {
|
||||
}
|
||||
|
||||
class ChatListScreen extends StatefulWidget {
|
||||
const ChatListScreen({super.key});
|
||||
final ValueChanged<DesktopChatSelection>? onChatSelected;
|
||||
|
||||
const ChatListScreen({super.key, this.onChatSelected});
|
||||
|
||||
@override
|
||||
State<ChatListScreen> createState() => _ChatListScreenState();
|
||||
@@ -65,7 +75,7 @@ class ChatListScreen extends StatefulWidget {
|
||||
enum _DeleteKind { personalLike, ownerGroup, blocked }
|
||||
|
||||
class _ChatListScreenState extends State<ChatListScreen>
|
||||
with TickerProviderStateMixin {
|
||||
with TickerProviderStateMixin, RouteAware {
|
||||
String? _selectedFolderId;
|
||||
|
||||
List<ChatFolder> _folders = [];
|
||||
@@ -74,7 +84,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
|
||||
double _navPageAnimStart = 0;
|
||||
double _navPageAnimEnd = 0;
|
||||
double _navDragDx = 0;
|
||||
final ValueNotifier<double> _navDragDx = ValueNotifier(0);
|
||||
double _navDragBaseLeft = 0;
|
||||
double _revealAnimBegin = 0.0;
|
||||
double _closeAnimBegin = 0.0;
|
||||
@@ -93,9 +103,11 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
|
||||
bool _navDragging = false;
|
||||
bool _isFabOpen = false;
|
||||
bool _showCacheWarning = false;
|
||||
bool _storiesAnimClosing = false;
|
||||
Timer? _contactRebuildTimer;
|
||||
bool _deferReloads = false;
|
||||
bool _reloadQueued = false;
|
||||
Timer? _settleTimer;
|
||||
bool get _isSelectionMode => _selectedChats.isNotEmpty;
|
||||
bool? _foldersListKnown;
|
||||
|
||||
@@ -133,11 +145,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
_selectedFolderId,
|
||||
_isInitialLoading,
|
||||
_foldersListKnown,
|
||||
_showCacheWarning,
|
||||
_isSelectionMode,
|
||||
_shouldCollapseSearch,
|
||||
_selectedChats.length,
|
||||
_pullRatio,
|
||||
_storiesDockedOpen,
|
||||
_storiesAnimClosing,
|
||||
_storiesOverscrollRevealArmed,
|
||||
@@ -173,8 +183,12 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
|
||||
List<CachedChat> _selectedChatObjects() {
|
||||
if (_selectedChats.isEmpty) return const [];
|
||||
final ids = _selectedChats;
|
||||
return _chats.where((c) => ids.contains(c.id.toString())).toList();
|
||||
final ids = <int>{};
|
||||
for (final s in _selectedChats) {
|
||||
final v = int.tryParse(s);
|
||||
if (v != null) ids.add(v);
|
||||
}
|
||||
return _chats.where((c) => ids.contains(c.id)).toList();
|
||||
}
|
||||
|
||||
_DeleteKind _categorizeChat(CachedChat c, int myId) {
|
||||
@@ -397,6 +411,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
}
|
||||
|
||||
bool _allowStoriesPullOverscrollTop() {
|
||||
if (!AppStories.current.value) return false;
|
||||
if (_storiesDockedOpen ||
|
||||
_storiesRevealController.isAnimating ||
|
||||
_pullRatio > 0) {
|
||||
@@ -440,30 +455,73 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_sessionState = state;
|
||||
if (state == SessionState.disconnected && _chats.isNotEmpty) {
|
||||
_showCacheWarning = true;
|
||||
}
|
||||
if (state == SessionState.online) {
|
||||
_showCacheWarning = false;
|
||||
}
|
||||
});
|
||||
if (state == SessionState.online) {
|
||||
_reloadChatsAndFolders();
|
||||
_requestReload();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_loginSub = accountModule.loginStatusStream.listen((status) {
|
||||
if (status == LoginStatus.success) {
|
||||
_reloadChatsAndFolders();
|
||||
_requestReload();
|
||||
}
|
||||
});
|
||||
ChatsModule.chatsChanged.addListener(_onChatsChanged);
|
||||
AppStories.current.addListener(_onStoriesEnabledChanged);
|
||||
_reloadChatsAndFolders();
|
||||
}
|
||||
|
||||
void _onStoriesEnabledChanged() {
|
||||
if (!mounted) return;
|
||||
if (!AppStories.current.value) {
|
||||
_storiesRevealController.stop();
|
||||
_pullRatio = 0;
|
||||
_storiesDockedOpen = false;
|
||||
_storiesAnimClosing = false;
|
||||
_storiesOverscrollRevealArmed = false;
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final route = ModalRoute.of(context);
|
||||
if (route is PageRoute) {
|
||||
appRouteObserver.subscribe(this, route);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didPushNext() {
|
||||
_deferReloads = true;
|
||||
}
|
||||
|
||||
@override
|
||||
void didPopNext() {
|
||||
_settleTimer?.cancel();
|
||||
_settleTimer = Timer(const Duration(milliseconds: 420), () {
|
||||
if (!mounted) return;
|
||||
_deferReloads = false;
|
||||
if (_reloadQueued) {
|
||||
_reloadQueued = false;
|
||||
_reloadChatsAndFolders();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _requestReload() {
|
||||
if (!mounted) return;
|
||||
if (_deferReloads) {
|
||||
_reloadQueued = true;
|
||||
return;
|
||||
}
|
||||
_reloadChatsAndFolders();
|
||||
}
|
||||
|
||||
void _onChatsChanged() {
|
||||
if (mounted) _reloadChatsAndFolders();
|
||||
_requestReload();
|
||||
}
|
||||
|
||||
Future<void> _reloadChatsAndFolders() async {
|
||||
@@ -503,7 +561,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_profile = p;
|
||||
_chats = chats;
|
||||
_chats = chats.where((c) => !CloudStorageModule.isCloudStorageGroup(c)).toList();
|
||||
_folders = folders;
|
||||
_foldersListKnown = foldersKnown;
|
||||
if (_selectedFolderId != null &&
|
||||
@@ -612,7 +670,19 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
});
|
||||
}
|
||||
|
||||
int? _pageChatsBaseKey;
|
||||
final Map<int, List<CachedChat>> _pageChatsCache = {};
|
||||
|
||||
List<CachedChat> _chatsForPageIndex(int pageIndex) {
|
||||
final baseKey =
|
||||
Object.hash(identityHashCode(_chats), identityHashCode(_folders));
|
||||
if (_pageChatsBaseKey != baseKey) {
|
||||
_pageChatsBaseKey = baseKey;
|
||||
_pageChatsCache.clear();
|
||||
}
|
||||
final cached = _pageChatsCache[pageIndex];
|
||||
if (cached != null) return cached;
|
||||
|
||||
List<CachedChat> base;
|
||||
if (_folders.isEmpty) {
|
||||
base = _chats;
|
||||
@@ -627,7 +697,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
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];
|
||||
final result = [...pinned, ...regular];
|
||||
_pageChatsCache[pageIndex] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
void _syncFolderChatScrollControllers() {
|
||||
@@ -921,7 +993,10 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
appRouteObserver.unsubscribe(this);
|
||||
_settleTimer?.cancel();
|
||||
ChatsModule.chatsChanged.removeListener(_onChatsChanged);
|
||||
AppStories.current.removeListener(_onStoriesEnabledChanged);
|
||||
_loginSub?.cancel();
|
||||
_stateSub?.cancel();
|
||||
_fabController.dispose();
|
||||
@@ -940,6 +1015,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
}
|
||||
_contactRebuildTimer?.cancel();
|
||||
_storiesUi.dispose();
|
||||
_navDragDx.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -948,7 +1024,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
required double Function(int index) bubbleLeftForIndex,
|
||||
}) {
|
||||
if (_navDragging) {
|
||||
final left = (_navDragBaseLeft + _navDragDx).clamp(
|
||||
final left = (_navDragBaseLeft + _navDragDx.value).clamp(
|
||||
bubbleLeftForIndex(0),
|
||||
bubbleLeftForIndex(3),
|
||||
);
|
||||
@@ -1021,7 +1097,8 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (_pullRatio < 0.8)
|
||||
if (AppStories.current.value &&
|
||||
_pullRatio < 0.8)
|
||||
Opacity(
|
||||
opacity: 1.0 - _pullRatio,
|
||||
child: Container(
|
||||
@@ -1100,68 +1177,33 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 96 * _pullRatio,
|
||||
child: Opacity(
|
||||
opacity: _pullRatio,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
),
|
||||
children: [
|
||||
_buildStoryItem(
|
||||
'Даша',
|
||||
'https://i.pravatar.cc/150?u=dasha',
|
||||
true,
|
||||
if (AppStories.current.value)
|
||||
SizedBox(
|
||||
height: 96 * _pullRatio,
|
||||
child: Opacity(
|
||||
opacity: _pullRatio,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
),
|
||||
_buildStoryItem(
|
||||
'Мастика',
|
||||
'https://i.pravatar.cc/150?u=mastika',
|
||||
false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_showCacheWarning)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.errorContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: cs.error.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.cloud_off,
|
||||
size: 18,
|
||||
color: cs.error,
|
||||
_buildStoryItem(
|
||||
'Даша',
|
||||
'https://i.pravatar.cc/150?u=dasha',
|
||||
true,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Ошибка соединения, сейчас вы смотрите КЕШ',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
_buildStoryItem(
|
||||
'Мастика',
|
||||
'https://i.pravatar.cc/150?u=mastika',
|
||||
false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 3, 20, 4),
|
||||
padding: const EdgeInsets.fromLTRB(20, 3, 20, 8),
|
||||
child: Container(
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
@@ -1314,7 +1356,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
parent: const AlwaysScrollableScrollPhysics(),
|
||||
),
|
||||
slivers: [
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 14)),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 8)),
|
||||
if (chats.isEmpty && !_isInitialLoading)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
@@ -1336,6 +1378,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
|
||||
if (hasSeparator && index == pinnedCount) {
|
||||
return Padding(
|
||||
key: const ValueKey('pinned_divider'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Divider(
|
||||
height: 1,
|
||||
@@ -1350,20 +1393,28 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
final isPinned = (chat.favIndex ?? 0) > 0;
|
||||
|
||||
if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) {
|
||||
final secondId = chat.participants.entries
|
||||
.where((entry) => entry.key != _profile?.id)
|
||||
.first
|
||||
.key;
|
||||
int secondId = _profile?.id ?? 0;
|
||||
for (final entry in chat.participants.entries) {
|
||||
if (entry.key != _profile?.id) {
|
||||
secondId = entry.key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
final isPlaceholder =
|
||||
chat.lastMsgText == ChatsModule.lastMsgPlaceholder;
|
||||
final previewText = isPlaceholder
|
||||
? 'зайдите в чат для подгрузки'
|
||||
: (chat.lastMsgTextOneLine ?? '');
|
||||
return _buildChatItem(
|
||||
chat.id.toString(),
|
||||
name ?? "Пользователь",
|
||||
chat.lastMsgTextOneLine ?? '',
|
||||
previewText,
|
||||
_formatTime(chat.lastMsgTime),
|
||||
avatar ?? "",
|
||||
isOnline: chat.isOnline,
|
||||
@@ -1372,20 +1423,25 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
isVerified: isVerified,
|
||||
isPinned: isPinned,
|
||||
chatType: "DIALOG",
|
||||
messageItalic: isPlaceholder,
|
||||
);
|
||||
} else {
|
||||
final name = chat.lastMsgSenderId != null
|
||||
final isPlaceholder =
|
||||
chat.lastMsgText == ChatsModule.lastMsgPlaceholder;
|
||||
final sender = chat.lastMsgSenderId != null
|
||||
? ContactCache.get(chat.lastMsgSenderId!)
|
||||
: null;
|
||||
|
||||
String fullMsg = "";
|
||||
|
||||
if (name?.isNotEmpty == true && chat.id != 0) {
|
||||
fullMsg += "$name: ";
|
||||
}
|
||||
|
||||
if (chat.lastMsgText?.isNotEmpty == true) {
|
||||
fullMsg += chat.lastMsgText ?? "";
|
||||
if (isPlaceholder) {
|
||||
fullMsg = 'зайдите в чат для подгрузки';
|
||||
} else {
|
||||
if (sender?.isNotEmpty == true && chat.id != 0) {
|
||||
fullMsg += "$sender: ";
|
||||
}
|
||||
if (chat.lastMsgText?.isNotEmpty == true) {
|
||||
fullMsg += chat.lastMsgText ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
return _buildChatItem(
|
||||
@@ -1402,6 +1458,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
isVerified: chat.isOfficial,
|
||||
isPinned: isPinned,
|
||||
chatType: chat.type,
|
||||
messageItalic: isPlaceholder,
|
||||
);
|
||||
}
|
||||
}, childCount: totalItems),
|
||||
@@ -1492,12 +1549,6 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
final minBubbleLeft = bubbleLeftForIndex(0);
|
||||
final maxBubbleLeft = bubbleLeftForIndex(3);
|
||||
|
||||
final bubbleLeft = _navDragging
|
||||
? (_navDragBaseLeft + _navDragDx).clamp(minBubbleLeft, maxBubbleLeft)
|
||||
: leftOffset;
|
||||
|
||||
final navRowT = ((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0);
|
||||
|
||||
double navInterpolatedWidth(int tabIndex, double rowT) {
|
||||
final rt = rowT.clamp(0.0, 3.0);
|
||||
final i0 = rt.floor().clamp(0, 3);
|
||||
@@ -1550,39 +1601,46 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
if (_isSelectionMode) return;
|
||||
_navPageAnimController.stop();
|
||||
_navPageAnimController.value = 1.0;
|
||||
_navDragDx.value = 0;
|
||||
setState(() {
|
||||
_navDragging = true;
|
||||
_navDragDx = 0;
|
||||
_navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex);
|
||||
});
|
||||
},
|
||||
onHorizontalDragUpdate: (details) {
|
||||
if (!_navDragging) return;
|
||||
setState(() {
|
||||
_navDragDx += details.delta.dx;
|
||||
});
|
||||
_navDragDx.value += details.delta.dx;
|
||||
},
|
||||
onHorizontalDragEnd: (_) {
|
||||
if (!_navDragging) return;
|
||||
final left = (_navDragBaseLeft + _navDragDx).clamp(
|
||||
final left = (_navDragBaseLeft + _navDragDx.value).clamp(
|
||||
minBubbleLeft,
|
||||
maxBubbleLeft,
|
||||
);
|
||||
final next = indexForBubbleLeft(left);
|
||||
_navDragDx.value = 0;
|
||||
setState(() {
|
||||
_currentNavIndex = next;
|
||||
_navDragging = false;
|
||||
_navDragDx = 0;
|
||||
});
|
||||
},
|
||||
onHorizontalDragCancel: () {
|
||||
if (!_navDragging) return;
|
||||
_navDragDx.value = 0;
|
||||
setState(() {
|
||||
_navDragging = false;
|
||||
_navDragDx = 0;
|
||||
});
|
||||
},
|
||||
child: Stack(
|
||||
child: ValueListenableBuilder<double>(
|
||||
valueListenable: _navDragDx,
|
||||
builder: (context, navDragDx, _) {
|
||||
final bubbleLeft = _navDragging
|
||||
? (_navDragBaseLeft + navDragDx)
|
||||
.clamp(minBubbleLeft, maxBubbleLeft)
|
||||
: leftOffset;
|
||||
final navRowT =
|
||||
((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0);
|
||||
return Stack(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
@@ -1654,6 +1712,8 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1699,7 +1759,8 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
width: pageW * 4,
|
||||
height: pageH,
|
||||
child: AnimatedBuilder(
|
||||
animation: _navPageAnimController,
|
||||
animation: Listenable.merge(
|
||||
[_navPageAnimController, _navDragDx]),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -1805,9 +1866,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
onPressed: _toggleFab,
|
||||
backgroundColor: cs.primaryContainer,
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
shape: const CircleBorder(),
|
||||
child: Transform.rotate(
|
||||
angle: val * (pi / 4),
|
||||
child: Icon(
|
||||
@@ -2013,7 +2072,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: Text(
|
||||
title,
|
||||
@@ -2042,26 +2101,41 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
bool isVerified = false,
|
||||
bool isPinned = false,
|
||||
String chatType = "CHAT",
|
||||
bool messageItalic = false,
|
||||
}) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isSelected = _selectedChats.contains(id);
|
||||
|
||||
return InkWell(
|
||||
key: ValueKey('chat_$id'),
|
||||
onTap: () {
|
||||
if (_isSelectionMode) {
|
||||
_toggleSelection(id);
|
||||
return;
|
||||
}
|
||||
if (imageUrl.isNotEmpty) {
|
||||
unawaited(precacheImage(
|
||||
CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144),
|
||||
context,
|
||||
));
|
||||
}
|
||||
if (widget.onChatSelected != null) {
|
||||
widget.onChatSelected!(DesktopChatSelection(
|
||||
chatId: int.parse(id),
|
||||
name: name,
|
||||
imageUrl: imageUrl,
|
||||
chatType: chatType,
|
||||
));
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChatScreen(
|
||||
chatId: int.parse(id),
|
||||
name: name,
|
||||
imageUrl: imageUrl,
|
||||
chatType: chatType,
|
||||
),
|
||||
),
|
||||
);
|
||||
pushSwipeable(
|
||||
context,
|
||||
(context) => ChatScreen(
|
||||
chatId: int.parse(id),
|
||||
name: name,
|
||||
imageUrl: imageUrl,
|
||||
chatType: chatType,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onLongPress: () => _toggleSelection(id),
|
||||
@@ -2210,6 +2284,9 @@ Navigator.push(
|
||||
fontWeight: isTyping
|
||||
? FontWeight.w500
|
||||
: FontWeight.w400,
|
||||
fontStyle: messageItalic
|
||||
? FontStyle.italic
|
||||
: FontStyle.normal,
|
||||
height: 1.2,
|
||||
),
|
||||
maxLines: 1,
|
||||
@@ -2275,8 +2352,12 @@ Navigator.push(
|
||||
final Duration opacityDur = instant
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 200);
|
||||
final bool isSettings = index == 3;
|
||||
return GestureDetector(
|
||||
onTap: () => _onNavTabSelected(index),
|
||||
onLongPressStart: isSettings
|
||||
? (details) => _openAccountSwitcher(details.globalPosition)
|
||||
: null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Center(
|
||||
child: FittedBox(
|
||||
@@ -2289,6 +2370,7 @@ Navigator.push(
|
||||
icon,
|
||||
color: isSelected ? cs.onPrimary : cs.onSurface,
|
||||
size: 20,
|
||||
fill: 1,
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: animDur,
|
||||
@@ -2320,6 +2402,46 @@ Navigator.push(
|
||||
);
|
||||
}
|
||||
|
||||
void _openAccountSwitcher(Offset point) {
|
||||
Haptics.medium();
|
||||
final controller = AccountSwitcherController()..attach(point);
|
||||
showAccountSwitcher(
|
||||
context: context,
|
||||
tapPoint: point,
|
||||
controller: controller,
|
||||
onSelected: (accountId) async {
|
||||
controller.dispose();
|
||||
if (!mounted) return;
|
||||
if (accountId == null) {
|
||||
final previousId = await TokenStorage.getActiveAccountId();
|
||||
try {
|
||||
await accountModule.beginAddAccount();
|
||||
} catch (_) {}
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => LoginScreen(returnToAccountId: previousId),
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await accountModule.switchAccount(accountId);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showCustomNotification(context, 'Не удалось переключить аккаунт');
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const AdaptiveShell()),
|
||||
(route) => false,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFabMenu() {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -8,8 +8,10 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../backend/modules/chats.dart';
|
||||
import '../../../backend/modules/contacts.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import '../../../core/utils/image_utils.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/swipe_route.dart';
|
||||
import 'chat_screen.dart';
|
||||
|
||||
const int _maxAvatarBytes = 8 * 1024 * 1024;
|
||||
@@ -130,30 +132,33 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
||||
if (_avatar != null) {
|
||||
final url = await ChatsModule.requestChatPhotoUploadUrl(api);
|
||||
if (url != null) {
|
||||
final bytes = await _avatar!.readAsBytes();
|
||||
final token = await fileUploader.uploadImage(
|
||||
Uri.parse(url),
|
||||
bytes,
|
||||
filename: _avatar!.uri.pathSegments.last,
|
||||
);
|
||||
if (token != null) {
|
||||
await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token);
|
||||
} else if (mounted) {
|
||||
showCustomNotification(context, 'Не удалось загрузить аватарку');
|
||||
final bytes = await compressAvatar(await _avatar!.readAsBytes());
|
||||
if (bytes == null) {
|
||||
if (mounted) showCustomNotification(context, 'Не удалось обработать аватарку');
|
||||
} else {
|
||||
final token = await fileUploader.uploadImage(
|
||||
Uri.parse(url),
|
||||
bytes,
|
||||
filename: 'avatar.jpg',
|
||||
);
|
||||
if (token != null) {
|
||||
await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token);
|
||||
} else if (mounted) {
|
||||
showCustomNotification(context, 'Не удалось загрузить аватарку');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
navigator.pop();
|
||||
navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ChatScreen(
|
||||
chatId: chat.id,
|
||||
name: chat.title ?? title,
|
||||
imageUrl: chat.iconUrl ?? '',
|
||||
chatType: chat.type,
|
||||
),
|
||||
pushSwipeable(
|
||||
context,
|
||||
(_) => ChatScreen(
|
||||
chatId: chat.id,
|
||||
name: chat.title ?? title,
|
||||
imageUrl: chat.iconUrl ?? '',
|
||||
chatType: chat.type,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -2,11 +2,11 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../core/protocol/opcode_map.dart';
|
||||
import '../../../core/cache/info_cache.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/swipe_route.dart';
|
||||
import '../chats/chat_screen.dart';
|
||||
|
||||
class ContactProfileScreen extends StatefulWidget {
|
||||
@@ -40,25 +40,18 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
api.sendRequest(Opcode.contactInfo, {'contactIds': [widget.contactId]}),
|
||||
api.sendRequest(Opcode.contactPresence, {'contactIds': [widget.contactId]}),
|
||||
ContactInfoFetch.get(widget.contactId),
|
||||
PresenceFetch.get(widget.contactId),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
final infoPacket = results[0];
|
||||
if (infoPacket.isOk) {
|
||||
final contacts = (infoPacket.payload as Map?)?['contacts'] as List?;
|
||||
if (contacts != null && contacts.isNotEmpty) {
|
||||
_contact = Map<String, dynamic>.from(contacts.first as Map);
|
||||
}
|
||||
final contact = results[0];
|
||||
if (contact != null) {
|
||||
_contact = contact;
|
||||
}
|
||||
final presencePacket = results[1];
|
||||
if (presencePacket.isOk) {
|
||||
final presence = (presencePacket.payload as Map?)?['presence'] as Map?;
|
||||
final p = presence?[widget.contactId.toString()] ?? presence?[widget.contactId];
|
||||
if (p is Map) {
|
||||
_seenTime = p['seen'] as int?;
|
||||
_presenceStatus = (p['status'] as int?) ?? 0;
|
||||
}
|
||||
final presence = results[1];
|
||||
if (presence != null) {
|
||||
_seenTime = presence['seen'] as int?;
|
||||
_presenceStatus = (presence['status'] as int?) ?? 0;
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) showCustomNotification(context, 'Ошибка: $e');
|
||||
@@ -170,15 +163,13 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
|
||||
);
|
||||
final chatId = existing ?? (accountId ^ widget.contactId);
|
||||
if (!mounted) return;
|
||||
Navigator.push(
|
||||
pushSwipeable(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ChatScreen(
|
||||
chatId: chatId,
|
||||
name: _displayName(),
|
||||
imageUrl: _avatarUrl() ?? '',
|
||||
chatType: 'DIALOG',
|
||||
),
|
||||
(_) => ChatScreen(
|
||||
chatId: chatId,
|
||||
name: _displayName(),
|
||||
imageUrl: _avatarUrl() ?? '',
|
||||
chatType: 'DIALOG',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:m3e_collection/m3e_collection.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../core/config/app_icon.dart';
|
||||
import '../../../core/utils/haptics.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
|
||||
class AppIconScreen extends StatefulWidget {
|
||||
const AppIconScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AppIconScreen> createState() => _AppIconScreenState();
|
||||
}
|
||||
|
||||
class _AppIconScreenState extends State<AppIconScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
AppIconConfig.load();
|
||||
}
|
||||
|
||||
Future<void> _select(AppIcon icon) async {
|
||||
if (!AppIconConfig.isSupported) {
|
||||
showCustomNotification(
|
||||
context,
|
||||
'Смена иконки доступна только на Android и iOS',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (AppIconConfig.current.value == icon) return;
|
||||
Haptics.selection();
|
||||
try {
|
||||
await AppIconConfig.apply(icon);
|
||||
if (!mounted) return;
|
||||
showCustomNotification(context, 'Иконка изменена на «${icon.title}»');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showCustomNotification(context, 'Не удалось сменить иконку: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@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: [
|
||||
Material(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Внешний вид иконки',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
AppIconConfig.isSupported
|
||||
? 'На Android приложение закроется — лаунчер подхватит новую иконку. На iOS — мгновенно с системным диалогом.'
|
||||
: 'Доступно только на Android и iOS',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ValueListenableBuilder<AppIcon>(
|
||||
valueListenable: AppIconConfig.current,
|
||||
builder: (context, current, _) {
|
||||
return Column(
|
||||
children: [
|
||||
for (final icon in AppIcon.values)
|
||||
_IconTile(
|
||||
icon: icon,
|
||||
selected: current == icon,
|
||||
onTap: () => _select(icon),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IconTile extends StatelessWidget {
|
||||
final AppIcon icon;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _IconTile({
|
||||
required this.icon,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Image.asset(
|
||||
icon.previewAsset,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
icon.title,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
selected
|
||||
? Symbols.radio_button_checked
|
||||
: Symbols.radio_button_unchecked,
|
||||
color: selected ? cs.primary : cs.outline,
|
||||
size: 22,
|
||||
fill: selected ? 1 : 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,21 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../backend/modules/chats.dart';
|
||||
import '../../../backend/modules/cloud_storage.dart';
|
||||
import '../../../backend/modules/upload_manager.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
|
||||
enum _EnvState { loading, notConfigured, ready }
|
||||
|
||||
class CloudStorageScreen extends StatefulWidget {
|
||||
const CloudStorageScreen({super.key});
|
||||
|
||||
@@ -16,21 +31,247 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
static const _cornerSidePadding = 16.0;
|
||||
static const _cornerBottomPadding = 24.0;
|
||||
static const _cornerSlideAmount = 28.0;
|
||||
static const _cardViewportFraction = 0.42;
|
||||
|
||||
late final _UploadModeController _mode;
|
||||
late final PageController _pageController;
|
||||
final _currentFilePage = ValueNotifier<int>(0);
|
||||
|
||||
_EnvState _envState = _EnvState.loading;
|
||||
bool _isCreatingEnv = false;
|
||||
int? _envGroupId;
|
||||
int? _accountId;
|
||||
List<CloudFile> _files = [];
|
||||
bool _isUploading = false;
|
||||
final ValueNotifier<double> _uploadProgress = ValueNotifier(0);
|
||||
bool _animateNewCard = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mode = _UploadModeController(this);
|
||||
_pageController = PageController(viewportFraction: _cardViewportFraction);
|
||||
_pageController.addListener(_onPageScroll);
|
||||
_checkEnv();
|
||||
_bindUploadManager();
|
||||
}
|
||||
|
||||
void _onPageScroll() {
|
||||
_currentFilePage.value = _pageController.page?.round() ?? 0;
|
||||
}
|
||||
|
||||
void _bindUploadManager() {
|
||||
final mgr = UploadManager.instance;
|
||||
if (mgr.isActive) {
|
||||
setState(() => _isUploading = true);
|
||||
_mode.open();
|
||||
}
|
||||
mgr.onProgress = (progress, _) {
|
||||
if (!mounted) return;
|
||||
if (!_isUploading) setState(() => _isUploading = true);
|
||||
_uploadProgress.value = progress;
|
||||
};
|
||||
mgr.onDone = (file) {
|
||||
if (!mounted) return;
|
||||
_uploadProgress.value = 0;
|
||||
setState(() => _isUploading = false);
|
||||
_prependFile(file);
|
||||
};
|
||||
mgr.onError = (msg) {
|
||||
if (!mounted) return;
|
||||
_uploadProgress.value = 0;
|
||||
setState(() => _isUploading = false);
|
||||
showCustomNotification(context, 'Ошибка: $msg');
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final mgr = UploadManager.instance;
|
||||
mgr.onProgress = null;
|
||||
mgr.onDone = null;
|
||||
mgr.onError = null;
|
||||
_mode.dispose();
|
||||
_pageController.dispose();
|
||||
_currentFilePage.dispose();
|
||||
_uploadProgress.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _checkEnv() async {
|
||||
final profile = await AppDatabase.loadActiveProfile();
|
||||
if (profile == null) {
|
||||
if (mounted) setState(() => _envState = _EnvState.notConfigured);
|
||||
return;
|
||||
}
|
||||
|
||||
final cachedId = await CloudStorageModule.getCachedEnvGroupId(profile.id);
|
||||
if (cachedId != null) {
|
||||
final rows = await ChatsModule.getChat(profile.id, cachedId);
|
||||
if (rows.isNotEmpty && CloudStorageModule.isCloudStorageGroup(rows.first)) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_envState = _EnvState.ready;
|
||||
_envGroupId = cachedId;
|
||||
_accountId = profile.id;
|
||||
});
|
||||
_loadFiles(profile.id, cachedId);
|
||||
_handleOrphansBackground(profile.id);
|
||||
return;
|
||||
}
|
||||
await CloudStorageModule.clearEnvGroupCache(profile.id);
|
||||
}
|
||||
|
||||
final chats = await ChatsModule.getChats(profile.id);
|
||||
CachedChat? envGroup = CloudStorageModule.findEnvGroup(chats);
|
||||
final orphans = CloudStorageModule.findOrphanGroups(chats);
|
||||
|
||||
if (envGroup == null && orphans.isNotEmpty) {
|
||||
final repaired = await CloudStorageModule.repairOrphan(api, orphans.first);
|
||||
if (repaired != null) {
|
||||
envGroup = repaired;
|
||||
await CloudStorageModule.cacheEnvGroupId(profile.id, repaired.id);
|
||||
}
|
||||
for (final orphan in orphans.skip(1)) {
|
||||
_deleteOrLeave(profile.id, orphan);
|
||||
}
|
||||
} else if (envGroup != null) {
|
||||
await CloudStorageModule.cacheEnvGroupId(profile.id, envGroup.id);
|
||||
for (final orphan in orphans) {
|
||||
_deleteOrLeave(profile.id, orphan);
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_envState = envGroup != null ? _EnvState.ready : _EnvState.notConfigured;
|
||||
_envGroupId = envGroup?.id;
|
||||
_accountId = profile.id;
|
||||
});
|
||||
if (envGroup != null) _loadFiles(profile.id, envGroup.id);
|
||||
}
|
||||
|
||||
void _handleOrphansBackground(int accountId) async {
|
||||
final chats = await ChatsModule.getChats(accountId);
|
||||
for (final orphan in CloudStorageModule.findOrphanGroups(chats)) {
|
||||
_deleteOrLeave(accountId, orphan);
|
||||
}
|
||||
}
|
||||
|
||||
void _deleteOrLeave(int accountId, CachedChat chat) async {
|
||||
final isAdmin = chat.owner == accountId || chat.admins.contains(accountId);
|
||||
if (isAdmin) {
|
||||
await ChatsModule.deleteChat(api, chatId: chat.id, lastEventTime: chat.lastEventTime, forAll: true);
|
||||
} else {
|
||||
await ChatsModule.leaveChat(api, chatId: chat.id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadFiles(int accountId, int chatId) async {
|
||||
final files = await CloudStorageModule.fetchFiles(messagesModule, accountId, chatId);
|
||||
if (!mounted) return;
|
||||
setState(() => _files = files.reversed.toList());
|
||||
}
|
||||
|
||||
void _prependFile(CloudFile file) {
|
||||
setState(() {
|
||||
_files = [file, ..._files];
|
||||
_animateNewCard = true;
|
||||
});
|
||||
if (_pageController.hasClients) {
|
||||
_pageController.animateToPage(0,
|
||||
duration: const Duration(milliseconds: 350), curve: Curves.easeOut);
|
||||
}
|
||||
Future.delayed(const Duration(milliseconds: 800), () {
|
||||
if (mounted) setState(() => _animateNewCard = false);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _setupEnv() async {
|
||||
final profile = await AppDatabase.loadActiveProfile();
|
||||
if (!mounted) return;
|
||||
if (profile == null) {
|
||||
showCustomNotification(context, 'Нет активного профиля');
|
||||
return;
|
||||
}
|
||||
setState(() => _isCreatingEnv = true);
|
||||
final result = await CloudStorageModule.setupEnv(api);
|
||||
if (!mounted) return;
|
||||
if (result == null) {
|
||||
setState(() => _isCreatingEnv = false);
|
||||
showCustomNotification(context, 'Не удалось создать среду');
|
||||
return;
|
||||
}
|
||||
await CloudStorageModule.cacheEnvGroupId(profile.id, result.id);
|
||||
setState(() {
|
||||
_isCreatingEnv = false;
|
||||
_envState = _EnvState.ready;
|
||||
_envGroupId = result.id;
|
||||
_accountId = profile.id;
|
||||
});
|
||||
_loadFiles(profile.id, result.id);
|
||||
}
|
||||
|
||||
Future<void> _pickAndUploadFile() async {
|
||||
final chatId = _envGroupId;
|
||||
final accountId = _accountId;
|
||||
if (chatId == null || accountId == null) return;
|
||||
|
||||
final result = await FilePicker.platform.pickFiles();
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
final picked = result.files.first;
|
||||
if (picked.path == null) return;
|
||||
|
||||
_uploadProgress.value = 0;
|
||||
setState(() => _isUploading = true);
|
||||
|
||||
await UploadManager.instance.start(
|
||||
chatId: chatId,
|
||||
accountId: accountId,
|
||||
file: File(picked.path!),
|
||||
filename: picked.name,
|
||||
totalSize: picked.size,
|
||||
);
|
||||
}
|
||||
|
||||
void _showSendByIdSheet() {
|
||||
final chatId = _envGroupId;
|
||||
final accountId = _accountId;
|
||||
if (chatId == null || accountId == null) return;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _SendByIdSheet(
|
||||
onSend: (id) async {
|
||||
final ok = await messagesModule.sendFileMessage(chatId, id);
|
||||
if (!ok) return false;
|
||||
final newest = await CloudStorageModule.fetchLatestFile(
|
||||
messagesModule, accountId, chatId, expectedFileId: id,
|
||||
);
|
||||
if (mounted) {
|
||||
if (newest != null) {
|
||||
_prependFile(newest);
|
||||
} else {
|
||||
_loadFiles(accountId, chatId);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onCardTap(CloudFile file) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _FileDetailsSheet(file: file),
|
||||
);
|
||||
}
|
||||
|
||||
void _onBack() {
|
||||
if (_mode.isOpen) {
|
||||
_mode.close();
|
||||
@@ -57,34 +298,76 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final h = constraints.maxHeight;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onVerticalDragUpdate: (d) => _mode.handleDragUpdate(d, h),
|
||||
onVerticalDragEnd: _mode.handleDragEnd,
|
||||
child: AnimatedBuilder(
|
||||
animation: _mode.anim,
|
||||
builder: (context, _) {
|
||||
final t = Curves.easeOutCubic.transform(_mode.anim.value);
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_buildHint(cs, t),
|
||||
_buildUploadingCenterHint(cs, t),
|
||||
_buildEmptyState(cs, t, h),
|
||||
..._buildCornerActions(cs, t),
|
||||
],
|
||||
);
|
||||
},
|
||||
body: switch (_envState) {
|
||||
_EnvState.loading => const Center(child: CircularProgressIndicator()),
|
||||
_EnvState.notConfigured => _buildNotConfigured(cs),
|
||||
_EnvState.ready => _buildReady(cs),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotConfigured(ColorScheme cs) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: _horizontalPadding),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Среда для облачного хранилища не настроена',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 17, fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
},
|
||||
const SizedBox(height: 6),
|
||||
Text('Начнем? Это быстро.', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14)),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _isCreatingEnv ? null : _setupEnv,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
child: _isCreatingEnv
|
||||
? SizedBox(
|
||||
width: 18, height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: cs.onPrimary),
|
||||
)
|
||||
: const Text('Начать', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReady(ColorScheme cs) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final h = constraints.maxHeight;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onVerticalDragUpdate: (d) => _mode.handleDragUpdate(d, h),
|
||||
onVerticalDragEnd: _mode.handleDragEnd,
|
||||
child: AnimatedBuilder(
|
||||
animation: _mode.anim,
|
||||
builder: (context, _) {
|
||||
final t = Curves.easeOutCubic.transform(_mode.anim.value);
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_buildHint(cs, t),
|
||||
_buildUploadingCenterHint(cs, t, constraints.maxWidth),
|
||||
_buildEmptyState(cs, t, h),
|
||||
..._buildCornerActions(cs, t),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHint(ColorScheme cs, double t) {
|
||||
return Positioned(
|
||||
top: 0,
|
||||
@@ -99,16 +382,86 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUploadingCenterHint(ColorScheme cs, double t) {
|
||||
Widget _buildUploadingCenterHint(ColorScheme cs, double t, double availableWidth) {
|
||||
final cardSide = availableWidth * _cardViewportFraction;
|
||||
return Center(
|
||||
child: Opacity(
|
||||
opacity: t,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: _horizontalPadding),
|
||||
child: Text(
|
||||
'Начните загрузку для прогресс-бара',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_files.isNotEmpty) ...[
|
||||
SizedBox(
|
||||
height: cardSide,
|
||||
width: availableWidth,
|
||||
child: ScrollConfiguration(
|
||||
behavior: _MouseDragScrollBehavior(),
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: _files.length,
|
||||
itemBuilder: (_, i) {
|
||||
final card = _CloudFileCard(
|
||||
file: _files[i],
|
||||
onTap: () => _onCardTap(_files[i]),
|
||||
);
|
||||
final padded = Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: card,
|
||||
);
|
||||
if (i == 0 && _animateNewCard) {
|
||||
return _FadeScaleEntry(
|
||||
key: ValueKey('${_files[0].messageId}_${_files[0].time}'),
|
||||
child: padded,
|
||||
);
|
||||
}
|
||||
return padded;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ValueListenableBuilder<int>(
|
||||
valueListenable: _currentFilePage,
|
||||
builder: (context, page, child) => Text(
|
||||
'${page + 1} / ${_files.length}',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (_isUploading) ...[
|
||||
ValueListenableBuilder<double>(
|
||||
valueListenable: _uploadProgress,
|
||||
builder: (context, progress, _) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
LinearProgressIndicator(
|
||||
value: progress,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
minHeight: 5,
|
||||
color: cs.primary,
|
||||
backgroundColor: cs.surfaceContainerHighest,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Загрузка ${(progress * 100).toStringAsFixed(0)}%',
|
||||
style:
|
||||
TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else if (_files.isEmpty) ...[
|
||||
Text(
|
||||
'Начните загрузку для прогресс-бара',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -139,7 +492,7 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _mode.open,
|
||||
onPressed: _mode.isOpen ? null : _mode.open,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: cs.primary,
|
||||
foregroundColor: cs.onPrimary,
|
||||
@@ -173,7 +526,7 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
child: _CornerAction(
|
||||
icon: Symbols.upload_file,
|
||||
label: 'С файла',
|
||||
onTap: () {},
|
||||
onTap: _pickAndUploadFile,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -185,7 +538,7 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
child: _CornerAction(
|
||||
icon: Symbols.tag,
|
||||
label: 'По ID',
|
||||
onTap: () {},
|
||||
onTap: _showSendByIdSheet,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -359,3 +712,400 @@ class _DragDownHintState extends State<_DragDownHint>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MouseDragScrollBehavior extends MaterialScrollBehavior {
|
||||
@override
|
||||
Set<PointerDeviceKind> get dragDevices => {
|
||||
PointerDeviceKind.touch,
|
||||
PointerDeviceKind.mouse,
|
||||
};
|
||||
}
|
||||
|
||||
class _FadeScaleEntry extends StatefulWidget {
|
||||
final Widget child;
|
||||
const _FadeScaleEntry({super.key, required this.child});
|
||||
|
||||
@override
|
||||
State<_FadeScaleEntry> createState() => _FadeScaleEntryState();
|
||||
}
|
||||
|
||||
class _FadeScaleEntryState extends State<_FadeScaleEntry>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _c;
|
||||
late final Animation<double> _scale;
|
||||
late final Animation<double> _opacity;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_c = AnimationController(vsync: this, duration: const Duration(milliseconds: 550));
|
||||
_scale = CurvedAnimation(parent: _c, curve: Curves.elasticOut);
|
||||
_opacity = CurvedAnimation(parent: _c, curve: const Interval(0, 0.4, curve: Curves.easeIn));
|
||||
_c.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_c.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _c,
|
||||
builder: (context, child) => Opacity(
|
||||
opacity: _opacity.value.clamp(0.0, 1.0),
|
||||
child: Transform.scale(scale: _scale.value, child: child),
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CloudFileCard extends StatelessWidget {
|
||||
final CloudFile file;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _CloudFileCard({required this.file, required this.onTap});
|
||||
|
||||
static IconData _icon(String name) {
|
||||
final ext = name.contains('.') ? name.split('.').last.toLowerCase() : '';
|
||||
return switch (ext) {
|
||||
'pdf' => Symbols.picture_as_pdf,
|
||||
'jpg' || 'jpeg' || 'png' || 'gif' || 'webp' || 'bmp' => Symbols.image,
|
||||
'mp4' || 'mov' || 'avi' || 'mkv' => Symbols.video_file,
|
||||
'mp3' || 'wav' || 'ogg' || 'flac' => Symbols.audio_file,
|
||||
'zip' || 'rar' || '7z' || 'tar' || 'gz' => Symbols.folder_zip,
|
||||
'doc' || 'docx' => Symbols.description,
|
||||
'xls' || 'xlsx' => Symbols.table_chart,
|
||||
'ppt' || 'pptx' => Symbols.slideshow,
|
||||
'txt' => Symbols.text_snippet,
|
||||
_ => Symbols.insert_drive_file,
|
||||
};
|
||||
}
|
||||
|
||||
static String _formatTime(int millis) {
|
||||
final d = DateTime.fromMillisecondsSinceEpoch(millis);
|
||||
final now = DateTime.now();
|
||||
if (d.year == now.year && d.month == now.month && d.day == now.day) {
|
||||
return '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5), width: 0.5),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Icon(_icon(file.name), color: cs.primary, size: 34),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 0, 10, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
file.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatTime(file.time),
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FileDetailsSheet extends StatefulWidget {
|
||||
final CloudFile file;
|
||||
const _FileDetailsSheet({required this.file});
|
||||
|
||||
@override
|
||||
State<_FileDetailsSheet> createState() => _FileDetailsSheetState();
|
||||
}
|
||||
|
||||
class _FileDetailsSheetState extends State<_FileDetailsSheet> {
|
||||
({String url, int expires})? _link;
|
||||
bool _loading = false;
|
||||
Timer? _expiryTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final f = widget.file;
|
||||
if (f.fileId != null) {
|
||||
_link = CloudStorageModule.getCachedLink(f.accountId, f.fileId!);
|
||||
}
|
||||
_expiryTimer = Timer.periodic(const Duration(minutes: 1), (_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_expiryTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _generateLink() async {
|
||||
final f = widget.file;
|
||||
if (f.fileId == null) return;
|
||||
setState(() => _loading = true);
|
||||
final result = await CloudStorageModule.fetchFileUrl(
|
||||
api,
|
||||
accountId: f.accountId,
|
||||
fileId: f.fileId!,
|
||||
chatId: f.chatId,
|
||||
messageId: f.messageId,
|
||||
);
|
||||
if (mounted) setState(() { _link = result; _loading = false; });
|
||||
}
|
||||
|
||||
static String _formatSize(int? bytes) {
|
||||
if (bytes == null) return '—';
|
||||
if (bytes < 1024) return '$bytes Б';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} КБ';
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} МБ';
|
||||
}
|
||||
|
||||
static String _formatExpiry(int expiresMs) {
|
||||
final remaining = DateTime.fromMillisecondsSinceEpoch(expiresMs).difference(DateTime.now());
|
||||
if (remaining.isNegative) return 'истекла';
|
||||
final h = remaining.inHours;
|
||||
final m = remaining.inMinutes % 60;
|
||||
if (h >= 24) return 'через ${remaining.inDays} д';
|
||||
if (h > 0) return 'через $h ч $m мин';
|
||||
return 'через $m мин';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final f = widget.file;
|
||||
final isExpired = _link == null ||
|
||||
_link!.expires <= DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
24, 16, 24,
|
||||
MediaQuery.of(context).viewInsets.bottom + 32,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 36, height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(f.name,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 12),
|
||||
_InfoRow(label: 'ID файла', value: f.fileId?.toString() ?? '—'),
|
||||
const SizedBox(height: 6),
|
||||
_InfoRow(label: 'Размер', value: _formatSize(f.size)),
|
||||
const SizedBox(height: 20),
|
||||
Container(height: 0.5, color: cs.outlineVariant),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: isExpired
|
||||
? Text('Ссылки пока нет. Создайте.',
|
||||
style: TextStyle(color: cs.error, fontSize: 13))
|
||||
: Text('Ссылка истечет ${_formatExpiry(_link!.expires)}',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_loading
|
||||
? SizedBox(
|
||||
width: 20, height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: cs.primary),
|
||||
)
|
||||
: IconButton(
|
||||
icon: Icon(
|
||||
isExpired ? Symbols.add_link : Symbols.content_copy,
|
||||
color: isExpired ? cs.error : cs.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: isExpired
|
||||
? _generateLink
|
||||
: () {
|
||||
Clipboard.setData(ClipboardData(text: _link!.url));
|
||||
showCustomNotification(context, 'Ссылка скопирована');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
const _InfoRow({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Row(
|
||||
children: [
|
||||
Text('$label: ', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 13, fontWeight: FontWeight.w500),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SendByIdSheet extends StatefulWidget {
|
||||
final Future<bool> Function(int fileId) onSend;
|
||||
const _SendByIdSheet({required this.onSend});
|
||||
|
||||
@override
|
||||
State<_SendByIdSheet> createState() => _SendByIdSheetState();
|
||||
}
|
||||
|
||||
class _SendByIdSheetState extends State<_SendByIdSheet> {
|
||||
final _controller = TextEditingController();
|
||||
bool _sending = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final id = int.tryParse(_controller.text.trim());
|
||||
if (id == null) {
|
||||
showCustomNotification(context, 'Неверный ID');
|
||||
return;
|
||||
}
|
||||
setState(() => _sending = true);
|
||||
final ok = await widget.onSend(id);
|
||||
if (!mounted) return;
|
||||
if (ok) {
|
||||
Navigator.pop(context);
|
||||
} else {
|
||||
setState(() => _sending = false);
|
||||
showCustomNotification(context, 'Ошибка отправки');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
24, 16, 24,
|
||||
MediaQuery.of(context).viewInsets.bottom + 32,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 36, height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.outlineVariant, borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text('Отправить по ID',
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.number,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 15),
|
||||
onSubmitted: (_) => _sending ? null : _submit(),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'fileId',
|
||||
hintStyle: TextStyle(color: cs.onSurfaceVariant),
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerHigh,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _sending ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: _sending
|
||||
? SizedBox(
|
||||
width: 18, height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: cs.onPrimary),
|
||||
)
|
||||
: const Text('Отправить', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:m3e_collection/m3e_collection.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../core/utils/haptics.dart';
|
||||
import 'app_icon_screen.dart';
|
||||
import 'appearance_screen.dart';
|
||||
import 'font_settings_screen.dart';
|
||||
import 'message_actions_screen.dart';
|
||||
@@ -50,6 +51,12 @@ class CustomizationScreen extends StatelessWidget {
|
||||
subtitle: 'Радиальное или список — для долгого нажатия на сообщение',
|
||||
builder: _buildMessageActions,
|
||||
),
|
||||
_CustomizationCategory(
|
||||
icon: Symbols.apps,
|
||||
title: 'Иконка приложения',
|
||||
subtitle: 'Default или Minimal — иконка на главном экране',
|
||||
builder: _buildAppIcon,
|
||||
),
|
||||
];
|
||||
|
||||
static Widget _buildAppearance(BuildContext context) =>
|
||||
@@ -64,6 +71,8 @@ class CustomizationScreen extends StatelessWidget {
|
||||
static Widget _buildMessageActions(BuildContext context) =>
|
||||
const MessageActionsScreen();
|
||||
|
||||
static Widget _buildAppIcon(BuildContext context) => const AppIconScreen();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||