diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 5ba0711..8e69e76 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -60,8 +60,10 @@ class ChatScreen extends StatefulWidget { final int chatId; final Contact contact; final int myId; + /// Колбэк для мягких обновлений списка чатов (например, после редактирования сообщения). final VoidCallback? onChatUpdated; + /// Колбэк, который вызывается, когда чат нужно убрать из списка (удаление / выход из группы). final VoidCallback? onChatRemoved; final bool isGroupChat; @@ -363,8 +365,8 @@ class _ChatScreenState extends State { if (contactProfile != null && contactProfile['id'] != null && contactProfile['id'] != 0) { - String? idStr = await prefs.getString("userId"); - _actualMyId = idStr!.isNotEmpty ? int.parse(idStr) : contactProfile['id']; + String? idStr = await prefs.getString("userId"); + _actualMyId = idStr!.isNotEmpty ? int.parse(idStr) : contactProfile['id']; print( '✅ [_initializeChat] ID пользователя получен из ApiService: $_actualMyId', ); @@ -382,7 +384,8 @@ class _ChatScreenState extends State { } } else if (_actualMyId == null) { final prefs = await SharedPreferences.getInstance(); - _actualMyId = int.parse(await prefs.getString('userId')!);; + _actualMyId = int.parse(await prefs.getString('userId')!); + ; print( '⚠️ [_initializeChat] ID не найден, используется из виджета: $_actualMyId', ); @@ -1235,7 +1238,6 @@ class _ChatScreenState extends State { } void _testSlideAnimation() { - final myMessage = Message( id: 'test_my_${DateTime.now().millisecondsSinceEpoch}', text: 'Тест моё сообщение (должно выехать справа)', @@ -1789,8 +1791,9 @@ class _ChatScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Ошибка очистки истории: $e'), - backgroundColor: - Theme.of(context).colorScheme.error, + backgroundColor: Theme.of( + context, + ).colorScheme.error, ), ); } @@ -1889,8 +1892,9 @@ class _ChatScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Ошибка удаления чата: $e'), - backgroundColor: - Theme.of(context).colorScheme.error, + backgroundColor: Theme.of( + context, + ).colorScheme.error, ), ); } @@ -2522,7 +2526,10 @@ class _ChatScreenState extends State { duration: const Duration(milliseconds: 100), curve: Curves.easeOutQuad, right: 16, - bottom: MediaQuery.of(context).viewInsets.bottom + 100, + bottom: + MediaQuery.of(context).viewInsets.bottom + + MediaQuery.of(context).padding.bottom + + 100, child: AnimatedScale( duration: const Duration(milliseconds: 200), curve: Curves.easeOutBack, @@ -2551,7 +2558,10 @@ class _ChatScreenState extends State { curve: Curves.easeOutQuad, left: 8, right: 8, - bottom: MediaQuery.of(context).viewInsets.bottom + 12, + bottom: + MediaQuery.of(context).viewInsets.bottom + + MediaQuery.of(context).padding.bottom + + 12, child: _buildTextInput(), ), ], @@ -3140,8 +3150,8 @@ class _ChatScreenState extends State { _replyingToMessage!.text.isNotEmpty ? _replyingToMessage!.text : (_replyingToMessage!.hasFileAttach - ? 'Файл' - : 'Фото'), + ? 'Файл' + : 'Фото'), style: TextStyle( fontSize: 13, color: Theme.of( @@ -3508,8 +3518,8 @@ class _ChatScreenState extends State { _replyingToMessage!.text.isNotEmpty ? _replyingToMessage!.text : (_replyingToMessage!.hasFileAttach - ? 'Файл' - : 'Фото'), + ? 'Файл' + : 'Фото'), style: TextStyle( fontSize: 13, color: Theme.of( diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index 29a3eea..672fb6c 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -40,6 +40,9 @@ class MusicTrack { final token = attach['token'] as String?; final name = attach['name'] as String? ?? 'Unknown'; + final durationSeconds = preview?['duration'] as int?; + final duration = durationSeconds != null ? durationSeconds * 1000 : null; + return MusicTrack( id: fileId?.toString() ?? @@ -48,7 +51,7 @@ class MusicTrack { artist: preview?['artistName'] as String? ?? 'Unknown Artist', album: preview?['albumName'] as String?, albumArtUrl: preview?['baseUrl'] as String?, - duration: preview?['duration'] as int?, + duration: duration, fileId: fileId, token: token, ); @@ -135,7 +138,7 @@ class MusicPlayerService extends ChangeNotifier { _isLoading = state.processingState == ProcessingState.loading || state.processingState == ProcessingState.buffering; - + // Detect track completion and auto-play next track if (state.processingState == ProcessingState.completed && !wasCompleted) { _wasCompleted = true; @@ -143,7 +146,7 @@ class MusicPlayerService extends ChangeNotifier { } else if (state.processingState != ProcessingState.completed) { _wasCompleted = false; } - + notifyListeners(); }); @@ -270,7 +273,7 @@ class MusicPlayerService extends ChangeNotifier { Future _autoPlayNext() async { if (_playlist.isEmpty || _playlist.length <= 1) return; - + try { _currentIndex = (_currentIndex + 1) % _playlist.length; await _loadAndPlayTrack(_playlist[_currentIndex]); diff --git a/lib/widgets/bottom_sheet_music_player.dart b/lib/widgets/bottom_sheet_music_player.dart index 128d044..57cf33d 100644 --- a/lib/widgets/bottom_sheet_music_player.dart +++ b/lib/widgets/bottom_sheet_music_player.dart @@ -43,7 +43,9 @@ class _BottomSheetMusicPlayerState extends State curve: Curves.easeInOut, ); BottomSheetMusicPlayer.isExpandedNotifier.addListener(_onExpandedChanged); - BottomSheetMusicPlayer.isFullscreenNotifier.addListener(_onFullscreenChanged); + BottomSheetMusicPlayer.isFullscreenNotifier.addListener( + _onFullscreenChanged, + ); } void _onExpandedChanged() { @@ -63,7 +65,8 @@ class _BottomSheetMusicPlayerState extends State } void _onFullscreenChanged() { - final shouldBeFullscreen = BottomSheetMusicPlayer.isFullscreenNotifier.value; + final shouldBeFullscreen = + BottomSheetMusicPlayer.isFullscreenNotifier.value; if (shouldBeFullscreen && _currentState != _PlayerState.fullscreen) { setState(() { _currentState = _PlayerState.fullscreen; @@ -164,7 +167,7 @@ class _BottomSheetMusicPlayerState extends State final collapsedHeight = 88.0; final expandedHeight = screenHeight * 0.85; final fullscreenHeight = screenHeight; - + double targetHeight; if (_currentState == _PlayerState.fullscreen) { targetHeight = fullscreenHeight; @@ -173,9 +176,10 @@ class _BottomSheetMusicPlayerState extends State } else { targetHeight = collapsedHeight; } - + // Interpolate between collapsed and target height - final currentHeight = collapsedHeight + + final currentHeight = + collapsedHeight + (targetHeight - collapsedHeight) * _heightAnimation.value; return Material( @@ -251,15 +255,16 @@ class _BottomSheetMusicPlayerState extends State return FadeTransition( opacity: animation, child: SlideTransition( - position: Tween( - begin: const Offset(0.0, 0.05), - end: Offset.zero, - ).animate( - CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - ), - ), + position: + Tween( + begin: const Offset(0.0, 0.05), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ), + ), child: child, ), ); @@ -285,7 +290,7 @@ class _BottomSheetMusicPlayerState extends State onTap: _toggleExpand, borderRadius: BorderRadius.circular(28), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ Hero( @@ -300,10 +305,13 @@ class _BottomSheetMusicPlayerState extends State ? Image.network( track.albumArtUrl!, fit: BoxFit.cover, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return _buildAlbumArtPlaceholder(colorScheme); - }, + loadingBuilder: + (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return _buildAlbumArtPlaceholder( + colorScheme, + ); + }, errorBuilder: (context, error, stackTrace) => _buildAlbumArtPlaceholder(colorScheme), ) @@ -318,24 +326,30 @@ class _BottomSheetMusicPlayerState extends State mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - Text( - track.title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - letterSpacing: -0.2, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + Flexible( + child: Text( + track.title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - const SizedBox(height: 6), - Text( - track.artist, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurface.withOpacity(0.65), - fontSize: 13, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + const SizedBox(height: 3), + Flexible( + child: Text( + track.artist, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onSurface.withOpacity(0.65), + fontSize: 13, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), ], ), @@ -371,7 +385,9 @@ class _BottomSheetMusicPlayerState extends State ), ) : Icon( - musicPlayer.isPlaying ? Icons.pause : Icons.play_arrow, + musicPlayer.isPlaying + ? Icons.pause + : Icons.play_arrow, size: 26, color: colorScheme.onPrimary, ), @@ -420,44 +436,44 @@ class _BottomSheetMusicPlayerState extends State ), Expanded( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 8), - // Album art with hero animation - LayoutBuilder( - builder: (context, constraints) { - final maxWidth = constraints.maxWidth; - final albumSize = maxWidth < 380 ? maxWidth : 380.0; - return Hero( - tag: 'album-art-${track.id}', - child: Container( - width: albumSize, - height: albumSize, - margin: EdgeInsets.symmetric( - horizontal: (maxWidth - albumSize) / 2, + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 8), + // Album art with hero animation + LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + final albumSize = maxWidth < 380 ? maxWidth : 380.0; + return Hero( + tag: 'album-art-${track.id}', + child: Container( + width: albumSize, + height: albumSize, + margin: EdgeInsets.symmetric( + horizontal: (maxWidth - albumSize) / 2, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(32), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.25), + blurRadius: 30, + offset: const Offset(0, 12), + spreadRadius: 2, ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(32), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.25), - blurRadius: 30, - offset: const Offset(0, 12), - spreadRadius: 2, - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(32), - child: track.albumArtUrl != null - ? Image.network( - track.albumArtUrl!, - fit: BoxFit.cover, - loadingBuilder: - (context, child, loadingProgress) { + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(32), + child: track.albumArtUrl != null + ? Image.network( + track.albumArtUrl!, + fit: BoxFit.cover, + loadingBuilder: + (context, child, loadingProgress) { if (loadingProgress == null) { return child; } @@ -466,228 +482,230 @@ class _BottomSheetMusicPlayerState extends State colorScheme, ); }, - errorBuilder: - (context, error, stackTrace) => - _buildLargeAlbumArtPlaceholder( - context, - colorScheme, - ), - ) - : _buildLargeAlbumArtPlaceholder( - context, - colorScheme, - ), - ), - ), - ); - }, - ), - const SizedBox(height: 32), - // Track info - Text( - track.title, - style: Theme.of(context).textTheme.headlineMedium - ?.copyWith( - fontWeight: FontWeight.bold, - letterSpacing: -0.5, - ), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 12), - Text( - track.artist, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: colorScheme.onSurface.withOpacity(0.75), - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (track.album != null) ...[ - const SizedBox(height: 8), - Text( - track.album!, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurface.withOpacity(0.6), - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, + errorBuilder: + (context, error, stackTrace) => + _buildLargeAlbumArtPlaceholder( + context, + colorScheme, + ), + ) + : _buildLargeAlbumArtPlaceholder( + context, + colorScheme, + ), + ), ), - ], - const SizedBox(height: 40), - // Progress slider - Column( - children: [ - SliderTheme( - data: SliderTheme.of(context).copyWith( - activeTrackColor: colorScheme.primary, - inactiveTrackColor: - colorScheme.surfaceContainerHigh, - thumbColor: colorScheme.primary, - overlayColor: - colorScheme.primary.withOpacity(0.1), - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 8, - ), - trackHeight: 4, - ), - child: Slider( - value: musicPlayer.duration.inMilliseconds > 0 - ? (musicPlayer.position.inMilliseconds / - musicPlayer.duration.inMilliseconds) - .clamp(0.0, 1.0) - : 0.0, - onChanged: (value) { - HapticFeedback.selectionClick(); - final newPosition = Duration( - milliseconds: (value * - musicPlayer.duration.inMilliseconds) + ); + }, + ), + const SizedBox(height: 32), + // Track info + Text( + track.title, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: -0.5, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 12), + Text( + track.artist, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: colorScheme.onSurface.withOpacity(0.75), + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (track.album != null && track.album!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + track.album!, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface.withOpacity(0.6), + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + if (track.duration != null) ...[ + const SizedBox(height: 8), + Text( + _formatDuration(Duration(milliseconds: track.duration!)), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withOpacity(0.55), + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 40), + // Progress slider + Column( + children: [ + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: colorScheme.primary, + inactiveTrackColor: colorScheme.surfaceContainerHigh, + thumbColor: colorScheme.primary, + overlayColor: colorScheme.primary.withOpacity(0.1), + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 8, + ), + trackHeight: 4, + ), + child: Slider( + value: musicPlayer.duration.inMilliseconds > 0 + ? (musicPlayer.position.inMilliseconds / + musicPlayer.duration.inMilliseconds) + .clamp(0.0, 1.0) + : 0.0, + onChanged: (value) { + HapticFeedback.selectionClick(); + final newPosition = Duration( + milliseconds: + (value * musicPlayer.duration.inMilliseconds) .round(), - ); - musicPlayer.seek(newPosition); - }, - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - _formatDuration(musicPlayer.position), - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: colorScheme.onSurface - .withOpacity(0.7), - fontWeight: FontWeight.w500, - fontSize: 13, - ), - ), - Text( - _formatDuration(musicPlayer.duration), - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: colorScheme.onSurface - .withOpacity(0.7), - fontWeight: FontWeight.w500, - fontSize: 13, - ), - ), - ], - ), - ), - ], + ); + musicPlayer.seek(newPosition); + }, + ), ), - const SizedBox(height: 16), - // Control buttons - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - HapticFeedback.selectionClick(); - musicPlayer.previous(); - }, - borderRadius: BorderRadius.circular(28), - child: Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, - shape: BoxShape.circle, - ), - child: Icon( - Icons.skip_previous_rounded, - size: 28, - color: colorScheme.onSurface, - ), - ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _formatDuration(musicPlayer.position), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onSurface.withOpacity( + 0.7, + ), + fontWeight: FontWeight.w500, + fontSize: 13, + ), ), - ), - const SizedBox(width: 24), - Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - HapticFeedback.mediumImpact(); - if (musicPlayer.isPlaying) { - musicPlayer.pause(); - } else { - musicPlayer.resume(); - } - }, - borderRadius: BorderRadius.circular(40), - child: Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: colorScheme.primary, - shape: BoxShape.circle, - ), - child: musicPlayer.isLoading - ? Padding( - padding: const EdgeInsets.all(24), - child: CircularProgressIndicator( - strokeWidth: 3, - valueColor: - AlwaysStoppedAnimation( - colorScheme.onPrimary, - ), - ), - ) - : Icon( - musicPlayer.isPlaying - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - size: 40, - color: colorScheme.onPrimary, - ), - ), + Text( + _formatDuration(musicPlayer.duration), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onSurface.withOpacity( + 0.7, + ), + fontWeight: FontWeight.w500, + fontSize: 13, + ), ), - ), - const SizedBox(width: 24), - Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - HapticFeedback.selectionClick(); - musicPlayer.next(); - }, - borderRadius: BorderRadius.circular(28), - child: Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, - shape: BoxShape.circle, - ), - child: Icon( - Icons.skip_next_rounded, - size: 28, - color: colorScheme.onSurface, - ), - ), - ), - ), - ], - ), - SizedBox( - height: MediaQuery.of(context).padding.bottom + 24, + ], + ), ), ], ), - ), - + const SizedBox(height: 16), + // Control buttons + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + HapticFeedback.selectionClick(); + musicPlayer.previous(); + }, + borderRadius: BorderRadius.circular(28), + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + shape: BoxShape.circle, + ), + child: Icon( + Icons.skip_previous_rounded, + size: 28, + color: colorScheme.onSurface, + ), + ), + ), + ), + const SizedBox(width: 24), + Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + HapticFeedback.mediumImpact(); + if (musicPlayer.isPlaying) { + musicPlayer.pause(); + } else { + musicPlayer.resume(); + } + }, + borderRadius: BorderRadius.circular(40), + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + ), + child: musicPlayer.isLoading + ? Padding( + padding: const EdgeInsets.all(24), + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation( + colorScheme.onPrimary, + ), + ), + ) + : Icon( + musicPlayer.isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + size: 40, + color: colorScheme.onPrimary, + ), + ), + ), + ), + const SizedBox(width: 24), + Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + HapticFeedback.selectionClick(); + musicPlayer.next(); + }, + borderRadius: BorderRadius.circular(28), + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + shape: BoxShape.circle, + ), + child: Icon( + Icons.skip_next_rounded, + size: 28, + color: colorScheme.onSurface, + ), + ), + ), + ), + ], + ), + SizedBox(height: MediaQuery.of(context).padding.bottom + 24), + ], + ), + ), ), ], ), diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart index 1b3822d..cd68782 100644 --- a/lib/widgets/chat_message_bubble.dart +++ b/lib/widgets/chat_message_bubble.dart @@ -30,8 +30,9 @@ import 'package:platform_info/platform_info.dart'; bool _currentIsDark = false; -bool isMobile = Platform.instance.operatingSystem.iOS || - Platform.instance.operatingSystem.android; +bool isMobile = + Platform.instance.operatingSystem.iOS || + Platform.instance.operatingSystem.android; enum MessageReadStatus { sending, // Отправляется (часы) @@ -1044,9 +1045,7 @@ class ChatMessageBubble extends StatelessWidget { // чтобы визуально не было "бабла" вокруг карточек файлов. BoxDecoration bubbleDecoration; if (isFileOnly) { - bubbleDecoration = const BoxDecoration( - color: Colors.transparent, - ); + bubbleDecoration = const BoxDecoration(color: Colors.transparent); } else { bubbleDecoration = _createBubbleDecoration( bubbleColor, @@ -1112,20 +1111,19 @@ class ChatMessageBubble extends StatelessWidget { if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) { if (isMobile) { messageContent = GestureDetector( - onTapDown: (TapDownDetails details) { - _showMessageContextMenu(context, details.globalPosition); - }, - child: messageContent, - ); + onTapDown: (TapDownDetails details) { + _showMessageContextMenu(context, details.globalPosition); + }, + child: messageContent, + ); } else { messageContent = GestureDetector( - onSecondaryTapDown: (TapDownDetails details) { - _showMessageContextMenu(context, details.globalPosition); - }, - child: messageContent, - ); + onSecondaryTapDown: (TapDownDetails details) { + _showMessageContextMenu(context, details.globalPosition); + }, + child: messageContent, + ); } - } return Column( @@ -1576,18 +1574,18 @@ class ChatMessageBubble extends StatelessWidget { if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) { if (isMobile) { videoContent = GestureDetector( - onTapDown: (TapDownDetails details) { - _showMessageContextMenu(context, details.globalPosition); - }, - child: videoContent, - ); + onTapDown: (TapDownDetails details) { + _showMessageContextMenu(context, details.globalPosition); + }, + child: videoContent, + ); } else { videoContent = GestureDetector( - onSecondaryTapDown: (TapDownDetails details) { - _showMessageContextMenu(context, details.globalPosition); - }, - child: videoContent, - ); + onSecondaryTapDown: (TapDownDetails details) { + _showMessageContextMenu(context, details.globalPosition); + }, + child: videoContent, + ); } } @@ -1671,11 +1669,11 @@ class ChatMessageBubble extends StatelessWidget { if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) { if (isMobile) { photoContent = GestureDetector( - onTapDown: (TapDownDetails details) { - _showMessageContextMenu(context, details.globalPosition); - }, - child: photoContent, - ); + onTapDown: (TapDownDetails details) { + _showMessageContextMenu(context, details.globalPosition); + }, + child: photoContent, + ); } else { photoContent = GestureDetector( onTapDown: (TapDownDetails details) { @@ -1683,7 +1681,7 @@ class ChatMessageBubble extends StatelessWidget { }, child: photoContent, ); - } + } } return photoContent; @@ -1808,11 +1806,11 @@ class ChatMessageBubble extends StatelessWidget { if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) { if (isMobile) { videoContent = GestureDetector( - onTapDown: (TapDownDetails details) { - _showMessageContextMenu(context, details.globalPosition); - }, - child: videoContent, - ); + onTapDown: (TapDownDetails details) { + _showMessageContextMenu(context, details.globalPosition); + }, + child: videoContent, + ); } else { videoContent = GestureDetector( onSecondaryTapDown: (TapDownDetails details) { @@ -2406,11 +2404,11 @@ class ChatMessageBubble extends StatelessWidget { final artist = preview?['artistName'] as String? ?? 'Unknown Artist'; final album = preview?['albumName'] as String?; final albumArtUrl = preview?['baseUrl'] as String?; - final duration = preview?['duration'] as int?; + final durationSeconds = preview?['duration'] as int?; + final duration = durationSeconds != null ? durationSeconds * 1000 : null; String durationText = ''; - if (duration != null) { - final durationSeconds = (duration / 1000).round(); + if (durationSeconds != null) { final minutes = durationSeconds ~/ 60; final seconds = durationSeconds % 60; durationText = '$minutes:${seconds.toString().padLeft(2, '0')}'; @@ -2441,7 +2439,14 @@ class ChatMessageBubble extends StatelessWidget { } if (!isDownloaded) { - await _handleFileDownload(context, fileId, token, fileName, chatId); + await _handleFileDownload( + context, + fileId, + token, + fileName, + chatId, + preview: preview, + ); await Future.delayed(const Duration(seconds: 1)); if (fileIdString != null) { final updatedFileIdMap = @@ -2708,6 +2713,7 @@ class ChatMessageBubble extends StatelessWidget { token, fileName, chatId, + preview: preview, ); await Future.delayed(const Duration(seconds: 1)); final updatedFileIdMap = @@ -2906,8 +2912,9 @@ class ChatMessageBubble extends StatelessWidget { int? fileId, String? token, String fileName, - int? chatId, - ) async { + int? chatId, { + Map? preview, + }) async { // 1. Проверяем fileId, он нужен в любом случае if (fileId == null) { if (context.mounted) { @@ -3048,7 +3055,16 @@ class ChatMessageBubble extends StatelessWidget { } // Download file to Downloads folder with progress - await _downloadFile(downloadUrl, fileName, fileId.toString(), context); + await _downloadFile( + downloadUrl, + fileName, + fileId.toString(), + context, + preview: preview, + fileIdInt: fileId, + token: token, + chatId: chatId, + ); } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -3065,10 +3081,23 @@ class ChatMessageBubble extends StatelessWidget { String url, String fileName, String fileId, - BuildContext context, - ) async { + BuildContext context, { + Map? preview, + int? fileIdInt, + String? token, + int? chatId, + }) async { // Download in background without blocking dialog - _startBackgroundDownload(url, fileName, fileId, context); + _startBackgroundDownload( + url, + fileName, + fileId, + context, + preview: preview, + fileIdInt: fileIdInt, + token: token, + chatId: chatId, + ); // Show immediate success snackbar if (context.mounted) { @@ -3085,8 +3114,12 @@ class ChatMessageBubble extends StatelessWidget { String url, String fileName, String fileId, - BuildContext context, - ) async { + BuildContext context, { + Map? preview, + int? fileIdInt, + String? token, + int? chatId, + }) async { // Initialize progress FileDownloadProgressService().updateProgress(fileId, 0.0); @@ -3167,6 +3200,40 @@ class ChatMessageBubble extends StatelessWidget { await prefs.setStringList('file_id_to_path_map', fileIdMap); } + // Save music metadata if preview is available and file is a music file + if (preview != null && fileIdInt != null) { + final extension = fileName.split('.').last.toLowerCase(); + if (['mp3', 'wav', 'flac', 'm4a', 'aac', 'ogg'].contains(extension)) { + final title = preview['title'] as String? ?? fileName; + final artist = preview['artistName'] as String? ?? 'Unknown Artist'; + final album = preview['albumName'] as String?; + final albumArtUrl = preview['baseUrl'] as String?; + final durationSeconds = preview['duration'] as int?; + final duration = durationSeconds != null + ? durationSeconds * 1000 + : null; + + final track = MusicTrack( + id: fileId, + title: title, + artist: artist, + album: album, + albumArtUrl: albumArtUrl, + duration: duration, + filePath: file.path, + fileId: fileIdInt, + token: token, + chatId: chatId, + ); + + final musicMetadataJson = prefs.getString('music_metadata') ?? '{}'; + final musicMetadata = + jsonDecode(musicMetadataJson) as Map; + musicMetadata[fileId] = track.toJson(); + await prefs.setString('music_metadata', jsonEncode(musicMetadata)); + } + } + // Show success message if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -3780,18 +3847,14 @@ class ChatMessageBubble extends StatelessWidget { Linkify( text: 'Привет! Это твои избранные. Все написанное сюда попадёт прямиком к дяде Майору.', - style: - TextStyle(color: textColor, fontStyle: FontStyle.italic), + style: TextStyle(color: textColor, fontStyle: FontStyle.italic), linkStyle: linkStyle, onOpen: onOpenLink, options: const LinkifyOptions(humanize: false), textAlign: TextAlign.left, ) else if (message.text.contains("komet.color_")) - _buildKometColorRichText( - message.text, - defaultTextStyle, - ) + _buildKometColorRichText(message.text, defaultTextStyle) else Linkify( text: message.text, @@ -3921,10 +3984,7 @@ class ChatMessageBubble extends StatelessWidget { /// Строит раскрашенный текст на основе синтаксиса komet.color_#HEX'текст'. /// Если цвет некорректный, используется красный. - Widget _buildKometColorRichText( - String rawText, - TextStyle baseStyle, - ) { + Widget _buildKometColorRichText(String rawText, TextStyle baseStyle) { final segments = _parseKometColorSegments(rawText, baseStyle.color); return RichText( @@ -3956,25 +4016,19 @@ class ChatMessageBubble extends StatelessWidget { while (index < text.length) { final start = text.indexOf(marker, index); if (start == -1) { - segments.add( - _KometColoredSegment(text.substring(index), null), - ); + segments.add(_KometColoredSegment(text.substring(index), null)); break; } if (start > index) { - segments.add( - _KometColoredSegment(text.substring(index, start), null), - ); + segments.add(_KometColoredSegment(text.substring(index, start), null)); } final colorStart = start + marker.length; final firstQuote = text.indexOf("'", colorStart); if (firstQuote == -1) { // Кривой синтаксис — считаем всё остальное обычным текстом. - segments.add( - _KometColoredSegment(text.substring(start), null), - ); + segments.add(_KometColoredSegment(text.substring(start), null)); break; } @@ -3982,9 +4036,7 @@ class ChatMessageBubble extends StatelessWidget { final textStart = firstQuote + 1; final secondQuote = text.indexOf("'", textStart); if (secondQuote == -1) { - segments.add( - _KometColoredSegment(text.substring(start), null), - ); + segments.add(_KometColoredSegment(text.substring(start), null)); break; } @@ -5254,7 +5306,7 @@ class _AudioPlayerWidgetState extends State<_AudioPlayerWidget> { }); _audioPlayer.durationStream.listen((duration) { - if (mounted && duration != null) { + if (mounted && duration != null && duration.inMilliseconds > 0) { setState(() { _totalDuration = duration; }); @@ -5515,7 +5567,9 @@ class _AudioPlayerWidgetState extends State<_AudioPlayerWidget> { ), ), Text( - widget.durationText, + _totalDuration.inMilliseconds > 0 + ? _formatDuration(_totalDuration) + : widget.durationText, style: TextStyle( color: widget.textColor.withOpacity( 0.7 * widget.messageTextOpacity,