From 5a44e855cbfc53a0c0410bb4bffdcf44f9e54c41 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Wed, 27 May 2026 20:54:08 +0700 Subject: [PATCH] =?UTF-8?q?=D1=81=D1=83=D0=BA=D0=B0=20=D1=83=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D1=8B=20=D0=BA=D0=BE=D0=BD=D1=87=D0=B5=D0=BD=D1=8B=D0=B5?= =?UTF-8?q?=20=D0=B1=D0=BB=D1=8F=D1=82=D1=8C=20=D0=B7=D0=B0=D0=B1=D0=B8?= =?UTF-8?q?=D0=BB=D0=B4=D0=B8=D1=82=D0=B5=20=D0=BC=D0=BD=D0=B5=20=D0=B0?= =?UTF-8?q?=D0=BF=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/NOTICE | 190 +++ android/app/src/main/AndroidManifest.xml | 6 + .../main/kotlin/ru/komet/app/MainActivity.kt | 44 + .../ru/komet/app/UploadForegroundService.kt | 95 ++ lib/backend/modules/chats.dart | 48 + lib/backend/modules/cloud_storage.dart | 209 ++++ lib/backend/modules/upload_manager.dart | 103 ++ .../modules/upload_notification_service.dart | 31 + .../screens/chats/chat_list_screen.dart | 3 +- .../screens/profile/cloud_storage_screen.dart | 1088 +++++++++++++---- 10 files changed, 1580 insertions(+), 237 deletions(-) create mode 100755 android/NOTICE create mode 100644 android/app/src/main/kotlin/ru/komet/app/UploadForegroundService.kt create mode 100644 lib/backend/modules/cloud_storage.dart create mode 100644 lib/backend/modules/upload_manager.dart create mode 100644 lib/backend/modules/upload_notification_service.dart 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)), + ), + ], + ), ); } }