какие то выкрутасы писькой
This commit is contained in:
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../api.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../models/attachment.dart';
|
||||
import 'chats.dart' show ChatsModule;
|
||||
@@ -462,8 +463,9 @@ class MessagesModule {
|
||||
int fileId, {
|
||||
String? token,
|
||||
bool notify = true,
|
||||
int maxAttempts = 5,
|
||||
int maxAttempts = 20,
|
||||
Duration retryDelay = const Duration(seconds: 1),
|
||||
Duration initialDelay = const Duration(seconds: 3),
|
||||
}) async {
|
||||
final payload = {
|
||||
'chatId': chatId,
|
||||
@@ -482,14 +484,18 @@ class MessagesModule {
|
||||
'notify': notify,
|
||||
};
|
||||
|
||||
await Future.delayed(initialDelay);
|
||||
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (response.isOk) return true;
|
||||
final err = response.payload is Map ? response.payload['error'] : null;
|
||||
if (err != 'attachment.not.ready' || attempt == maxAttempts - 1) {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (response.isOk) return true;
|
||||
return false;
|
||||
} on PacketError catch (e) {
|
||||
if (e.errorKey != 'attachment.not.ready') rethrow;
|
||||
if (attempt == maxAttempts - 1) return false;
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,8 @@ class Packet {
|
||||
|
||||
class PacketError implements Exception {
|
||||
final String message;
|
||||
const PacketError(this.message);
|
||||
final String? errorKey;
|
||||
const PacketError(this.message, {this.errorKey});
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
@@ -70,11 +70,14 @@ class PacketDispatcher {
|
||||
|
||||
if (packet.isError) {
|
||||
final message = messageFromErrorPayload(packet.payload);
|
||||
final errorKey = packet.payload is Map
|
||||
? packet.payload['error']?.toString()
|
||||
: null;
|
||||
if (packet.payload is Map &&
|
||||
packet.payload['message'] == 'FAIL_LOGIN_TOKEN') {
|
||||
completer.completeError(SessionExpiredException(message));
|
||||
} else {
|
||||
completer.completeError(PacketError(message));
|
||||
completer.completeError(PacketError(message, errorKey: errorKey));
|
||||
}
|
||||
} else {
|
||||
completer.complete(packet);
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:komet/backend/modules/chats.dart';
|
||||
import 'package:komet/backend/modules/file_uploader.dart';
|
||||
import 'package:komet/backend/modules/upload_notification_service.dart';
|
||||
import 'package:komet/frontend/screens/chats/chat_info_screen.dart';
|
||||
import 'package:komet/frontend/widgets/custom_notification.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@@ -1347,6 +1348,15 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
size: file.size,
|
||||
));
|
||||
|
||||
UploadNotificationService.start(file.name);
|
||||
|
||||
var notifLastSent = 0;
|
||||
var notifLastMs = DateTime.now().millisecondsSinceEpoch;
|
||||
var notifSpeedBps = 0;
|
||||
var notifLastPercent = -1;
|
||||
|
||||
void stopNotif() => UploadNotificationService.stop();
|
||||
|
||||
_uploadSub?.cancel();
|
||||
_uploadSub = fileUploader
|
||||
.upload(
|
||||
@@ -1361,7 +1371,24 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
switch (event) {
|
||||
case UploadProgress(:final sent, :final total):
|
||||
_uploadStatus.value = _UploadStatus(active: true, sent: sent, total: total);
|
||||
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||
final elapsed = nowMs - notifLastMs;
|
||||
if (elapsed >= 500) {
|
||||
notifSpeedBps = ((sent - notifLastSent) * 1000 / elapsed).round();
|
||||
notifLastSent = sent;
|
||||
notifLastMs = nowMs;
|
||||
}
|
||||
final percent = total > 0 ? (sent * 100 ~/ total) : 0;
|
||||
if (percent != notifLastPercent) {
|
||||
notifLastPercent = percent;
|
||||
UploadNotificationService.update(
|
||||
filename: file.name,
|
||||
progressPercent: percent,
|
||||
speedBps: notifSpeedBps,
|
||||
);
|
||||
}
|
||||
case UploadDone(:final fileId, :final token, :final url):
|
||||
stopNotif();
|
||||
FileHistoryCache.add(FileHistoryEntry(
|
||||
fileId: fileId,
|
||||
url: url,
|
||||
@@ -1381,12 +1408,14 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
),
|
||||
);
|
||||
case UploadError(:final message):
|
||||
stopNotif();
|
||||
showCustomNotification(context, 'Ошибка: $message');
|
||||
_updateFileMessageStatus(tempId, 'error');
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (!mounted) return;
|
||||
stopNotif();
|
||||
final inFlight = _messages.firstWhere(
|
||||
(m) => m.id == tempId,
|
||||
orElse: () => CachedMessage(
|
||||
@@ -1401,6 +1430,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
},
|
||||
onError: (Object e) {
|
||||
if (!mounted) return;
|
||||
stopNotif();
|
||||
showCustomNotification(context, 'Ошибка: $e');
|
||||
_updateFileMessageStatus(tempId, 'error');
|
||||
_uploadStatus.value = const _UploadStatus();
|
||||
|
||||
@@ -23,10 +23,17 @@ class CloudStorageScreen extends StatefulWidget {
|
||||
State<CloudStorageScreen> createState() => _CloudStorageScreenState();
|
||||
}
|
||||
|
||||
class _CloudStorageScreenState extends State<CloudStorageScreen> {
|
||||
class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static const _translateFactor = 0.7;
|
||||
static const _horizontalPadding = 32.0;
|
||||
static const _cardViewportFraction = 0.38;
|
||||
static const _hintSidePadding = 35.0;
|
||||
static const _cornerSidePadding = 16.0;
|
||||
static const _cornerBottomPadding = 24.0;
|
||||
static const _cornerSlideAmount = 28.0;
|
||||
static const _cardViewportFraction = 0.42;
|
||||
|
||||
late final _UploadModeController _mode;
|
||||
late final PageController _pageController;
|
||||
final _currentFilePage = ValueNotifier<int>(0);
|
||||
|
||||
@@ -35,28 +42,31 @@ class _CloudStorageScreenState extends State<CloudStorageScreen> {
|
||||
int? _envGroupId;
|
||||
int? _accountId;
|
||||
List<CloudFile> _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 _onPageScroll() {
|
||||
_currentFilePage.value = _pageController.page?.round() ?? 0;
|
||||
}
|
||||
|
||||
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; });
|
||||
setState(() => _isUploading = true);
|
||||
_mode.open();
|
||||
}
|
||||
mgr.onProgress = (progress, speedBps) {
|
||||
mgr.onProgress = (progress, _) {
|
||||
if (!mounted) return;
|
||||
setState(() { _isUploading = true; _uploadProgress = progress; });
|
||||
};
|
||||
@@ -72,17 +82,13 @@ class _CloudStorageScreenState extends State<CloudStorageScreen> {
|
||||
};
|
||||
}
|
||||
|
||||
void _onPageScroll() {
|
||||
_currentFilePage.value = _pageController.page?.round() ?? 0;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Unregister UI callbacks — upload continues in background via UploadManager
|
||||
final mgr = UploadManager.instance;
|
||||
mgr.onProgress = null;
|
||||
mgr.onDone = null;
|
||||
mgr.onError = null;
|
||||
_mode.dispose();
|
||||
_pageController.dispose();
|
||||
_currentFilePage.dispose();
|
||||
super.dispose();
|
||||
@@ -95,7 +101,6 @@ class _CloudStorageScreenState extends State<CloudStorageScreen> {
|
||||
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);
|
||||
@@ -107,31 +112,27 @@ class _CloudStorageScreenState extends State<CloudStorageScreen> {
|
||||
_accountId = profile.id;
|
||||
});
|
||||
_loadFiles(profile.id, cachedId);
|
||||
_handleOrphansBackground(profile.id, cachedId);
|
||||
_handleOrphansBackground(profile.id);
|
||||
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);
|
||||
}
|
||||
@@ -146,11 +147,9 @@ class _CloudStorageScreenState extends State<CloudStorageScreen> {
|
||||
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 {
|
||||
void _handleOrphansBackground(int accountId) async {
|
||||
final chats = await ChatsModule.getChats(accountId);
|
||||
final orphans = CloudStorageModule.findOrphanGroups(chats);
|
||||
for (final orphan in orphans) {
|
||||
for (final orphan in CloudStorageModule.findOrphanGroups(chats)) {
|
||||
_deleteOrLeave(accountId, orphan);
|
||||
}
|
||||
}
|
||||
@@ -165,13 +164,9 @@ class _CloudStorageScreenState extends State<CloudStorageScreen> {
|
||||
}
|
||||
|
||||
Future<void> _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;
|
||||
});
|
||||
setState(() => _files = files.reversed.toList());
|
||||
}
|
||||
|
||||
void _prependFile(CloudFile file) {
|
||||
@@ -180,11 +175,8 @@ class _CloudStorageScreenState extends State<CloudStorageScreen> {
|
||||
_animateNewCard = true;
|
||||
});
|
||||
if (_pageController.hasClients) {
|
||||
_pageController.animateToPage(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
_pageController.animateToPage(0,
|
||||
duration: const Duration(milliseconds: 350), curve: Curves.easeOut);
|
||||
}
|
||||
Future.delayed(const Duration(milliseconds: 800), () {
|
||||
if (mounted) setState(() => _animateNewCard = false);
|
||||
@@ -250,7 +242,6 @@ class _CloudStorageScreenState extends State<CloudStorageScreen> {
|
||||
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,
|
||||
);
|
||||
@@ -276,6 +267,14 @@ class _CloudStorageScreenState extends State<CloudStorageScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _onBack() {
|
||||
if (_mode.isOpen) {
|
||||
_mode.close();
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
@@ -287,13 +286,7 @@ class _CloudStorageScreenState extends State<CloudStorageScreen> {
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Symbols.arrow_back, color: cs.onSurface),
|
||||
onPressed: () {
|
||||
if (_showUploadCard) {
|
||||
setState(() => _showUploadCard = false);
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
onPressed: _onBack,
|
||||
),
|
||||
title: Text(
|
||||
'Облачное хранилище',
|
||||
@@ -331,8 +324,7 @@ class _CloudStorageScreenState extends State<CloudStorageScreen> {
|
||||
),
|
||||
child: _isCreatingEnv
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
width: 18, height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: cs.onPrimary),
|
||||
)
|
||||
: const Text('Начать', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
@@ -344,90 +336,61 @@ class _CloudStorageScreenState extends State<CloudStorageScreen> {
|
||||
}
|
||||
|
||||
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),
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final h = constraints.maxHeight;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onVerticalDragUpdate: (d) => _mode.handleDragUpdate(d, h),
|
||||
onVerticalDragEnd: _mode.handleDragEnd,
|
||||
child: AnimatedBuilder(
|
||||
animation: _mode.anim,
|
||||
builder: (context, _) {
|
||||
final t = Curves.easeOutCubic.transform(_mode.anim.value);
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_buildHint(cs, t),
|
||||
_buildUploadingCenterHint(cs, t, constraints.maxWidth),
|
||||
_buildEmptyState(cs, t, h),
|
||||
..._buildCornerActions(cs, t),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
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 _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 _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),
|
||||
),
|
||||
Widget _buildUploadingCenterHint(ColorScheme cs, double t, double availableWidth) {
|
||||
final cardSide = availableWidth * _cardViewportFraction;
|
||||
return Center(
|
||||
child: Opacity(
|
||||
opacity: t,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: _horizontalPadding),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_files.isNotEmpty) ...[
|
||||
SizedBox(
|
||||
height: cardSide,
|
||||
width: availableWidth,
|
||||
child: ScrollConfiguration(
|
||||
behavior: _MouseDragScrollBehavior(),
|
||||
child: PageView.builder(
|
||||
@@ -439,7 +402,7 @@ class _CloudStorageScreenState extends State<CloudStorageScreen> {
|
||||
onTap: () => _onCardTap(_files[i]),
|
||||
);
|
||||
final padded = Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: card,
|
||||
);
|
||||
if (i == 0 && _animateNewCard) {
|
||||
@@ -453,18 +416,15 @@ class _CloudStorageScreenState extends State<CloudStorageScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Page indicator
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: ValueListenableBuilder<int>(
|
||||
valueListenable: _currentFilePage,
|
||||
builder: (context, page, child) => Text(
|
||||
'${page + 1} / ${_files.length}',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
ValueListenableBuilder<int>(
|
||||
valueListenable: _currentFilePage,
|
||||
builder: (context, page, child) => Text(
|
||||
'${page + 1} / ${_files.length}',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (_isUploading) ...[
|
||||
LinearProgressIndicator(
|
||||
@@ -474,40 +434,18 @@ class _CloudStorageScreenState extends State<CloudStorageScreen> {
|
||||
color: cs.primary,
|
||||
backgroundColor: cs.surfaceContainerHighest,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Загрузка ${(_uploadProgress * 100).toStringAsFixed(0)}%',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
] else ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
'Начните загрузку для прогресс-бара',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
] else if (_files.isEmpty) ...[
|
||||
Text(
|
||||
'Начните загрузку для прогресс-бара',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
),
|
||||
],
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -515,10 +453,248 @@ class _CloudStorageScreenState extends State<CloudStorageScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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 'файлов';
|
||||
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,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Добавите?',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _mode.isOpen ? null : _mode.open,
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _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: _pickAndUploadFile,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: _cornerBottomPadding,
|
||||
right: _cornerSidePadding - slide,
|
||||
child: Opacity(
|
||||
opacity: t,
|
||||
child: _CornerAction(
|
||||
icon: Symbols.tag,
|
||||
label: 'По ID',
|
||||
onTap: _showSendByIdSheet,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _CornerAction({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: cs.outlineVariant.withValues(alpha: 0.5),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: cs.onSurface, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DragDownHint extends StatefulWidget {
|
||||
final Color color;
|
||||
|
||||
const _DragDownHint({required this.color});
|
||||
|
||||
@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();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_c.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@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(
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
({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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,7 +739,7 @@ class _FadeScaleEntryState extends State<_FadeScaleEntry>
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _c,
|
||||
builder: (_, child) => Opacity(
|
||||
builder: (context, child) => Opacity(
|
||||
opacity: _opacity.value.clamp(0.0, 1.0),
|
||||
child: Transform.scale(scale: _scale.value, child: child),
|
||||
),
|
||||
@@ -572,49 +748,6 @@ class _FadeScaleEntryState extends State<_FadeScaleEntry>
|
||||
}
|
||||
}
|
||||
|
||||
class _CardActionButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? 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(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? cs.primaryContainer : cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: active ? cs.onPrimaryContainer : cs.onSurfaceVariant),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: active ? cs.onPrimaryContainer : cs.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CloudFileCard extends StatelessWidget {
|
||||
final CloudFile file;
|
||||
final VoidCallback onTap;
|
||||
@@ -655,7 +788,7 @@ class _CloudFileCard extends StatelessWidget {
|
||||
aspectRatio: 1.0,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
color: cs.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5), width: 0.5),
|
||||
),
|
||||
@@ -719,7 +852,6 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> {
|
||||
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(() {});
|
||||
});
|
||||
@@ -784,8 +916,7 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> {
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
width: 36, height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
@@ -793,10 +924,8 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
f.name,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700),
|
||||
),
|
||||
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),
|
||||
@@ -808,20 +937,15 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: isExpired
|
||||
? Text(
|
||||
'Ссылки пока нет. Создайте.',
|
||||
style: TextStyle(color: cs.error, fontSize: 13),
|
||||
)
|
||||
: Text(
|
||||
'Ссылка истечет ${_formatExpiry(_link!.expires)}',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
? 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,
|
||||
width: 20, height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: cs.primary),
|
||||
)
|
||||
: IconButton(
|
||||
@@ -923,19 +1047,15 @@ class _SendByIdSheetState extends State<_SendByIdSheet> {
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
width: 36, height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
color: cs.outlineVariant, borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Отправить по ID',
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700),
|
||||
),
|
||||
Text('Отправить по ID',
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
@@ -964,8 +1084,7 @@ class _SendByIdSheetState extends State<_SendByIdSheet> {
|
||||
),
|
||||
child: _sending
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
width: 18, height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: cs.onPrimary),
|
||||
)
|
||||
: const Text('Отправить', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
|
||||
Reference in New Issue
Block a user