diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index e64996e..6fb91eb 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -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; } diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 592ddd2..75af3a2 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -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; } diff --git a/lib/core/transport/dispatcher.dart b/lib/core/transport/dispatcher.dart index 89c6237..ff776c8 100644 --- a/lib/core/transport/dispatcher.dart +++ b/lib/core/transport/dispatcher.dart @@ -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); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 4b9fa90..bfdefd3 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -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 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 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 ), ); 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 }, onError: (Object e) { if (!mounted) return; + stopNotif(); showCustomNotification(context, 'Ошибка: $e'); _updateFileMessageStatus(tempId, 'error'); _uploadStatus.value = const _UploadStatus(); diff --git a/lib/frontend/screens/profile/cloud_storage_screen.dart b/lib/frontend/screens/profile/cloud_storage_screen.dart index b776637..1f353b0 100644 --- a/lib/frontend/screens/profile/cloud_storage_screen.dart +++ b/lib/frontend/screens/profile/cloud_storage_screen.dart @@ -23,10 +23,17 @@ class CloudStorageScreen extends StatefulWidget { State createState() => _CloudStorageScreenState(); } -class _CloudStorageScreenState extends State { +class _CloudStorageScreenState extends State + 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(0); @@ -35,28 +42,31 @@ class _CloudStorageScreenState extends State { 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 _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 { }; } - 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 { 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 { _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 { 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 { } 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; - }); + setState(() => _files = files.reversed.toList()); } void _prependFile(CloudFile file) { @@ -180,11 +175,8 @@ class _CloudStorageScreenState extends State { _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 { 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 { ); } + 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 { 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 { ), 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 { } 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 { 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 { ), ), ), - // 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), - ), + ValueListenableBuilder( + 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 { 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 { ); } - 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 _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)),