починил плеер, добавил отступ для панели сообщений (ДИНАМИЧЕСКИЙ СУКА, ТОЛЬКО ПОПРЛБУЙТЕ ВОЗРАЗИТИТЬ)

This commit is contained in:
needle10
2025-12-01 22:35:13 +03:00
parent 4166f1c868
commit d84e91748a
4 changed files with 464 additions and 379 deletions

View File

@@ -60,8 +60,10 @@ class ChatScreen extends StatefulWidget {
final int chatId; final int chatId;
final Contact contact; final Contact contact;
final int myId; final int myId;
/// Колбэк для мягких обновлений списка чатов (например, после редактирования сообщения). /// Колбэк для мягких обновлений списка чатов (например, после редактирования сообщения).
final VoidCallback? onChatUpdated; final VoidCallback? onChatUpdated;
/// Колбэк, который вызывается, когда чат нужно убрать из списка (удаление / выход из группы). /// Колбэк, который вызывается, когда чат нужно убрать из списка (удаление / выход из группы).
final VoidCallback? onChatRemoved; final VoidCallback? onChatRemoved;
final bool isGroupChat; final bool isGroupChat;
@@ -363,8 +365,8 @@ class _ChatScreenState extends State<ChatScreen> {
if (contactProfile != null && if (contactProfile != null &&
contactProfile['id'] != null && contactProfile['id'] != null &&
contactProfile['id'] != 0) { contactProfile['id'] != 0) {
String? idStr = await prefs.getString("userId"); String? idStr = await prefs.getString("userId");
_actualMyId = idStr!.isNotEmpty ? int.parse(idStr) : contactProfile['id']; _actualMyId = idStr!.isNotEmpty ? int.parse(idStr) : contactProfile['id'];
print( print(
'✅ [_initializeChat] ID пользователя получен из ApiService: $_actualMyId', '✅ [_initializeChat] ID пользователя получен из ApiService: $_actualMyId',
); );
@@ -382,7 +384,8 @@ class _ChatScreenState extends State<ChatScreen> {
} }
} else if (_actualMyId == null) { } else if (_actualMyId == null) {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_actualMyId = int.parse(await prefs.getString('userId')!);; _actualMyId = int.parse(await prefs.getString('userId')!);
;
print( print(
'⚠️ [_initializeChat] ID не найден, используется из виджета: $_actualMyId', '⚠️ [_initializeChat] ID не найден, используется из виджета: $_actualMyId',
); );
@@ -1235,7 +1238,6 @@ class _ChatScreenState extends State<ChatScreen> {
} }
void _testSlideAnimation() { void _testSlideAnimation() {
final myMessage = Message( final myMessage = Message(
id: 'test_my_${DateTime.now().millisecondsSinceEpoch}', id: 'test_my_${DateTime.now().millisecondsSinceEpoch}',
text: 'Тест моё сообщение (должно выехать справа)', text: 'Тест моё сообщение (должно выехать справа)',
@@ -1789,8 +1791,9 @@ class _ChatScreenState extends State<ChatScreen> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Ошибка очистки истории: $e'), content: Text('Ошибка очистки истории: $e'),
backgroundColor: backgroundColor: Theme.of(
Theme.of(context).colorScheme.error, context,
).colorScheme.error,
), ),
); );
} }
@@ -1889,8 +1892,9 @@ class _ChatScreenState extends State<ChatScreen> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Ошибка удаления чата: $e'), content: Text('Ошибка удаления чата: $e'),
backgroundColor: backgroundColor: Theme.of(
Theme.of(context).colorScheme.error, context,
).colorScheme.error,
), ),
); );
} }
@@ -2522,7 +2526,10 @@ class _ChatScreenState extends State<ChatScreen> {
duration: const Duration(milliseconds: 100), duration: const Duration(milliseconds: 100),
curve: Curves.easeOutQuad, curve: Curves.easeOutQuad,
right: 16, right: 16,
bottom: MediaQuery.of(context).viewInsets.bottom + 100, bottom:
MediaQuery.of(context).viewInsets.bottom +
MediaQuery.of(context).padding.bottom +
100,
child: AnimatedScale( child: AnimatedScale(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
curve: Curves.easeOutBack, curve: Curves.easeOutBack,
@@ -2551,7 +2558,10 @@ class _ChatScreenState extends State<ChatScreen> {
curve: Curves.easeOutQuad, curve: Curves.easeOutQuad,
left: 8, left: 8,
right: 8, right: 8,
bottom: MediaQuery.of(context).viewInsets.bottom + 12, bottom:
MediaQuery.of(context).viewInsets.bottom +
MediaQuery.of(context).padding.bottom +
12,
child: _buildTextInput(), child: _buildTextInput(),
), ),
], ],
@@ -3140,8 +3150,8 @@ class _ChatScreenState extends State<ChatScreen> {
_replyingToMessage!.text.isNotEmpty _replyingToMessage!.text.isNotEmpty
? _replyingToMessage!.text ? _replyingToMessage!.text
: (_replyingToMessage!.hasFileAttach : (_replyingToMessage!.hasFileAttach
? 'Файл' ? 'Файл'
: 'Фото'), : 'Фото'),
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
color: Theme.of( color: Theme.of(
@@ -3508,8 +3518,8 @@ class _ChatScreenState extends State<ChatScreen> {
_replyingToMessage!.text.isNotEmpty _replyingToMessage!.text.isNotEmpty
? _replyingToMessage!.text ? _replyingToMessage!.text
: (_replyingToMessage!.hasFileAttach : (_replyingToMessage!.hasFileAttach
? 'Файл' ? 'Файл'
: 'Фото'), : 'Фото'),
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
color: Theme.of( color: Theme.of(

View File

@@ -40,6 +40,9 @@ class MusicTrack {
final token = attach['token'] as String?; final token = attach['token'] as String?;
final name = attach['name'] as String? ?? 'Unknown'; final name = attach['name'] as String? ?? 'Unknown';
final durationSeconds = preview?['duration'] as int?;
final duration = durationSeconds != null ? durationSeconds * 1000 : null;
return MusicTrack( return MusicTrack(
id: id:
fileId?.toString() ?? fileId?.toString() ??
@@ -48,7 +51,7 @@ class MusicTrack {
artist: preview?['artistName'] as String? ?? 'Unknown Artist', artist: preview?['artistName'] as String? ?? 'Unknown Artist',
album: preview?['albumName'] as String?, album: preview?['albumName'] as String?,
albumArtUrl: preview?['baseUrl'] as String?, albumArtUrl: preview?['baseUrl'] as String?,
duration: preview?['duration'] as int?, duration: duration,
fileId: fileId, fileId: fileId,
token: token, token: token,
); );
@@ -135,7 +138,7 @@ class MusicPlayerService extends ChangeNotifier {
_isLoading = _isLoading =
state.processingState == ProcessingState.loading || state.processingState == ProcessingState.loading ||
state.processingState == ProcessingState.buffering; state.processingState == ProcessingState.buffering;
// Detect track completion and auto-play next track // Detect track completion and auto-play next track
if (state.processingState == ProcessingState.completed && !wasCompleted) { if (state.processingState == ProcessingState.completed && !wasCompleted) {
_wasCompleted = true; _wasCompleted = true;
@@ -143,7 +146,7 @@ class MusicPlayerService extends ChangeNotifier {
} else if (state.processingState != ProcessingState.completed) { } else if (state.processingState != ProcessingState.completed) {
_wasCompleted = false; _wasCompleted = false;
} }
notifyListeners(); notifyListeners();
}); });
@@ -270,7 +273,7 @@ class MusicPlayerService extends ChangeNotifier {
Future<void> _autoPlayNext() async { Future<void> _autoPlayNext() async {
if (_playlist.isEmpty || _playlist.length <= 1) return; if (_playlist.isEmpty || _playlist.length <= 1) return;
try { try {
_currentIndex = (_currentIndex + 1) % _playlist.length; _currentIndex = (_currentIndex + 1) % _playlist.length;
await _loadAndPlayTrack(_playlist[_currentIndex]); await _loadAndPlayTrack(_playlist[_currentIndex]);

View File

@@ -43,7 +43,9 @@ class _BottomSheetMusicPlayerState extends State<BottomSheetMusicPlayer>
curve: Curves.easeInOut, curve: Curves.easeInOut,
); );
BottomSheetMusicPlayer.isExpandedNotifier.addListener(_onExpandedChanged); BottomSheetMusicPlayer.isExpandedNotifier.addListener(_onExpandedChanged);
BottomSheetMusicPlayer.isFullscreenNotifier.addListener(_onFullscreenChanged); BottomSheetMusicPlayer.isFullscreenNotifier.addListener(
_onFullscreenChanged,
);
} }
void _onExpandedChanged() { void _onExpandedChanged() {
@@ -63,7 +65,8 @@ class _BottomSheetMusicPlayerState extends State<BottomSheetMusicPlayer>
} }
void _onFullscreenChanged() { void _onFullscreenChanged() {
final shouldBeFullscreen = BottomSheetMusicPlayer.isFullscreenNotifier.value; final shouldBeFullscreen =
BottomSheetMusicPlayer.isFullscreenNotifier.value;
if (shouldBeFullscreen && _currentState != _PlayerState.fullscreen) { if (shouldBeFullscreen && _currentState != _PlayerState.fullscreen) {
setState(() { setState(() {
_currentState = _PlayerState.fullscreen; _currentState = _PlayerState.fullscreen;
@@ -164,7 +167,7 @@ class _BottomSheetMusicPlayerState extends State<BottomSheetMusicPlayer>
final collapsedHeight = 88.0; final collapsedHeight = 88.0;
final expandedHeight = screenHeight * 0.85; final expandedHeight = screenHeight * 0.85;
final fullscreenHeight = screenHeight; final fullscreenHeight = screenHeight;
double targetHeight; double targetHeight;
if (_currentState == _PlayerState.fullscreen) { if (_currentState == _PlayerState.fullscreen) {
targetHeight = fullscreenHeight; targetHeight = fullscreenHeight;
@@ -173,9 +176,10 @@ class _BottomSheetMusicPlayerState extends State<BottomSheetMusicPlayer>
} else { } else {
targetHeight = collapsedHeight; targetHeight = collapsedHeight;
} }
// Interpolate between collapsed and target height // Interpolate between collapsed and target height
final currentHeight = collapsedHeight + final currentHeight =
collapsedHeight +
(targetHeight - collapsedHeight) * _heightAnimation.value; (targetHeight - collapsedHeight) * _heightAnimation.value;
return Material( return Material(
@@ -251,15 +255,16 @@ class _BottomSheetMusicPlayerState extends State<BottomSheetMusicPlayer>
return FadeTransition( return FadeTransition(
opacity: animation, opacity: animation,
child: SlideTransition( child: SlideTransition(
position: Tween<Offset>( position:
begin: const Offset(0.0, 0.05), Tween<Offset>(
end: Offset.zero, begin: const Offset(0.0, 0.05),
).animate( end: Offset.zero,
CurvedAnimation( ).animate(
parent: animation, CurvedAnimation(
curve: Curves.easeOutCubic, parent: animation,
), curve: Curves.easeOutCubic,
), ),
),
child: child, child: child,
), ),
); );
@@ -285,7 +290,7 @@ class _BottomSheetMusicPlayerState extends State<BottomSheetMusicPlayer>
onTap: _toggleExpand, onTap: _toggleExpand,
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row( child: Row(
children: [ children: [
Hero( Hero(
@@ -300,10 +305,13 @@ class _BottomSheetMusicPlayerState extends State<BottomSheetMusicPlayer>
? Image.network( ? Image.network(
track.albumArtUrl!, track.albumArtUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) { loadingBuilder:
if (loadingProgress == null) return child; (context, child, loadingProgress) {
return _buildAlbumArtPlaceholder(colorScheme); if (loadingProgress == null) return child;
}, return _buildAlbumArtPlaceholder(
colorScheme,
);
},
errorBuilder: (context, error, stackTrace) => errorBuilder: (context, error, stackTrace) =>
_buildAlbumArtPlaceholder(colorScheme), _buildAlbumArtPlaceholder(colorScheme),
) )
@@ -318,24 +326,30 @@ class _BottomSheetMusicPlayerState extends State<BottomSheetMusicPlayer>
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Flexible(
track.title, child: Text(
style: Theme.of(context).textTheme.titleMedium?.copyWith( track.title,
fontWeight: FontWeight.w600, style: Theme.of(context).textTheme.titleMedium
letterSpacing: -0.2, ?.copyWith(
), fontWeight: FontWeight.w600,
maxLines: 1, letterSpacing: -0.2,
overflow: TextOverflow.ellipsis, ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
), ),
const SizedBox(height: 6), const SizedBox(height: 3),
Text( Flexible(
track.artist, child: Text(
style: Theme.of(context).textTheme.bodyMedium?.copyWith( track.artist,
color: colorScheme.onSurface.withOpacity(0.65), style: Theme.of(context).textTheme.bodyMedium
fontSize: 13, ?.copyWith(
), color: colorScheme.onSurface.withOpacity(0.65),
maxLines: 1, fontSize: 13,
overflow: TextOverflow.ellipsis, ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
), ),
], ],
), ),
@@ -371,7 +385,9 @@ class _BottomSheetMusicPlayerState extends State<BottomSheetMusicPlayer>
), ),
) )
: Icon( : Icon(
musicPlayer.isPlaying ? Icons.pause : Icons.play_arrow, musicPlayer.isPlaying
? Icons.pause
: Icons.play_arrow,
size: 26, size: 26,
color: colorScheme.onPrimary, color: colorScheme.onPrimary,
), ),
@@ -420,44 +436,44 @@ class _BottomSheetMusicPlayerState extends State<BottomSheetMusicPlayer>
), ),
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24), padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const SizedBox(height: 8), const SizedBox(height: 8),
// Album art with hero animation // Album art with hero animation
LayoutBuilder( LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final maxWidth = constraints.maxWidth; final maxWidth = constraints.maxWidth;
final albumSize = maxWidth < 380 ? maxWidth : 380.0; final albumSize = maxWidth < 380 ? maxWidth : 380.0;
return Hero( return Hero(
tag: 'album-art-${track.id}', tag: 'album-art-${track.id}',
child: Container( child: Container(
width: albumSize, width: albumSize,
height: albumSize, height: albumSize,
margin: EdgeInsets.symmetric( margin: EdgeInsets.symmetric(
horizontal: (maxWidth - albumSize) / 2, 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: [ child: ClipRRect(
BoxShadow( borderRadius: BorderRadius.circular(32),
color: Colors.black.withOpacity(0.25), child: track.albumArtUrl != null
blurRadius: 30, ? Image.network(
offset: const Offset(0, 12), track.albumArtUrl!,
spreadRadius: 2, 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) { if (loadingProgress == null) {
return child; return child;
} }
@@ -466,228 +482,230 @@ class _BottomSheetMusicPlayerState extends State<BottomSheetMusicPlayer>
colorScheme, colorScheme,
); );
}, },
errorBuilder: errorBuilder:
(context, error, stackTrace) => (context, error, stackTrace) =>
_buildLargeAlbumArtPlaceholder( _buildLargeAlbumArtPlaceholder(
context, context,
colorScheme, colorScheme,
), ),
) )
: _buildLargeAlbumArtPlaceholder( : _buildLargeAlbumArtPlaceholder(
context, context,
colorScheme, 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,
), ),
], );
const SizedBox(height: 40), },
// Progress slider ),
Column( const SizedBox(height: 32),
children: [ // Track info
SliderTheme( Text(
data: SliderTheme.of(context).copyWith( track.title,
activeTrackColor: colorScheme.primary, style: Theme.of(context).textTheme.headlineMedium?.copyWith(
inactiveTrackColor: fontWeight: FontWeight.bold,
colorScheme.surfaceContainerHigh, letterSpacing: -0.5,
thumbColor: colorScheme.primary, ),
overlayColor: textAlign: TextAlign.center,
colorScheme.primary.withOpacity(0.1), maxLines: 2,
thumbShape: const RoundSliderThumbShape( overflow: TextOverflow.ellipsis,
enabledThumbRadius: 8, ),
), const SizedBox(height: 12),
trackHeight: 4, Text(
), track.artist,
child: Slider( style: Theme.of(context).textTheme.titleLarge?.copyWith(
value: musicPlayer.duration.inMilliseconds > 0 color: colorScheme.onSurface.withOpacity(0.75),
? (musicPlayer.position.inMilliseconds / fontWeight: FontWeight.w500,
musicPlayer.duration.inMilliseconds) ),
.clamp(0.0, 1.0) textAlign: TextAlign.center,
: 0.0, maxLines: 1,
onChanged: (value) { overflow: TextOverflow.ellipsis,
HapticFeedback.selectionClick(); ),
final newPosition = Duration( if (track.album != null && track.album!.isNotEmpty) ...[
milliseconds: (value * const SizedBox(height: 8),
musicPlayer.duration.inMilliseconds) 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(), .round(),
); );
musicPlayer.seek(newPosition); 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,
),
),
],
),
),
],
), ),
const SizedBox(height: 16), Padding(
// Control buttons padding: const EdgeInsets.symmetric(horizontal: 4),
Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Material( Text(
color: Colors.transparent, _formatDuration(musicPlayer.position),
child: InkWell( style: Theme.of(context).textTheme.bodyMedium
onTap: () { ?.copyWith(
HapticFeedback.selectionClick(); color: colorScheme.onSurface.withOpacity(
musicPlayer.previous(); 0.7,
}, ),
borderRadius: BorderRadius.circular(28), fontWeight: FontWeight.w500,
child: Container( fontSize: 13,
width: 56, ),
height: 56,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
shape: BoxShape.circle,
),
child: Icon(
Icons.skip_previous_rounded,
size: 28,
color: colorScheme.onSurface,
),
),
), ),
), Text(
const SizedBox(width: 24), _formatDuration(musicPlayer.duration),
Material( style: Theme.of(context).textTheme.bodyMedium
color: Colors.transparent, ?.copyWith(
child: InkWell( color: colorScheme.onSurface.withOpacity(
onTap: () { 0.7,
HapticFeedback.mediumImpact(); ),
if (musicPlayer.isPlaying) { fontWeight: FontWeight.w500,
musicPlayer.pause(); fontSize: 13,
} 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<Color>(
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,
), ),
], ],
), ),
), 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<Color>(
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),
],
),
),
), ),
], ],
), ),

View File

@@ -30,8 +30,9 @@ import 'package:platform_info/platform_info.dart';
bool _currentIsDark = false; bool _currentIsDark = false;
bool isMobile = Platform.instance.operatingSystem.iOS || bool isMobile =
Platform.instance.operatingSystem.android; Platform.instance.operatingSystem.iOS ||
Platform.instance.operatingSystem.android;
enum MessageReadStatus { enum MessageReadStatus {
sending, // Отправляется (часы) sending, // Отправляется (часы)
@@ -1044,9 +1045,7 @@ class ChatMessageBubble extends StatelessWidget {
// чтобы визуально не было "бабла" вокруг карточек файлов. // чтобы визуально не было "бабла" вокруг карточек файлов.
BoxDecoration bubbleDecoration; BoxDecoration bubbleDecoration;
if (isFileOnly) { if (isFileOnly) {
bubbleDecoration = const BoxDecoration( bubbleDecoration = const BoxDecoration(color: Colors.transparent);
color: Colors.transparent,
);
} else { } else {
bubbleDecoration = _createBubbleDecoration( bubbleDecoration = _createBubbleDecoration(
bubbleColor, bubbleColor,
@@ -1112,20 +1111,19 @@ class ChatMessageBubble extends StatelessWidget {
if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) { if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) {
if (isMobile) { if (isMobile) {
messageContent = GestureDetector( messageContent = GestureDetector(
onTapDown: (TapDownDetails details) { onTapDown: (TapDownDetails details) {
_showMessageContextMenu(context, details.globalPosition); _showMessageContextMenu(context, details.globalPosition);
}, },
child: messageContent, child: messageContent,
); );
} else { } else {
messageContent = GestureDetector( messageContent = GestureDetector(
onSecondaryTapDown: (TapDownDetails details) { onSecondaryTapDown: (TapDownDetails details) {
_showMessageContextMenu(context, details.globalPosition); _showMessageContextMenu(context, details.globalPosition);
}, },
child: messageContent, child: messageContent,
); );
} }
} }
return Column( return Column(
@@ -1576,18 +1574,18 @@ class ChatMessageBubble extends StatelessWidget {
if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) { if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) {
if (isMobile) { if (isMobile) {
videoContent = GestureDetector( videoContent = GestureDetector(
onTapDown: (TapDownDetails details) { onTapDown: (TapDownDetails details) {
_showMessageContextMenu(context, details.globalPosition); _showMessageContextMenu(context, details.globalPosition);
}, },
child: videoContent, child: videoContent,
); );
} else { } else {
videoContent = GestureDetector( videoContent = GestureDetector(
onSecondaryTapDown: (TapDownDetails details) { onSecondaryTapDown: (TapDownDetails details) {
_showMessageContextMenu(context, details.globalPosition); _showMessageContextMenu(context, details.globalPosition);
}, },
child: videoContent, child: videoContent,
); );
} }
} }
@@ -1671,11 +1669,11 @@ class ChatMessageBubble extends StatelessWidget {
if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) { if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) {
if (isMobile) { if (isMobile) {
photoContent = GestureDetector( photoContent = GestureDetector(
onTapDown: (TapDownDetails details) { onTapDown: (TapDownDetails details) {
_showMessageContextMenu(context, details.globalPosition); _showMessageContextMenu(context, details.globalPosition);
}, },
child: photoContent, child: photoContent,
); );
} else { } else {
photoContent = GestureDetector( photoContent = GestureDetector(
onTapDown: (TapDownDetails details) { onTapDown: (TapDownDetails details) {
@@ -1683,7 +1681,7 @@ class ChatMessageBubble extends StatelessWidget {
}, },
child: photoContent, child: photoContent,
); );
} }
} }
return photoContent; return photoContent;
@@ -1808,11 +1806,11 @@ class ChatMessageBubble extends StatelessWidget {
if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) { if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) {
if (isMobile) { if (isMobile) {
videoContent = GestureDetector( videoContent = GestureDetector(
onTapDown: (TapDownDetails details) { onTapDown: (TapDownDetails details) {
_showMessageContextMenu(context, details.globalPosition); _showMessageContextMenu(context, details.globalPosition);
}, },
child: videoContent, child: videoContent,
); );
} else { } else {
videoContent = GestureDetector( videoContent = GestureDetector(
onSecondaryTapDown: (TapDownDetails details) { onSecondaryTapDown: (TapDownDetails details) {
@@ -2406,11 +2404,11 @@ class ChatMessageBubble extends StatelessWidget {
final artist = preview?['artistName'] as String? ?? 'Unknown Artist'; final artist = preview?['artistName'] as String? ?? 'Unknown Artist';
final album = preview?['albumName'] as String?; final album = preview?['albumName'] as String?;
final albumArtUrl = preview?['baseUrl'] 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 = ''; String durationText = '';
if (duration != null) { if (durationSeconds != null) {
final durationSeconds = (duration / 1000).round();
final minutes = durationSeconds ~/ 60; final minutes = durationSeconds ~/ 60;
final seconds = durationSeconds % 60; final seconds = durationSeconds % 60;
durationText = '$minutes:${seconds.toString().padLeft(2, '0')}'; durationText = '$minutes:${seconds.toString().padLeft(2, '0')}';
@@ -2441,7 +2439,14 @@ class ChatMessageBubble extends StatelessWidget {
} }
if (!isDownloaded) { 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)); await Future.delayed(const Duration(seconds: 1));
if (fileIdString != null) { if (fileIdString != null) {
final updatedFileIdMap = final updatedFileIdMap =
@@ -2708,6 +2713,7 @@ class ChatMessageBubble extends StatelessWidget {
token, token,
fileName, fileName,
chatId, chatId,
preview: preview,
); );
await Future.delayed(const Duration(seconds: 1)); await Future.delayed(const Duration(seconds: 1));
final updatedFileIdMap = final updatedFileIdMap =
@@ -2906,8 +2912,9 @@ class ChatMessageBubble extends StatelessWidget {
int? fileId, int? fileId,
String? token, String? token,
String fileName, String fileName,
int? chatId, int? chatId, {
) async { Map<String, dynamic>? preview,
}) async {
// 1. Проверяем fileId, он нужен в любом случае // 1. Проверяем fileId, он нужен в любом случае
if (fileId == null) { if (fileId == null) {
if (context.mounted) { if (context.mounted) {
@@ -3048,7 +3055,16 @@ class ChatMessageBubble extends StatelessWidget {
} }
// Download file to Downloads folder with progress // 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) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -3065,10 +3081,23 @@ class ChatMessageBubble extends StatelessWidget {
String url, String url,
String fileName, String fileName,
String fileId, String fileId,
BuildContext context, BuildContext context, {
) async { Map<String, dynamic>? preview,
int? fileIdInt,
String? token,
int? chatId,
}) async {
// Download in background without blocking dialog // 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 // Show immediate success snackbar
if (context.mounted) { if (context.mounted) {
@@ -3085,8 +3114,12 @@ class ChatMessageBubble extends StatelessWidget {
String url, String url,
String fileName, String fileName,
String fileId, String fileId,
BuildContext context, BuildContext context, {
) async { Map<String, dynamic>? preview,
int? fileIdInt,
String? token,
int? chatId,
}) async {
// Initialize progress // Initialize progress
FileDownloadProgressService().updateProgress(fileId, 0.0); FileDownloadProgressService().updateProgress(fileId, 0.0);
@@ -3167,6 +3200,40 @@ class ChatMessageBubble extends StatelessWidget {
await prefs.setStringList('file_id_to_path_map', fileIdMap); 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<String, dynamic>;
musicMetadata[fileId] = track.toJson();
await prefs.setString('music_metadata', jsonEncode(musicMetadata));
}
}
// Show success message // Show success message
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -3780,18 +3847,14 @@ class ChatMessageBubble extends StatelessWidget {
Linkify( Linkify(
text: text:
'Привет! Это твои избранные. Все написанное сюда попадёт прямиком к дяде Майору.', 'Привет! Это твои избранные. Все написанное сюда попадёт прямиком к дяде Майору.',
style: style: TextStyle(color: textColor, fontStyle: FontStyle.italic),
TextStyle(color: textColor, fontStyle: FontStyle.italic),
linkStyle: linkStyle, linkStyle: linkStyle,
onOpen: onOpenLink, onOpen: onOpenLink,
options: const LinkifyOptions(humanize: false), options: const LinkifyOptions(humanize: false),
textAlign: TextAlign.left, textAlign: TextAlign.left,
) )
else if (message.text.contains("komet.color_")) else if (message.text.contains("komet.color_"))
_buildKometColorRichText( _buildKometColorRichText(message.text, defaultTextStyle)
message.text,
defaultTextStyle,
)
else else
Linkify( Linkify(
text: message.text, text: message.text,
@@ -3921,10 +3984,7 @@ class ChatMessageBubble extends StatelessWidget {
/// Строит раскрашенный текст на основе синтаксиса komet.color_#HEX'текст'. /// Строит раскрашенный текст на основе синтаксиса komet.color_#HEX'текст'.
/// Если цвет некорректный, используется красный. /// Если цвет некорректный, используется красный.
Widget _buildKometColorRichText( Widget _buildKometColorRichText(String rawText, TextStyle baseStyle) {
String rawText,
TextStyle baseStyle,
) {
final segments = _parseKometColorSegments(rawText, baseStyle.color); final segments = _parseKometColorSegments(rawText, baseStyle.color);
return RichText( return RichText(
@@ -3956,25 +4016,19 @@ class ChatMessageBubble extends StatelessWidget {
while (index < text.length) { while (index < text.length) {
final start = text.indexOf(marker, index); final start = text.indexOf(marker, index);
if (start == -1) { if (start == -1) {
segments.add( segments.add(_KometColoredSegment(text.substring(index), null));
_KometColoredSegment(text.substring(index), null),
);
break; break;
} }
if (start > index) { if (start > index) {
segments.add( segments.add(_KometColoredSegment(text.substring(index, start), null));
_KometColoredSegment(text.substring(index, start), null),
);
} }
final colorStart = start + marker.length; final colorStart = start + marker.length;
final firstQuote = text.indexOf("'", colorStart); final firstQuote = text.indexOf("'", colorStart);
if (firstQuote == -1) { if (firstQuote == -1) {
// Кривой синтаксис — считаем всё остальное обычным текстом. // Кривой синтаксис — считаем всё остальное обычным текстом.
segments.add( segments.add(_KometColoredSegment(text.substring(start), null));
_KometColoredSegment(text.substring(start), null),
);
break; break;
} }
@@ -3982,9 +4036,7 @@ class ChatMessageBubble extends StatelessWidget {
final textStart = firstQuote + 1; final textStart = firstQuote + 1;
final secondQuote = text.indexOf("'", textStart); final secondQuote = text.indexOf("'", textStart);
if (secondQuote == -1) { if (secondQuote == -1) {
segments.add( segments.add(_KometColoredSegment(text.substring(start), null));
_KometColoredSegment(text.substring(start), null),
);
break; break;
} }
@@ -5254,7 +5306,7 @@ class _AudioPlayerWidgetState extends State<_AudioPlayerWidget> {
}); });
_audioPlayer.durationStream.listen((duration) { _audioPlayer.durationStream.listen((duration) {
if (mounted && duration != null) { if (mounted && duration != null && duration.inMilliseconds > 0) {
setState(() { setState(() {
_totalDuration = duration; _totalDuration = duration;
}); });
@@ -5515,7 +5567,9 @@ class _AudioPlayerWidgetState extends State<_AudioPlayerWidget> {
), ),
), ),
Text( Text(
widget.durationText, _totalDuration.inMilliseconds > 0
? _formatDuration(_totalDuration)
: widget.durationText,
style: TextStyle( style: TextStyle(
color: widget.textColor.withOpacity( color: widget.textColor.withOpacity(
0.7 * widget.messageTextOpacity, 0.7 * widget.messageTextOpacity,