ебаное медиа оно меня заебало
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import '../api.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../models/attachment.dart';
|
||||
|
||||
class CachedMessage {
|
||||
final String id;
|
||||
@@ -12,6 +14,7 @@ class CachedMessage {
|
||||
final int time;
|
||||
final String? status;
|
||||
final Map<String, dynamic>? payload;
|
||||
final List<MessageAttachment>? attachments;
|
||||
|
||||
const CachedMessage({
|
||||
required this.id,
|
||||
@@ -22,6 +25,7 @@ class CachedMessage {
|
||||
required this.time,
|
||||
this.status,
|
||||
this.payload,
|
||||
this.attachments,
|
||||
});
|
||||
|
||||
factory CachedMessage.fromDbRow(Map<String, dynamic> row) {
|
||||
@@ -33,6 +37,16 @@ class CachedMessage {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
List<MessageAttachment>? attachments;
|
||||
if (payload != null) {
|
||||
final attaches = payload['attaches'] as List?;
|
||||
if (attaches != null) {
|
||||
attachments = attaches
|
||||
.map((a) => MessageAttachment.fromMap(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
return CachedMessage(
|
||||
id: row['id'] as String,
|
||||
accountId: row['account_id'] as int,
|
||||
@@ -42,6 +56,7 @@ class CachedMessage {
|
||||
time: row['time'] as int,
|
||||
status: row['status'] as String?,
|
||||
payload: payload,
|
||||
attachments: attachments,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,6 +158,15 @@ class MessagesModule {
|
||||
final id = m['id']?.toString();
|
||||
if (id == null) return null;
|
||||
|
||||
final attaches = m['attaches'] as List?;
|
||||
List<MessageAttachment>? attachments;
|
||||
if (attaches != null) {
|
||||
attachments = attaches
|
||||
.whereType<Map>()
|
||||
.map((a) => MessageAttachment.fromMap(a.cast<String, dynamic>()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
return CachedMessage(
|
||||
id: id,
|
||||
accountId: accountId,
|
||||
@@ -152,6 +176,7 @@ class MessagesModule {
|
||||
time: (m['time'] as int?) ?? 0,
|
||||
status: m['status'] as String?,
|
||||
payload: m.cast<String, dynamic>(),
|
||||
attachments: attachments,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,4 +185,118 @@ class MessagesModule {
|
||||
|
||||
await _api.sendRequest(Opcode.msgSend, payload);
|
||||
}
|
||||
|
||||
Future<Uint8List?> downloadPhoto(String baseUrl, String photoToken) async {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||
'url': baseUrl,
|
||||
'token': photoToken,
|
||||
});
|
||||
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is! Map) return null;
|
||||
|
||||
final content = data['content'];
|
||||
if (content is String) {
|
||||
return Uri.parse(content).host.isNotEmpty ? null : null;
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getPhotoUrl(String baseUrl, String photoToken) async {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||
'url': baseUrl,
|
||||
'token': photoToken,
|
||||
});
|
||||
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is! Map) return null;
|
||||
|
||||
return data['content'] as String?;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List?> downloadVideo(String baseUrl, String videoToken) async {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||
'url': baseUrl,
|
||||
'token': videoToken,
|
||||
});
|
||||
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is! Map) return null;
|
||||
|
||||
final content = data['content'];
|
||||
if (content is String) {
|
||||
return Uri.parse(content).host.isNotEmpty ? null : null;
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getVideoUrl(String baseUrl, String videoToken) async {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||
'url': baseUrl,
|
||||
'token': videoToken,
|
||||
});
|
||||
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is! Map) return null;
|
||||
|
||||
return data['content'] as String?;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List?> downloadFile(String baseUrl, String fileToken) async {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||
'url': baseUrl,
|
||||
'token': fileToken,
|
||||
});
|
||||
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is! Map) return null;
|
||||
|
||||
final content = data['content'];
|
||||
if (content is String) {
|
||||
return Uri.parse(content).host.isNotEmpty ? null : null;
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getFileUrl(String baseUrl, String fileToken) async {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||
'url': baseUrl,
|
||||
'token': fileToken,
|
||||
});
|
||||
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is! Map) return null;
|
||||
|
||||
return data['content'] as String?;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../backend/modules/messages.dart';
|
||||
import '../../models/attachment.dart';
|
||||
|
||||
enum MessageType { text, attachment, voice }
|
||||
|
||||
enum BubbleShape { singleTop, singleBottom, singleMiddle, groupedMiddle }
|
||||
|
||||
class MessageBubble extends StatelessWidget {
|
||||
static const double photoMaxSize = 280.0;
|
||||
static const double photoMinSize = 100.0;
|
||||
static const double photoBorderRadius = 12.0;
|
||||
static const double bubbleBorderRadius = 20.0;
|
||||
static const double captionPaddingHorizontal = 6.0;
|
||||
static const double captionPaddingRight = 4.0;
|
||||
static const double compactTimePadding = 8.0;
|
||||
|
||||
bool get _hasPhotoWithCaption {
|
||||
if (message.attachments == null || message.attachments!.isEmpty)
|
||||
return false;
|
||||
final hasPhoto = message.attachments!.any((a) => a is PhotoAttachment);
|
||||
final hasCaption = message.text != null && message.text!.isNotEmpty;
|
||||
debugPrint(
|
||||
'DEBUG _hasPhotoWithCaption: hasPhoto=$hasPhoto, hasCaption=$hasCaption, text="${message.text}", shape=$shape',
|
||||
);
|
||||
return hasPhoto && hasCaption;
|
||||
}
|
||||
|
||||
bool get _hasMultiplePhotosNoCaption {
|
||||
if (message.attachments == null || message.attachments!.isEmpty)
|
||||
return false;
|
||||
final photoCount = message.attachments!.whereType<PhotoAttachment>().length;
|
||||
final hasCaption = message.text != null && message.text!.isNotEmpty;
|
||||
return photoCount >= 2 && !hasCaption;
|
||||
}
|
||||
|
||||
final CachedMessage message;
|
||||
final bool isMe;
|
||||
final int myId;
|
||||
@@ -51,13 +79,15 @@ class MessageBubble extends StatelessWidget {
|
||||
}
|
||||
|
||||
MessageType get contentType {
|
||||
if (message.attachments != null && message.attachments!.isNotEmpty) {
|
||||
final first = message.attachments!.first;
|
||||
if (first.type == AttachmentType.audio) return MessageType.voice;
|
||||
return MessageType.attachment;
|
||||
}
|
||||
|
||||
final payload = message.payload;
|
||||
if (payload == null) return MessageType.text;
|
||||
|
||||
final attachments = payload['attachments'];
|
||||
if (attachments is List && attachments.isNotEmpty)
|
||||
return MessageType.attachment;
|
||||
|
||||
final voice = payload['voice'];
|
||||
if (voice != null) return MessageType.voice;
|
||||
|
||||
@@ -66,34 +96,61 @@ class MessageBubble extends StatelessWidget {
|
||||
|
||||
// скругление уже смешариков т.е сообщений, те которые isme ? .. Это наши, после : это чужие
|
||||
BorderRadius get _borderRadius {
|
||||
final topRadius = Radius.circular(bubbleBorderRadius);
|
||||
final bottomRadius = Radius.circular(bubbleBorderRadius);
|
||||
final smallRadius = const Radius.circular(4);
|
||||
|
||||
if (_hasPhotoWithCaption &&
|
||||
(shape == BubbleShape.singleTop ||
|
||||
shape == BubbleShape.singleMiddle ||
|
||||
shape == BubbleShape.singleBottom)) {
|
||||
return BorderRadius.only(
|
||||
topLeft: topRadius,
|
||||
topRight: isMe ? topRadius : topRadius,
|
||||
bottomLeft: smallRadius,
|
||||
bottomRight: smallRadius,
|
||||
);
|
||||
}
|
||||
|
||||
if (_hasMultiplePhotosNoCaption &&
|
||||
(shape == BubbleShape.singleBottom ||
|
||||
shape == BubbleShape.singleMiddle)) {
|
||||
return BorderRadius.only(
|
||||
topLeft: smallRadius,
|
||||
topRight: smallRadius,
|
||||
bottomLeft: isMe ? smallRadius : smallRadius,
|
||||
bottomRight: isMe ? smallRadius : bottomRadius,
|
||||
);
|
||||
}
|
||||
|
||||
switch (shape) {
|
||||
case BubbleShape.singleTop:
|
||||
return BorderRadius.only(
|
||||
topLeft: isMe ? Radius.circular(20) : Radius.circular(4),
|
||||
topRight: isMe ? Radius.circular(4) : Radius.circular(20),
|
||||
bottomLeft: isMe ? Radius.circular(4) : Radius.circular(4),
|
||||
bottomRight: isMe ? Radius.circular(4) : Radius.circular(4),
|
||||
topLeft: isMe ? topRadius : smallRadius,
|
||||
topRight: isMe ? smallRadius : topRadius,
|
||||
bottomLeft: smallRadius,
|
||||
bottomRight: smallRadius,
|
||||
);
|
||||
case BubbleShape.singleBottom:
|
||||
return BorderRadius.only(
|
||||
topLeft: isMe ? Radius.circular(4) : Radius.circular(4),
|
||||
topRight: isMe ? Radius.circular(4) : Radius.circular(4),
|
||||
bottomLeft: isMe ? Radius.circular(20) : Radius.circular(4),
|
||||
bottomRight: isMe ? Radius.circular(4) : Radius.circular(20),
|
||||
topLeft: smallRadius,
|
||||
topRight: smallRadius,
|
||||
bottomLeft: isMe ? topRadius : smallRadius,
|
||||
bottomRight: isMe ? smallRadius : topRadius,
|
||||
);
|
||||
case BubbleShape.singleMiddle:
|
||||
return BorderRadius.only(
|
||||
topLeft: isMe ? Radius.circular(20) : Radius.circular(20),
|
||||
topRight: isMe ? Radius.circular(20) : Radius.circular(20),
|
||||
bottomLeft: isMe ? Radius.circular(20) : Radius.circular(4),
|
||||
bottomRight: isMe ? Radius.circular(4) : Radius.circular(20),
|
||||
topLeft: topRadius,
|
||||
topRight: topRadius,
|
||||
bottomLeft: isMe ? topRadius : smallRadius,
|
||||
bottomRight: isMe ? smallRadius : topRadius,
|
||||
);
|
||||
case BubbleShape.groupedMiddle:
|
||||
return BorderRadius.only(
|
||||
topLeft: isMe ? Radius.circular(20) : Radius.circular(4),
|
||||
topRight: isMe ? Radius.circular(4) : Radius.circular(4),
|
||||
bottomLeft: isMe ? Radius.circular(20) : Radius.circular(4),
|
||||
bottomRight: isMe ? Radius.circular(4) : Radius.circular(4),
|
||||
topLeft: isMe ? topRadius : smallRadius,
|
||||
topRight: isMe ? smallRadius : smallRadius,
|
||||
bottomLeft: isMe ? topRadius : smallRadius,
|
||||
bottomRight: isMe ? smallRadius : smallRadius,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -288,7 +345,7 @@ class MessageBubble extends StatelessWidget {
|
||||
_formatTime(message.time),
|
||||
style: TextStyle(
|
||||
color: textColor.withValues(alpha: 0.7),
|
||||
fontSize: 11,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -298,157 +355,393 @@ class MessageBubble extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildAttachmentContent(BuildContext context) {
|
||||
final payload = message.payload;
|
||||
final attachments = payload?['attachments'] as List?;
|
||||
final attachments = message.attachments;
|
||||
if (attachments == null || attachments.isEmpty) {
|
||||
return _buildTextContent(context);
|
||||
}
|
||||
|
||||
final attachment = attachments.first as Map<String, dynamic>?;
|
||||
if (attachment == null) return _buildTextContent(context);
|
||||
|
||||
final type = attachment['type'] as String?;
|
||||
final url = attachment['url'] ?? attachment['path'];
|
||||
final thumbnail = attachment['thumbnail'];
|
||||
|
||||
if (type == 'image' || type == 'photo') {
|
||||
return _buildImageAttachment(context, url, thumbnail);
|
||||
} else if (type == 'video') {
|
||||
return _buildVideoAttachment(context, url, attachment['duration']);
|
||||
} else if (type == 'file') {
|
||||
return _buildFileAttachment(context, attachment['name'] ?? 'File', url);
|
||||
final photos = attachments.whereType<PhotoAttachment>().toList();
|
||||
if (photos.isEmpty) {
|
||||
return _buildGenericAttachment(context, attachments.first);
|
||||
}
|
||||
|
||||
return _buildTextContent(context);
|
||||
return _buildPhotoContent(context, photos);
|
||||
}
|
||||
|
||||
Widget _buildImageAttachment(
|
||||
BuildContext ctx,
|
||||
dynamic url,
|
||||
dynamic thumbnail,
|
||||
) {
|
||||
final cs = Theme.of(ctx).colorScheme;
|
||||
final imageUrl = url?.toString() ?? '';
|
||||
final thumbUrl = thumbnail?.toString() ?? '';
|
||||
Widget _buildPhotoContent(BuildContext ctx, List<PhotoAttachment> photos) {
|
||||
final hasCaption = message.text != null && message.text!.isNotEmpty;
|
||||
final count = photos.length;
|
||||
|
||||
Widget photosWidget;
|
||||
if (count == 1) {
|
||||
photosWidget = _buildSinglePhoto(ctx, photos[0]);
|
||||
} else if (count == 2) {
|
||||
photosWidget = _buildTwoPhotos(ctx, photos[0], photos[1]);
|
||||
} else {
|
||||
photosWidget = _buildPhotoGrid(ctx, photos);
|
||||
}
|
||||
|
||||
if (!hasCaption) {
|
||||
return Stack(
|
||||
children: [
|
||||
photosWidget,
|
||||
Positioned(
|
||||
bottom: compactTimePadding,
|
||||
right: compactTimePadding,
|
||||
child: _buildCompactTime(ctx),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (count == 1) {
|
||||
return IntrinsicWidth(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
photosWidget,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: captionPaddingHorizontal,
|
||||
right: captionPaddingRight,
|
||||
bottom: 6,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(child: _buildCaption(ctx)),
|
||||
_buildMeta(ctx),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Stack(
|
||||
photosWidget,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: captionPaddingHorizontal,
|
||||
right: captionPaddingRight,
|
||||
bottom: 6,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (thumbUrl.isNotEmpty)
|
||||
Image.network(
|
||||
thumbUrl,
|
||||
width: 200,
|
||||
height: 200,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _buildImagePlaceholder(cs),
|
||||
)
|
||||
else if (imageUrl.isNotEmpty)
|
||||
Image.network(
|
||||
imageUrl,
|
||||
width: 200,
|
||||
height: 200,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _buildImagePlaceholder(cs),
|
||||
)
|
||||
else
|
||||
_buildImagePlaceholder(cs),
|
||||
if (imageUrl.isNotEmpty)
|
||||
Positioned.fill(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(onTap: () {}),
|
||||
),
|
||||
),
|
||||
Expanded(child: _buildCaption(ctx)),
|
||||
_buildMeta(ctx),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_buildMeta(ctx),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImagePlaceholder(ColorScheme cs) {
|
||||
return Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
color: cs.surfaceContainerHighest,
|
||||
child: Icon(Symbols.image, size: 48, color: cs.onSurfaceVariant),
|
||||
Widget _buildSinglePhoto(BuildContext ctx, PhotoAttachment photo) {
|
||||
final imageUrl = photo.baseUrl ?? '';
|
||||
final width = photo.width?.toDouble() ?? 200;
|
||||
final height = photo.height?.toDouble() ?? 200;
|
||||
|
||||
final constrainedWidth = width.clamp(photoMinSize, photoMaxSize);
|
||||
final constrainedHeight = height.clamp(photoMinSize, photoMaxSize);
|
||||
|
||||
final bool matchTop = _hasPhotoWithCaption;
|
||||
final bool matchBottom = !_hasPhotoWithCaption;
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(
|
||||
matchTop ? bubbleBorderRadius : photoBorderRadius,
|
||||
),
|
||||
topRight: Radius.circular(
|
||||
matchTop ? bubbleBorderRadius : photoBorderRadius,
|
||||
),
|
||||
bottomLeft: Radius.circular(
|
||||
matchBottom ? (isMe ? bubbleBorderRadius : 4) : 4,
|
||||
),
|
||||
bottomRight: Radius.circular(
|
||||
matchBottom ? (isMe ? 4 : bubbleBorderRadius) : 4,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (imageUrl.isNotEmpty)
|
||||
Image.network(
|
||||
imageUrl,
|
||||
width: constrainedWidth,
|
||||
height: constrainedHeight,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _buildPhotoPlaceholder(
|
||||
ctx,
|
||||
constrainedWidth,
|
||||
constrainedHeight,
|
||||
),
|
||||
)
|
||||
else
|
||||
_buildPhotoPlaceholder(ctx, constrainedWidth, constrainedHeight),
|
||||
Positioned.fill(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(onTap: () => _openPhotoViewer(ctx, photo)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVideoAttachment(
|
||||
Widget _buildTwoPhotos(
|
||||
BuildContext ctx,
|
||||
dynamic url,
|
||||
dynamic duration,
|
||||
PhotoAttachment p1,
|
||||
PhotoAttachment p2,
|
||||
) {
|
||||
final cs = Theme.of(ctx).colorScheme;
|
||||
final videoUrl = url?.toString() ?? '';
|
||||
final durationSec = duration as int? ?? 0;
|
||||
final durationStr = _formatDuration(durationSec);
|
||||
final matchTop =
|
||||
_hasMultiplePhotosNoCaption && shape == BubbleShape.singleTop;
|
||||
final matchBottom =
|
||||
_hasMultiplePhotosNoCaption && shape == BubbleShape.singleBottom;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 200,
|
||||
height: 150,
|
||||
color: cs.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Symbols.videocam,
|
||||
size: 48,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(
|
||||
matchTop ? bubbleBorderRadius : photoBorderRadius,
|
||||
),
|
||||
topRight: Radius.circular(
|
||||
matchTop ? bubbleBorderRadius : photoBorderRadius,
|
||||
),
|
||||
bottomLeft: Radius.circular(matchBottom ? 4 : photoBorderRadius),
|
||||
bottomRight: Radius.circular(
|
||||
matchBottom ? (isMe ? 4 : bubbleBorderRadius) : photoBorderRadius,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildPhotoTile(ctx, p1),
|
||||
const SizedBox(width: 2),
|
||||
_buildPhotoTile(ctx, p2),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhotoGrid(BuildContext ctx, List<PhotoAttachment> photos) {
|
||||
final displayCount = photos.length > 4 ? 4 : photos.length;
|
||||
final remaining = photos.length - 4;
|
||||
|
||||
final matchTop =
|
||||
_hasMultiplePhotosNoCaption && shape == BubbleShape.singleTop;
|
||||
final matchBottom =
|
||||
_hasMultiplePhotosNoCaption && shape == BubbleShape.singleBottom;
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(
|
||||
matchTop ? bubbleBorderRadius : photoBorderRadius,
|
||||
),
|
||||
topRight: Radius.circular(
|
||||
matchTop ? bubbleBorderRadius : photoBorderRadius,
|
||||
),
|
||||
bottomLeft: Radius.circular(matchBottom ? 4 : photoBorderRadius),
|
||||
bottomRight: Radius.circular(
|
||||
matchBottom ? (isMe ? 4 : bubbleBorderRadius) : photoBorderRadius,
|
||||
),
|
||||
),
|
||||
child: GridView.count(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 2,
|
||||
crossAxisSpacing: 2,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: List.generate(displayCount, (i) {
|
||||
if (i == 3 && remaining > 0) {
|
||||
return _buildPhotoTileWithOverlay(ctx, photos[i], '+$remaining');
|
||||
}
|
||||
return _buildPhotoTile(ctx, photos[i]);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhotoTile(BuildContext ctx, PhotoAttachment photo) {
|
||||
final imageUrl = photo.baseUrl ?? '';
|
||||
return Expanded(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (imageUrl.isNotEmpty)
|
||||
Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
_buildPhotoPlaceholder(ctx, 100, 100),
|
||||
)
|
||||
else
|
||||
_buildPhotoPlaceholder(ctx, 100, 100),
|
||||
Positioned.fill(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(onTap: () => _openPhotoViewer(ctx, photo)),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 8,
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhotoTileWithOverlay(
|
||||
BuildContext ctx,
|
||||
PhotoAttachment photo,
|
||||
String overlay,
|
||||
) {
|
||||
final imageUrl = photo.baseUrl ?? '';
|
||||
return Expanded(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (imageUrl.isNotEmpty)
|
||||
Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
_buildPhotoPlaceholder(ctx, 100, 100),
|
||||
)
|
||||
else
|
||||
_buildPhotoPlaceholder(ctx, 100, 100),
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: Colors.black45,
|
||||
child: Center(
|
||||
child: Text(
|
||||
durationStr,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
overlay,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (videoUrl.isNotEmpty)
|
||||
Positioned.fill(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(onTap: () {}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_buildMeta(ctx),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFileAttachment(BuildContext ctx, String fileName, dynamic url) {
|
||||
Widget _buildPhotoPlaceholder(
|
||||
BuildContext ctx,
|
||||
double w,
|
||||
double h, {
|
||||
VoidCallback? onRetry,
|
||||
}) {
|
||||
final cs = Theme.of(ctx).colorScheme;
|
||||
return Container(
|
||||
width: w,
|
||||
height: h,
|
||||
color: cs.surfaceContainerHighest,
|
||||
child: onRetry != null
|
||||
? Center(
|
||||
child: IconButton(
|
||||
icon: Icon(Symbols.refresh, color: cs.onSurfaceVariant),
|
||||
onPressed: onRetry,
|
||||
tooltip: 'Retry',
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(Symbols.image, size: 48, color: cs.onSurfaceVariant),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCaption(BuildContext ctx) {
|
||||
final cs = Theme.of(ctx).colorScheme;
|
||||
final isDark = cs.brightness == Brightness.dark;
|
||||
final textColor = isMe
|
||||
? Colors.white
|
||||
: (isDark ? cs.onSurface : const Color(0xFF1C1C1E));
|
||||
|
||||
return Text(
|
||||
message.text ?? '',
|
||||
style: TextStyle(color: textColor, fontSize: 16, height: 1.3),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGenericAttachment(
|
||||
BuildContext ctx,
|
||||
MessageAttachment attachment,
|
||||
) {
|
||||
final cs = Theme.of(ctx).colorScheme;
|
||||
final isDark = cs.brightness == Brightness.dark;
|
||||
final textColor = isMe
|
||||
? Colors.white
|
||||
: (isDark ? cs.onSurface : const Color(0xFF1C1C1E));
|
||||
|
||||
switch (attachment.type) {
|
||||
case AttachmentType.video:
|
||||
return _buildVideoAttachment(ctx, attachment);
|
||||
case AttachmentType.file:
|
||||
return _buildFileAttachment(ctx, attachment);
|
||||
case AttachmentType.sticker:
|
||||
return _buildStickerAttachment(ctx, attachment);
|
||||
default:
|
||||
return _buildTextContent(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildVideoAttachment(BuildContext ctx, MessageAttachment video) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(photoBorderRadius),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 200,
|
||||
height: 150,
|
||||
color: Theme.of(ctx).colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Symbols.videocam,
|
||||
size: 48,
|
||||
color: Theme.of(ctx).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(onTap: () {}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_buildMeta(ctx),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFileAttachment(BuildContext ctx, MessageAttachment file) {
|
||||
final cs = Theme.of(ctx).colorScheme;
|
||||
final isDark = cs.brightness == Brightness.dark;
|
||||
final textColor = isMe
|
||||
? Colors.white
|
||||
: (isDark ? cs.onSurface : const Color(0xFF1C1C1E));
|
||||
final name = (file as dynamic).name as String? ?? 'File';
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
@@ -459,7 +752,7 @@ class MessageBubble extends StatelessWidget {
|
||||
color: isMe
|
||||
? Colors.white.withValues(alpha: 0.15)
|
||||
: (isDark ? cs.surface : const Color(0xFFFFFFFF)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(photoBorderRadius),
|
||||
border: Border.all(
|
||||
color: isMe
|
||||
? Colors.white.withValues(alpha: 0.3)
|
||||
@@ -489,7 +782,7 @@ class MessageBubble extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
fileName,
|
||||
name,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 14,
|
||||
@@ -518,6 +811,33 @@ class MessageBubble extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStickerAttachment(BuildContext ctx, MessageAttachment sticker) {
|
||||
final preview = (sticker as dynamic).previewData as String? ?? '';
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(photoBorderRadius),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (preview.isNotEmpty)
|
||||
Image.network(
|
||||
preview,
|
||||
width: 150,
|
||||
height: 150,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
_buildPhotoPlaceholder(ctx, 150, 150),
|
||||
)
|
||||
else
|
||||
_buildPhotoPlaceholder(ctx, 150, 150),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openPhotoViewer(BuildContext ctx, PhotoAttachment photo) {
|
||||
// TODO: Open photo viewer
|
||||
}
|
||||
|
||||
Widget _buildVoiceContent(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isDark = cs.brightness == Brightness.dark;
|
||||
@@ -553,6 +873,7 @@ class MessageBubble extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(message.time),
|
||||
@@ -564,6 +885,31 @@ class MessageBubble extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompactTime(BuildContext ctx) {
|
||||
final cs = Theme.of(ctx).colorScheme;
|
||||
final isDark = cs.brightness == Brightness.dark;
|
||||
final bgColor = isMe
|
||||
? Colors.black.withValues(alpha: 0.4)
|
||||
: Colors.black.withValues(alpha: 0.5);
|
||||
final textColor = Colors.white;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
_formatTime(message.time),
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusIcon(BuildContext context) {
|
||||
final status = message.status;
|
||||
IconData icon;
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
enum AttachmentType {
|
||||
photo,
|
||||
video,
|
||||
audio,
|
||||
file,
|
||||
contact,
|
||||
location,
|
||||
sticker,
|
||||
control,
|
||||
}
|
||||
|
||||
abstract class MessageAttachment {
|
||||
final AttachmentType type;
|
||||
final String? previewData;
|
||||
final String? baseUrl;
|
||||
final String? fileUrl;
|
||||
|
||||
const MessageAttachment({
|
||||
required this.type,
|
||||
this.previewData,
|
||||
this.baseUrl,
|
||||
this.fileUrl,
|
||||
});
|
||||
|
||||
factory MessageAttachment.fromMap(Map<String, dynamic> map) {
|
||||
final type = (map['_type'] as String? ?? '').toUpperCase();
|
||||
switch (type) {
|
||||
case 'PHOTO':
|
||||
return PhotoAttachment.fromMap(map);
|
||||
case 'VIDEO':
|
||||
return VideoAttachment.fromMap(map);
|
||||
case 'AUDIO':
|
||||
return AudioAttachment.fromMap(map);
|
||||
case 'FILE':
|
||||
return FileAttachment.fromMap(map);
|
||||
case 'STICKER':
|
||||
return StickerAttachment.fromMap(map);
|
||||
case 'CONTACT':
|
||||
return ContactAttachment.fromMap(map);
|
||||
case 'LOCATION':
|
||||
return LocationAttachment.fromMap(map);
|
||||
case 'CONTROL':
|
||||
return ControlAttachment.fromMap(map);
|
||||
default:
|
||||
return UnknownAttachment(map);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap();
|
||||
}
|
||||
|
||||
class PhotoAttachment extends MessageAttachment {
|
||||
final int? photoId;
|
||||
final String? photoToken;
|
||||
final int? width;
|
||||
final int? height;
|
||||
final int? size;
|
||||
|
||||
const PhotoAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.photoId,
|
||||
this.photoToken,
|
||||
this.width,
|
||||
this.height,
|
||||
this.size,
|
||||
}) : super(type: AttachmentType.photo);
|
||||
|
||||
factory PhotoAttachment.fromMap(Map<String, dynamic> map) {
|
||||
String? previewStr;
|
||||
final previewRaw = map['previewData'];
|
||||
if (previewRaw is String) {
|
||||
previewStr = previewRaw;
|
||||
} else if (previewRaw is List) {
|
||||
try {
|
||||
final bytes = List<int>.from(previewRaw);
|
||||
final base64 = String.fromCharCodes(bytes);
|
||||
previewStr = 'data:image/webp;base64,$base64';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return PhotoAttachment(
|
||||
previewData: previewStr,
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
photoId: map['photoId'] as int?,
|
||||
photoToken: map['photoToken'] as String?,
|
||||
width: map['width'] as int?,
|
||||
height: map['height'] as int?,
|
||||
size: map['size'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'PHOTO',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'photoId': photoId,
|
||||
'photoToken': photoToken,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'size': size,
|
||||
};
|
||||
}
|
||||
|
||||
class VideoAttachment extends MessageAttachment {
|
||||
final int? videoId;
|
||||
final String? videoToken;
|
||||
final int? width;
|
||||
final int? height;
|
||||
final int? duration;
|
||||
final int? size;
|
||||
|
||||
const VideoAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.videoId,
|
||||
this.videoToken,
|
||||
this.width,
|
||||
this.height,
|
||||
this.duration,
|
||||
this.size,
|
||||
}) : super(type: AttachmentType.video);
|
||||
|
||||
factory VideoAttachment.fromMap(Map<String, dynamic> map) {
|
||||
return VideoAttachment(
|
||||
previewData: map['previewData'] as String?,
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
videoId: map['videoId'] as int?,
|
||||
videoToken: map['videoToken'] as String?,
|
||||
width: map['width'] as int?,
|
||||
height: map['height'] as int?,
|
||||
duration: map['duration'] as int?,
|
||||
size: map['size'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'VIDEO',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'videoId': videoId,
|
||||
'videoToken': videoToken,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'duration': duration,
|
||||
'size': size,
|
||||
};
|
||||
}
|
||||
|
||||
class AudioAttachment extends MessageAttachment {
|
||||
final int? audioId;
|
||||
final String? audioToken;
|
||||
final int? duration;
|
||||
final int? size;
|
||||
final String? waveform;
|
||||
|
||||
const AudioAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.audioId,
|
||||
this.audioToken,
|
||||
this.duration,
|
||||
this.size,
|
||||
this.waveform,
|
||||
}) : super(type: AttachmentType.audio);
|
||||
|
||||
factory AudioAttachment.fromMap(Map<String, dynamic> map) {
|
||||
return AudioAttachment(
|
||||
previewData: map['previewData'] as String?,
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
audioId: map['audioId'] as int?,
|
||||
audioToken: map['audioToken'] as String?,
|
||||
duration: map['duration'] as int?,
|
||||
size: map['size'] as int?,
|
||||
waveform: map['waveform'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'AUDIO',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'audioId': audioId,
|
||||
'audioToken': audioToken,
|
||||
'duration': duration,
|
||||
'size': size,
|
||||
'waveform': waveform,
|
||||
};
|
||||
}
|
||||
|
||||
class FileAttachment extends MessageAttachment {
|
||||
final int? fileId;
|
||||
final String? fileToken;
|
||||
final String? name;
|
||||
final int? size;
|
||||
|
||||
const FileAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.fileId,
|
||||
this.fileToken,
|
||||
this.name,
|
||||
this.size,
|
||||
}) : super(type: AttachmentType.file);
|
||||
|
||||
factory FileAttachment.fromMap(Map<String, dynamic> map) {
|
||||
return FileAttachment(
|
||||
previewData: map['previewData'] as String?,
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
fileId: map['fileId'] as int?,
|
||||
fileToken: map['fileToken'] as String?,
|
||||
name: map['name'] as String?,
|
||||
size: map['size'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'FILE',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'fileId': fileId,
|
||||
'fileToken': fileToken,
|
||||
'name': name,
|
||||
'size': size,
|
||||
};
|
||||
}
|
||||
|
||||
class StickerAttachment extends MessageAttachment {
|
||||
final String? stickerId;
|
||||
final String? stickerPackId;
|
||||
final int? width;
|
||||
final int? height;
|
||||
|
||||
const StickerAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.stickerId,
|
||||
this.stickerPackId,
|
||||
this.width,
|
||||
this.height,
|
||||
}) : super(type: AttachmentType.sticker);
|
||||
|
||||
factory StickerAttachment.fromMap(Map<String, dynamic> map) {
|
||||
return StickerAttachment(
|
||||
previewData: map['previewData'] as String?,
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
stickerId: map['stickerId'] as String?,
|
||||
stickerPackId: map['stickerPackId'] as String?,
|
||||
width: map['width'] as int?,
|
||||
height: map['height'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'STICKER',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'stickerId': stickerId,
|
||||
'stickerPackId': stickerPackId,
|
||||
'width': width,
|
||||
'height': height,
|
||||
};
|
||||
}
|
||||
|
||||
class ContactAttachment extends MessageAttachment {
|
||||
final String? userId;
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
final String? phoneNumber;
|
||||
|
||||
const ContactAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.userId,
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
this.phoneNumber,
|
||||
}) : super(type: AttachmentType.contact);
|
||||
|
||||
factory ContactAttachment.fromMap(Map<String, dynamic> map) {
|
||||
return ContactAttachment(
|
||||
previewData: map['previewData'] as String?,
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
userId: map['userId'] as String?,
|
||||
firstName: map['firstName'] as String?,
|
||||
lastName: map['lastName'] as String?,
|
||||
phoneNumber: map['phoneNumber'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'CONTACT',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'userId': userId,
|
||||
'firstName': firstName,
|
||||
'lastName': lastName,
|
||||
'phoneNumber': phoneNumber,
|
||||
};
|
||||
}
|
||||
|
||||
class LocationAttachment extends MessageAttachment {
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final String? title;
|
||||
final String? address;
|
||||
|
||||
const LocationAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.title,
|
||||
this.address,
|
||||
}) : super(type: AttachmentType.location);
|
||||
|
||||
factory LocationAttachment.fromMap(Map<String, dynamic> map) {
|
||||
return LocationAttachment(
|
||||
previewData: map['previewData'] as String?,
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
latitude: (map['latitude'] as num?)?.toDouble(),
|
||||
longitude: (map['longitude'] as num?)?.toDouble(),
|
||||
title: map['title'] as String?,
|
||||
address: map['address'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'LOCATION',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'title': title,
|
||||
'address': address,
|
||||
};
|
||||
}
|
||||
|
||||
class ControlAttachment extends MessageAttachment {
|
||||
final String? event;
|
||||
final String? title;
|
||||
final List<int>? userIds;
|
||||
|
||||
const ControlAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.event,
|
||||
this.title,
|
||||
this.userIds,
|
||||
}) : super(type: AttachmentType.control);
|
||||
|
||||
factory ControlAttachment.fromMap(Map<String, dynamic> map) {
|
||||
return ControlAttachment(
|
||||
previewData: map['previewData'] as String?,
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
event: map['event'] as String?,
|
||||
title: map['title'] as String?,
|
||||
userIds: (map['userIds'] as List?)?.cast<int>(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'CONTROL',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'event': event,
|
||||
'title': title,
|
||||
'userIds': userIds,
|
||||
};
|
||||
}
|
||||
|
||||
class UnknownAttachment extends MessageAttachment {
|
||||
final Map<String, dynamic> rawData;
|
||||
|
||||
const UnknownAttachment(this.rawData) : super(type: AttachmentType.photo);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => rawData;
|
||||
}
|
||||
Reference in New Issue
Block a user