diff --git a/android/NOTICE b/android/NOTICE
new file mode 100755
index 0000000..06a9081
--- /dev/null
+++ b/android/NOTICE
@@ -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
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 8dd5624..a3b1ba5 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -3,6 +3,8 @@
+
+
+
diff --git a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt
index 2aeadb5..dfd5d77 100644
--- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt
+++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt
@@ -1,10 +1,12 @@
package ru.komet.app
import android.content.Context
+import android.content.Intent
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
@@ -36,6 +38,48 @@ class MainActivity : FlutterActivity() {
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("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("filename") ?: "Файл"
+ val progress = call.argument("progress") ?: 0
+ val speed = call.argument("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 =
diff --git a/android/app/src/main/kotlin/ru/komet/app/UploadForegroundService.kt b/android/app/src/main/kotlin/ru/komet/app/UploadForegroundService.kt
new file mode 100644
index 0000000..3bd7698
--- /dev/null
+++ b/android/app/src/main/kotlin/ru/komet/app/UploadForegroundService.kt
@@ -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)} МБ/с"
+ }
+}
diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart
index 5cc7cca..96387e6 100644
--- a/lib/backend/modules/chats.dart
+++ b/lib/backend/modules/chats.dart
@@ -661,6 +661,40 @@ class ChatsModule {
return packet.isOk;
}
+ static Future setChatOptions(
+ Api api, {
+ required int chatId,
+ required Map options,
+ }) async {
+ final packet = await api.sendRequest(Opcode.chatUpdate, {
+ 'chatId': chatId,
+ 'options': options,
+ });
+ return packet.isOk;
+ }
+
+ static Future 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.from(rows.first);
+ updated['title'] = title;
+ await AppDatabase.saveChats([updated]);
+ _bump();
+ }
+ return true;
+ }
+
static Future togglePin(
Api api, {
required List chatIds,
@@ -777,6 +811,20 @@ class ChatsModule {
}
}
+ static Future 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> refreshChats(
Api api,
List chatIds,
diff --git a/lib/backend/modules/cloud_storage.dart b/lib/backend/modules/cloud_storage.dart
new file mode 100644
index 0000000..9b173fe
--- /dev/null
+++ b/lib/backend/modules/cloud_storage.dart
@@ -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 _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 chats) {
+ for (final c in chats) {
+ if (isCloudStorageGroup(c)) return c;
+ }
+ return null;
+ }
+
+ static List findOrphanGroups(List 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 cacheEnvGroupId(int accountId, int groupId) async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setInt('cloud_storage_env_$accountId', groupId);
+ }
+
+ static Future getCachedEnvGroupId(int accountId) async {
+ final prefs = await SharedPreferences.getInstance();
+ return prefs.getInt('cloud_storage_env_$accountId');
+ }
+
+ static Future clearEnvGroupCache(int accountId) async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.remove('cloud_storage_env_$accountId');
+ }
+
+ static Future _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 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 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> fetchFiles(
+ MessagesModule messages,
+ int accountId,
+ int chatId, {
+ int count = 200,
+ }) async {
+ final msgs = await messages.fetchHistory(accountId, chatId, count: count);
+ final files = [];
+ 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 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;
+ }
+ }
+}
diff --git a/lib/backend/modules/upload_manager.dart b/lib/backend/modules/upload_manager.dart
new file mode 100644
index 0000000..1c0c9c4
--- /dev/null
+++ b/lib/backend/modules/upload_manager.dart
@@ -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? _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 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 cancel() async {
+ await _sub?.cancel();
+ _sub = null;
+ await UploadNotificationService.stop();
+ }
+}
diff --git a/lib/backend/modules/upload_notification_service.dart b/lib/backend/modules/upload_notification_service.dart
new file mode 100644
index 0000000..180370f
--- /dev/null
+++ b/lib/backend/modules/upload_notification_service.dart
@@ -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 start(String filename) async {
+ if (!Platform.isAndroid) return;
+ try { await _ch.invokeMethod('start', {'filename': filename}); } catch (_) {}
+ }
+
+ static Future 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 stop() async {
+ if (!Platform.isAndroid) return;
+ try { await _ch.invokeMethod('stop'); } catch (_) {}
+ }
+}
diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart
index 60b4d53..08ce89e 100644
--- a/lib/frontend/screens/chats/chat_list_screen.dart
+++ b/lib/frontend/screens/chats/chat_list_screen.dart
@@ -18,6 +18,7 @@ import '../../../core/utils/haptics.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;
@@ -503,7 +504,7 @@ class _ChatListScreenState extends State
if (mounted) {
setState(() {
_profile = p;
- _chats = chats;
+ _chats = chats.where((c) => !CloudStorageModule.isCloudStorageGroup(c)).toList();
_folders = folders;
_foldersListKnown = foldersKnown;
if (_selectedFolderId != null &&
diff --git a/lib/frontend/screens/profile/cloud_storage_screen.dart b/lib/frontend/screens/profile/cloud_storage_screen.dart
index d49ccd9..b776637 100644
--- a/lib/frontend/screens/profile/cloud_storage_screen.dart
+++ b/lib/frontend/screens/profile/cloud_storage_screen.dart
@@ -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});
@@ -8,35 +23,257 @@ class CloudStorageScreen extends StatefulWidget {
State createState() => _CloudStorageScreenState();
}
-class _CloudStorageScreenState extends State
- with SingleTickerProviderStateMixin {
- static const _translateFactor = 0.7;
+class _CloudStorageScreenState extends State {
static const _horizontalPadding = 32.0;
- static const _hintSidePadding = 35.0;
- static const _cornerSidePadding = 16.0;
- static const _cornerBottomPadding = 24.0;
- static const _cornerSlideAmount = 28.0;
+ static const _cardViewportFraction = 0.38;
- late final _UploadModeController _mode;
+ late final PageController _pageController;
+ final _currentFilePage = ValueNotifier(0);
+
+ _EnvState _envState = _EnvState.loading;
+ bool _isCreatingEnv = false;
+ int? _envGroupId;
+ int? _accountId;
+ List _files = [];
+ bool _filesLoading = false;
+ bool _isUploading = false;
+ double _uploadProgress = 0;
+ bool _animateNewCard = false;
+ bool _showUploadCard = false;
@override
void initState() {
super.initState();
- _mode = _UploadModeController(this);
+ _pageController = PageController(viewportFraction: _cardViewportFraction);
+ _pageController.addListener(_onPageScroll);
+ _checkEnv();
+ _bindUploadManager();
+ }
+
+ void _bindUploadManager() {
+ final mgr = UploadManager.instance;
+ if (mgr.isActive) {
+ // Upload was already running (e.g. user left and came back)
+ setState(() { _isUploading = true; _showUploadCard = true; });
+ }
+ mgr.onProgress = (progress, speedBps) {
+ if (!mounted) return;
+ setState(() { _isUploading = true; _uploadProgress = progress; });
+ };
+ mgr.onDone = (file) {
+ if (!mounted) return;
+ setState(() { _isUploading = false; _uploadProgress = 0; });
+ _prependFile(file);
+ };
+ mgr.onError = (msg) {
+ if (!mounted) return;
+ setState(() { _isUploading = false; _uploadProgress = 0; });
+ showCustomNotification(context, 'Ошибка: $msg');
+ };
+ }
+
+ void _onPageScroll() {
+ _currentFilePage.value = _pageController.page?.round() ?? 0;
}
@override
void dispose() {
- _mode.dispose();
+ // Unregister UI callbacks — upload continues in background via UploadManager
+ final mgr = UploadManager.instance;
+ mgr.onProgress = null;
+ mgr.onDone = null;
+ mgr.onError = null;
+ _pageController.dispose();
+ _currentFilePage.dispose();
super.dispose();
}
- void _onBack() {
- if (_mode.isOpen) {
- _mode.close();
- } else {
- Navigator.pop(context);
+ Future _checkEnv() async {
+ final profile = await AppDatabase.loadActiveProfile();
+ if (profile == null) {
+ if (mounted) setState(() => _envState = _EnvState.notConfigured);
+ return;
}
+
+ // Fast path: validate cached env group ID with a single DB row query
+ 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, cachedId);
+ return;
+ }
+ await CloudStorageModule.clearEnvGroupCache(profile.id);
+ }
+
+ // Full scan
+ final chats = await ChatsModule.getChats(profile.id);
+ CachedChat? envGroup = CloudStorageModule.findEnvGroup(chats);
+ final orphans = CloudStorageModule.findOrphanGroups(chats);
+
+ if (envGroup == null && orphans.isNotEmpty) {
+ // Repair first orphan into a valid env group
+ final repaired = await CloudStorageModule.repairOrphan(api, orphans.first);
+ if (repaired != null) {
+ envGroup = repaired;
+ await CloudStorageModule.cacheEnvGroupId(profile.id, repaired.id);
+ }
+ // If there were multiple orphans, clean up the rest
+ for (final orphan in orphans.skip(1)) {
+ _deleteOrLeave(profile.id, orphan);
+ }
+ } else if (envGroup != null) {
+ await CloudStorageModule.cacheEnvGroupId(profile.id, envGroup.id);
+ // Env already exists — delete or leave any orphan groups
+ 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);
+ }
+
+ // Runs in background after the fast-cache path — loads all chats to find and remove orphans
+ void _handleOrphansBackground(int accountId, int envGroupId) async {
+ final chats = await ChatsModule.getChats(accountId);
+ final orphans = CloudStorageModule.findOrphanGroups(chats);
+ for (final orphan in orphans) {
+ _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 _loadFiles(int accountId, int chatId) async {
+ if (mounted) setState(() => _filesLoading = true);
+ final files = await CloudStorageModule.fetchFiles(messagesModule, accountId, chatId);
+ if (!mounted) return;
+ setState(() {
+ _files = files.reversed.toList();
+ _filesLoading = false;
+ });
+ }
+
+ 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 _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 _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;
+
+ setState(() { _isUploading = true; _uploadProgress = 0; });
+
+ 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;
+ // Fetch just the new file — avoids full 200-msg reload
+ 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),
+ );
}
@override
@@ -50,111 +287,55 @@ class _CloudStorageScreenState extends State
elevation: 0,
leading: IconButton(
icon: Icon(Symbols.arrow_back, color: cs.onSurface),
- onPressed: _onBack,
+ onPressed: () {
+ if (_showUploadCard) {
+ setState(() => _showUploadCard = false);
+ } else {
+ Navigator.pop(context);
+ }
+ },
),
title: Text(
'Облачное хранилище',
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 _buildHint(ColorScheme cs, double t) {
- return Positioned(
- top: 0,
- left: _hintSidePadding,
- right: _hintSidePadding,
- child: IgnorePointer(
- child: Opacity(
- opacity: t,
- child: _DragDownHint(color: cs.onSurfaceVariant),
- ),
- ),
- );
- }
-
- Widget _buildUploadingCenterHint(ColorScheme cs, double t) {
+ Widget _buildNotConfigured(ColorScheme cs) {
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),
- ),
- ),
- ),
- );
- }
-
- Widget _buildEmptyState(ColorScheme cs, double t, double availableHeight) {
- return Transform.translate(
- offset: Offset(0, -t * availableHeight * _translateFactor),
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,
- ),
+ style: TextStyle(color: cs.onSurface, fontSize: 17, fontWeight: FontWeight.w600),
),
const SizedBox(height: 6),
- Text(
- 'Добавите?',
- style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
- ),
+ Text('Начнем? Это быстро.', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14)),
const SizedBox(height: 24),
FilledButton(
- onPressed: _mode.open,
+ onPressed: _isCreatingEnv ? null : _setupEnv,
style: FilledButton.styleFrom(
- backgroundColor: cs.primary,
- foregroundColor: cs.onPrimary,
- padding: const EdgeInsets.symmetric(
- horizontal: 32,
- vertical: 14,
- ),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(14),
- ),
- ),
- child: const Text(
- 'Загрузить',
- style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
+ 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)),
),
],
),
@@ -162,115 +343,266 @@ class _CloudStorageScreenState extends State
);
}
- List _buildCornerActions(ColorScheme cs, double t) {
- final slide = (1 - t) * _cornerSlideAmount;
- return [
- Positioned(
- bottom: _cornerBottomPadding,
- left: _cornerSidePadding - slide,
- child: Opacity(
- opacity: t,
- child: _CornerAction(
- icon: Symbols.upload_file,
- label: 'С файла',
- onTap: () {},
+ Widget _buildReady(ColorScheme cs) {
+ return LayoutBuilder(builder: (context, constraints) {
+ final cardSide = constraints.maxWidth * _cardViewportFraction;
+ return Stack(
+ fit: StackFit.expand,
+ children: [
+ GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: _showUploadCard
+ ? () => setState(() => _showUploadCard = false)
+ : null,
+ child: _buildMainContent(cs),
+ ),
+ AnimatedSwitcher(
+ duration: const Duration(milliseconds: 380),
+ reverseDuration: const Duration(milliseconds: 220),
+ transitionBuilder: (child, anim) => SlideTransition(
+ position: Tween(
+ begin: const Offset(0, 0.15),
+ end: Offset.zero,
+ ).animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)),
+ child: FadeTransition(opacity: anim, child: child),
+ ),
+ child: _showUploadCard
+ ? _buildUploadCard(cs, cardSide)
+ : const SizedBox.shrink(),
+ ),
+ ],
+ );
+ });
+ }
+
+ Widget _buildMainContent(ColorScheme cs) {
+ if (_filesLoading) return const Center(child: CircularProgressIndicator());
+
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: _horizontalPadding),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Text(
+ _files.isEmpty ? 'Облачных файлов пока нет...' : '${_files.length} ${_pluralFiles(_files.length)} в облаке',
+ textAlign: TextAlign.center,
+ style: TextStyle(color: cs.onSurface, fontSize: 17, fontWeight: FontWeight.w600),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ _files.isEmpty ? 'Добавите?' : 'Добавить ещё?',
+ style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
+ ),
+ const SizedBox(height: 24),
+ FilledButton(
+ onPressed: _showUploadCard ? null : () => setState(() => _showUploadCard = true),
+ style: FilledButton.styleFrom(
+ padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
+ ),
+ child: const Text('Загрузить', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildUploadCard(ColorScheme cs, double cardSide) {
+ return Align(
+ alignment: Alignment.bottomCenter,
+ child: GestureDetector(
+ onTap: () {},
+ child: Container(
+ key: const ValueKey('upload_card'),
+ margin: const EdgeInsets.fromLTRB(12, 0, 12, 24),
+ padding: const EdgeInsets.fromLTRB(16, 20, 16, 20),
+ decoration: BoxDecoration(
+ color: cs.surfaceContainerHigh,
+ borderRadius: BorderRadius.circular(24),
+ border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.4), width: 0.5),
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (_files.isNotEmpty) ...[
+ SizedBox(
+ height: cardSide,
+ 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: 5),
+ child: card,
+ );
+ if (i == 0 && _animateNewCard) {
+ return _FadeScaleEntry(
+ key: ValueKey('${_files[0].messageId}_${_files[0].time}'),
+ child: padded,
+ );
+ }
+ return padded;
+ },
+ ),
+ ),
+ ),
+ // Page indicator
+ const SizedBox(height: 8),
+ Center(
+ child: ValueListenableBuilder(
+ valueListenable: _currentFilePage,
+ builder: (context, page, child) => Text(
+ '${page + 1} / ${_files.length}',
+ style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
+ ),
+ ),
+ ),
+ const SizedBox(height: 12),
+ ],
+ if (_isUploading) ...[
+ LinearProgressIndicator(
+ value: _uploadProgress,
+ borderRadius: BorderRadius.circular(4),
+ minHeight: 5,
+ color: cs.primary,
+ backgroundColor: cs.surfaceContainerHighest,
+ ),
+ const SizedBox(height: 6),
+ Text(
+ 'Загрузка ${(_uploadProgress * 100).toStringAsFixed(0)}%',
+ style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
+ ),
+ const SizedBox(height: 16),
+ ] else ...[
+ Padding(
+ padding: const EdgeInsets.only(bottom: 16),
+ child: Text(
+ 'Начните загрузку для прогресс-бара',
+ style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
+ ),
+ ),
+ ],
+ Row(
+ children: [
+ Expanded(
+ child: _CardActionButton(
+ icon: Symbols.upload_file,
+ label: 'С файла',
+ onTap: _isUploading ? null : _pickAndUploadFile,
+ ),
+ ),
+ const SizedBox(width: 10),
+ Expanded(
+ child: _CardActionButton(
+ icon: Symbols.tag,
+ label: 'По ID',
+ onTap: _showSendByIdSheet,
+ ),
+ ),
+ ],
+ ),
+ ],
),
),
),
- Positioned(
- bottom: _cornerBottomPadding,
- right: _cornerSidePadding - slide,
- child: Opacity(
- opacity: t,
- child: _CornerAction(
- icon: Symbols.tag,
- label: 'По ID',
- onTap: () {},
- ),
- ),
+ );
+ }
+
+ static String _pluralFiles(int n) {
+ if (n % 10 == 1 && n % 100 != 11) return 'файл';
+ if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return 'файла';
+ return 'файлов';
+ }
+}
+
+class _MouseDragScrollBehavior extends MaterialScrollBehavior {
+ @override
+ Set 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 _scale;
+ late final Animation _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: (_, child) => Opacity(
+ opacity: _opacity.value.clamp(0.0, 1.0),
+ child: Transform.scale(scale: _scale.value, child: child),
),
- ];
+ child: widget.child,
+ );
}
}
-class _UploadModeController {
- static const _openDuration = Duration(milliseconds: 480);
- static const _closeDuration = Duration(milliseconds: 320);
- static const _dragRangeFactor = 0.55;
- static const _flingVelocityThreshold = 280.0;
- static const _snapMidpoint = 0.5;
-
- final AnimationController anim;
-
- _UploadModeController(TickerProvider vsync)
- : anim = AnimationController(
- vsync: vsync,
- duration: _openDuration,
- reverseDuration: _closeDuration,
- );
-
- bool get isOpen => anim.value > 0;
-
- void open() => anim.forward();
- void close() => anim.reverse();
-
- void handleDragUpdate(DragUpdateDetails d, double availableHeight) {
- if (anim.value == 0) return;
- final delta = d.primaryDelta ?? 0;
- final next = anim.value - delta / (availableHeight * _dragRangeFactor);
- anim.value = next.clamp(0.0, 1.0);
- }
-
- void handleDragEnd(DragEndDetails d) {
- final v = d.primaryVelocity ?? 0;
- if (v.abs() > _flingVelocityThreshold) {
- v > 0 ? close() : open();
- return;
- }
- anim.value < _snapMidpoint ? close() : open();
- }
-
- void dispose() => anim.dispose();
-}
-
-class _CornerAction extends StatelessWidget {
+class _CardActionButton extends StatelessWidget {
final IconData icon;
final String label;
- final VoidCallback onTap;
+ final VoidCallback? onTap;
- const _CornerAction({
- required this.icon,
- required this.label,
- required this.onTap,
- });
+ const _CardActionButton({required this.icon, required this.label, required this.onTap});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
+ final active = onTap != null;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
- borderRadius: BorderRadius.circular(16),
+ borderRadius: BorderRadius.circular(14),
child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
+ padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
- color: cs.surfaceContainerHigh,
- borderRadius: BorderRadius.circular(16),
- border: Border.all(
- color: cs.outlineVariant.withValues(alpha: 0.5),
- width: 0.5,
- ),
+ color: active ? cs.primaryContainer : cs.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(14),
),
child: Row(
- mainAxisSize: MainAxisSize.min,
+ mainAxisAlignment: MainAxisAlignment.center,
children: [
- Icon(icon, color: cs.onSurface, size: 20),
- const SizedBox(width: 8),
+ Icon(icon, size: 18, color: active ? cs.onPrimaryContainer : cs.onSurfaceVariant),
+ const SizedBox(width: 6),
Text(
label,
style: TextStyle(
- color: cs.onSurface,
+ color: active ? cs.onPrimaryContainer : cs.onSurfaceVariant,
fontSize: 14,
fontWeight: FontWeight.w600,
),
@@ -283,79 +615,363 @@ class _CornerAction extends StatelessWidget {
}
}
-class _DragDownHint extends StatefulWidget {
- final Color color;
+class _CloudFileCard extends StatelessWidget {
+ final CloudFile file;
+ final VoidCallback onTap;
- const _DragDownHint({required this.color});
+ const _CloudFileCard({required this.file, required this.onTap});
- @override
- State<_DragDownHint> createState() => _DragDownHintState();
-}
-
-class _DragDownHintState extends State<_DragDownHint>
- with SingleTickerProviderStateMixin {
- static const _cycle = Duration(milliseconds: 2800);
- static const _activeFraction = 0.4;
- static const _height = 3.0;
- static const _slotHeight = 28.0;
- static const _startY = -6.0;
- static const _travel = 16.0;
- static const _peakOpacity = 0.32;
-
- late final AnimationController _c;
-
- @override
- void initState() {
- super.initState();
- _c = AnimationController(vsync: this, duration: _cycle)..repeat();
+ 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,
+ };
}
- @override
- void dispose() {
- _c.dispose();
- super.dispose();
+ 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) {
- return SizedBox(
- height: _slotHeight,
- child: AnimatedBuilder(
- animation: _c,
- builder: (context, _) {
- final ghost = _ghostFor(_c.value);
- if (ghost.opacity <= 0) return const SizedBox.shrink();
- return Stack(
+ final cs = Theme.of(context).colorScheme;
+ return GestureDetector(
+ onTap: onTap,
+ child: AspectRatio(
+ aspectRatio: 1.0,
+ child: Container(
+ decoration: BoxDecoration(
+ color: cs.surface,
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5), width: 0.5),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Positioned(
- left: 0,
- right: 0,
- top: ghost.dy,
- height: _height,
- child: Opacity(
- opacity: ghost.opacity,
- child: DecoratedBox(
- decoration: BoxDecoration(
- color: widget.color,
- borderRadius: BorderRadius.circular(2),
+ 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),
+ ),
+ ],
),
),
],
- );
- },
+ ),
+ ),
),
);
}
+}
- ({double dy, double opacity}) _ghostFor(double phase) {
- if (phase > _activeFraction) return (dy: 0, opacity: 0);
- final local = phase / _activeFraction;
- final eased = Curves.easeOutCubic.transform(local);
- return (
- dy: _startY + eased * _travel,
- opacity: (1 - local) * _peakOpacity,
+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!);
+ }
+ // Refresh the expiry countdown every minute
+ _expiryTimer = Timer.periodic(const Duration(minutes: 1), (_) {
+ if (mounted) setState(() {});
+ });
+ }
+
+ @override
+ void dispose() {
+ _expiryTimer?.cancel();
+ super.dispose();
+ }
+
+ Future _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 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 _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)),
+ ),
+ ],
+ ),
);
}
}