починил плеер, добавил отступ для панели сообщений (ДИНАМИЧЕСКИЙ СУКА, ТОЛЬКО ПОПРЛБУЙТЕ ВОЗРАЗИТИТЬ)
This commit is contained in:
@@ -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<ChatScreen> {
|
||||
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<ChatScreen> {
|
||||
}
|
||||
} 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<ChatScreen> {
|
||||
}
|
||||
|
||||
void _testSlideAnimation() {
|
||||
|
||||
final myMessage = Message(
|
||||
id: 'test_my_${DateTime.now().millisecondsSinceEpoch}',
|
||||
text: 'Тест моё сообщение (должно выехать справа)',
|
||||
@@ -1789,8 +1791,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
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<ChatScreen> {
|
||||
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<ChatScreen> {
|
||||
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<ChatScreen> {
|
||||
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<ChatScreen> {
|
||||
_replyingToMessage!.text.isNotEmpty
|
||||
? _replyingToMessage!.text
|
||||
: (_replyingToMessage!.hasFileAttach
|
||||
? 'Файл'
|
||||
: 'Фото'),
|
||||
? 'Файл'
|
||||
: 'Фото'),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(
|
||||
@@ -3508,8 +3518,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
_replyingToMessage!.text.isNotEmpty
|
||||
? _replyingToMessage!.text
|
||||
: (_replyingToMessage!.hasFileAttach
|
||||
? 'Файл'
|
||||
: 'Фото'),
|
||||
? 'Файл'
|
||||
: 'Фото'),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(
|
||||
|
||||
@@ -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<void> _autoPlayNext() async {
|
||||
if (_playlist.isEmpty || _playlist.length <= 1) return;
|
||||
|
||||
|
||||
try {
|
||||
_currentIndex = (_currentIndex + 1) % _playlist.length;
|
||||
await _loadAndPlayTrack(_playlist[_currentIndex]);
|
||||
|
||||
@@ -43,7 +43,9 @@ class _BottomSheetMusicPlayerState extends State<BottomSheetMusicPlayer>
|
||||
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<BottomSheetMusicPlayer>
|
||||
}
|
||||
|
||||
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<BottomSheetMusicPlayer>
|
||||
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<BottomSheetMusicPlayer>
|
||||
} 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<BottomSheetMusicPlayer>
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0.0, 0.05),
|
||||
end: Offset.zero,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
),
|
||||
position:
|
||||
Tween<Offset>(
|
||||
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<BottomSheetMusicPlayer>
|
||||
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<BottomSheetMusicPlayer>
|
||||
? 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<BottomSheetMusicPlayer>
|
||||
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<BottomSheetMusicPlayer>
|
||||
),
|
||||
)
|
||||
: 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<BottomSheetMusicPlayer>
|
||||
),
|
||||
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<BottomSheetMusicPlayer>
|
||||
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<Color>(
|
||||
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<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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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<String, dynamic>? 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<String, dynamic>? 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<String, dynamic>? 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<String, dynamic>;
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user