Merge pull request #32 from KometTeam/feature/FullStack

ну тут много всего
This commit is contained in:
klockky
2026-06-03 18:08:46 +03:00
committed by GitHub
146 changed files with 9620 additions and 1199 deletions
+293
View File
@@ -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/*
+6 -1
View File
@@ -133,4 +133,9 @@ agents.md
# Environment variables
.env
.env.*
.env.*
# Локальные дампы трафика и скрипты анализа (содержат секреты)
komet.txt
original_app.txt
fingerprint.py
PCAPdroid_*.txt
Executable
+190
View File
@@ -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
+28 -2
View File
@@ -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)} МБ/с"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+10 -2
View File
@@ -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++";
+37
View File
@@ -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"}}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 700 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

+16 -1
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+16 -7
View File
@@ -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);
+204 -39
View File
@@ -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;
}
+354 -6
View File
@@ -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,
+209
View File
@@ -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;
}
}
}
+58 -22
View File
@@ -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;
}
+6 -3
View File
@@ -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;
+93 -27
View File
@@ -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;
}
+61
View File
@@ -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);
}
}
}
+103
View File
@@ -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 (_) {}
}
}
+226
View File
@@ -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);
}
}
+1 -1
View File
@@ -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;
+53
View File
@@ -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;
}
}
+33
View File
@@ -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);
}
}
+20
View File
@@ -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);
}
}
+20
View File
@@ -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;
}
}
+32 -9
View File
@@ -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;
}
/// Распаковка пакета от сервера
+41 -9
View File
@@ -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],
);
}
}
+2 -2
View File
@@ -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();
+5
View File
@@ -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);
+4 -1
View File
@@ -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);
+38 -11
View File
@@ -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;
}
}
+15
View File
@@ -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;
}
}
+50
View File
@@ -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());
}
}
+28
View File
@@ -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;
}
+185
View File
@@ -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) {
+85 -21
View File
@@ -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,
),
),
),
),
),
);
},
),
),
],
);
}
}
+381
View File
@@ -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;
+241 -119
View File
@@ -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,
File diff suppressed because it is too large Load Diff
@@ -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;

Some files were not shown because too many files have changed in this diff Show More